// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.
using System;
using System.Collections.Generic;
using System.Numerics;
using SixLabors.Fonts.Rendering;
namespace SixLabors.Fonts {
///
/// Represents text prepared for repeated line layout, measurement, and rendering.
///
public sealed partial class TextBlock
{
///
/// Initializes a new instance of the class.
///
/// The text to prepare.
/// The text options used to prepare, measure, and render the block.
///
/// is ignored while preparing the block; pass the wrapping length
/// to the measurement or rendering method. Use -1 there to disable wrapping.
///
public TextBlock(string text, TextOptions options)
: this(text.AsSpan(), options)
{
}
///
/// Initializes a new instance of the class.
///
/// The text to prepare.
/// The text options used to prepare, measure, and render the block.
///
/// is ignored while preparing the block; pass the wrapping length
/// to the measurement or rendering method. Use -1 there to disable wrapping.
///
public TextBlock(ReadOnlySpan text, TextOptions options)
{
this.Options = options;
if (text.IsEmpty)
{
this.LogicalLine = new(new TextLine(), [], [], []);
return;
}
ShapedText shaped = TextLayout.ShapeText(text, options);
this.LogicalLine = TextLayout.ComposeLogicalLine(shaped, text, options);
}
///
/// Gets the text options used by this block.
///
internal TextOptions Options { get; }
///
/// Gets the prepared logical line and line break opportunities.
///
internal LogicalTextLine LogicalLine { get; }
///
/// Breaks this block into lines for the supplied wrapping length.
///
/// The wrapping length in pixels. Use -1 to disable wrapping.
/// The line-broken text box.
internal TextBox BreakLines(float wrappingLength)
=> TextLayout.BreakLines(this.LogicalLine, this.Options, wrappingLength);
///
/// Measures the full set of layout metrics for this block at the supplied wrapping length.
///
/// The wrapping length in pixels. Use -1 to disable wrapping.
/// A instance containing every measurement for the laid-out text.
public TextMetrics Measure(float wrappingLength)
{
TextBox textBox = this.BreakLines(wrappingLength);
float dpi = this.Options.Dpi;
bool isHorizontal = this.Options.LayoutMode.IsHorizontal();
FontRectangle advance = GetAdvance(textBox, dpi, isHorizontal);
GraphemeMetrics[] graphemes = new GraphemeMetrics[CountGraphemeMetrics(textBox)];
WordMetrics[] wordMetrics = new WordMetrics[this.LogicalLine.WordSegments.Count];
GraphemeAndWordMetricsVisitor visitor = new(dpi, graphemes, this.LogicalLine.WordSegments, wordMetrics);
TextLayout.LayoutText(textBox, this.Options, wrappingLength, ref visitor);
FontRectangle bounds = visitor.Bounds();
FontRectangle absoluteAdvance = new(this.Options.Origin.X, this.Options.Origin.Y, advance.Width, advance.Height);
FontRectangle renderableBounds = FontRectangle.Union(absoluteAdvance, bounds);
LineMetrics[] lineMetrics = GetLineMetrics(textBox, this.Options, wrappingLength);
return new TextMetrics(
this,
textBox,
wrappingLength,
advance,
bounds,
renderableBounds,
textBox.TextLines.Count,
graphemes,
lineMetrics,
wordMetrics);
}
///
/// Measures the logical advance of this block at the supplied wrapping length.
///
/// The wrapping length in pixels. Use -1 to disable wrapping.
/// The logical advance rectangle.
public FontRectangle MeasureAdvance(float wrappingLength)
{
TextBox textBox = this.BreakLines(wrappingLength);
return GetAdvance(textBox, this.Options.Dpi, this.Options.LayoutMode.IsHorizontal());
}
///
/// Measures the rendered glyph bounds of this block at the supplied wrapping length.
///
/// The wrapping length in pixels. Use -1 to disable wrapping.
/// The rendered glyph bounds.
public FontRectangle MeasureBounds(float wrappingLength)
=> GetBounds(this.BreakLines(wrappingLength), this.Options, wrappingLength);
///
/// Measures the union of logical advance and rendered glyph bounds at the supplied wrapping length.
///
/// The wrapping length in pixels. Use -1 to disable wrapping.
/// The full renderable bounds.
public FontRectangle MeasureRenderableBounds(float wrappingLength)
{
TextBox textBox = this.BreakLines(wrappingLength);
FontRectangle advance = GetAdvance(textBox, this.Options.Dpi, this.Options.LayoutMode.IsHorizontal());
FontRectangle absoluteAdvance = new(this.Options.Origin.X, this.Options.Origin.Y, advance.Width, advance.Height);
FontRectangle bounds = GetBounds(textBox, this.Options, wrappingLength);
return FontRectangle.Union(absoluteAdvance, bounds);
}
///
/// Gets the positioned metrics of each laid-out glyph entry.
///
/// The wrapping length in pixels. Use -1 to disable wrapping.
/// A read-only memory region containing per-glyph metrics entries.
public ReadOnlyMemory GetGlyphMetrics(float wrappingLength)
=> this.GetGlyphMetricsArray(wrappingLength);
///
/// Gets the positioned metrics of each laid-out grapheme.
///
/// The wrapping length in pixels. Use -1 to disable wrapping.
/// A read-only memory region containing per-grapheme metrics entries.
public ReadOnlyMemory GetGraphemeMetrics(float wrappingLength)
{
TextBox textBox = this.BreakLines(wrappingLength);
return GetGraphemeMetricsArray(textBox, this.Options, wrappingLength);
}
///
/// Gets the positioned metrics of each Unicode word-boundary segment.
///
/// The wrapping length in pixels. Use -1 to disable wrapping.
/// A read-only memory region containing per-word-boundary segment metrics entries.
public ReadOnlyMemory GetWordMetrics(float wrappingLength)
{
TextBox textBox = this.BreakLines(wrappingLength);
WordMetrics[] wordMetrics = new WordMetrics[this.LogicalLine.WordSegments.Count];
WordMetricsVisitor visitor = new(this.LogicalLine.WordSegments, wordMetrics, this.Options.Dpi);
TextLayout.LayoutText(textBox, this.Options, wrappingLength, ref visitor);
return wordMetrics;
}
///
/// Gets the number of laid-out lines at the supplied wrapping length.
///
/// The wrapping length in pixels. Use -1 to disable wrapping.
/// The laid-out line count.
public int CountLines(float wrappingLength)
=> this.BreakLines(wrappingLength).TextLines.Count;
///
/// Gets per-line layout metrics at the supplied wrapping length.
///
/// The wrapping length in pixels. Use -1 to disable wrapping.
/// A read-only memory region containing in pixel units.
public ReadOnlyMemory GetLineMetrics(float wrappingLength)
=> GetLineMetrics(this.BreakLines(wrappingLength), this.Options, wrappingLength);
///
/// Gets visual line layouts for this block at the supplied wrapping length.
///
///
/// The returned memory contains every laid-out line, including lines produced by hard line breaks.
///
/// The wrapping length in pixels. Use -1 to disable wrapping.
/// A read-only memory region containing entries in final layout order.
public ReadOnlyMemory GetLineLayouts(float wrappingLength)
{
TextBox textBox = this.BreakLines(wrappingLength);
if (textBox.TextLines.Count == 0)
{
return ReadOnlyMemory.Empty;
}
return this.GetLineLayouts(textBox, wrappingLength);
}
///
/// Creates an enumerator that lays out this block one line at a time.
///
/// A line layout enumerator for this block.
public LineLayoutEnumerator EnumerateLineLayouts()
=> new(this);
///
/// Gets a single line layout for an already line-broken text line.
///
/// The line to lay out.
/// The wrapping length in pixels.
/// The block-level text direction used for alignment.
/// The line layout for the supplied line.
internal LineLayout GetLineLayout(
TextLine textLine,
float wrappingLength,
TextDirection textDirection)
{
TextBox textBox = new([textLine], textDirection);
return this.GetLineLayouts(textBox, wrappingLength)[0];
}
///
/// Gets visual line layouts for an already line-broken text box.
///
/// The shaped and line-broken text box.
/// The wrapping length in pixels.
/// The line layouts for the supplied text box.
private LineLayout[] GetLineLayouts(TextBox textBox, float wrappingLength)
{
GraphemeMetrics[] graphemes = new GraphemeMetrics[CountGraphemeMetrics(textBox)];
LineMetrics[] metrics = GetLineMetrics(textBox, this.Options, wrappingLength);
LineLayout[] lines = new LineLayout[textBox.TextLines.Count];
WordMetrics[] wordMetrics = new WordMetrics[this.LogicalLine.WordSegments.Count];
LineLayoutVisitor visitor = new(textBox, this.Options, wrappingLength, graphemes, metrics, lines, this.LogicalLine.WordSegments, wordMetrics, this.Options.Dpi);
TextLayout.LayoutText(textBox, this.Options, wrappingLength, ref visitor);
return lines;
}
///
/// Renders this block to the supplied glyph renderer at the supplied wrapping length.
///
/// The target renderer.
/// The wrapping length in pixels. Use -1 to disable wrapping.
public void RenderTo(IGlyphRenderer renderer, float wrappingLength)
{
TextBox textBox = this.BreakLines(wrappingLength);
FontRectangle rect = GetBounds(textBox, this.Options, wrappingLength);
RenderTo(renderer, textBox, this.Options, wrappingLength, rect);
}
///
/// Renders an already line-broken text box to the supplied glyph renderer.
///
/// The target renderer.
/// The shaped and line-broken text box.
/// The text options used for rendering.
/// The wrapping length in pixels.
/// The bounds passed to the renderer.
/// The line index to render, or -1 to render every line.
internal static void RenderTo(
IGlyphRenderer renderer,
TextBox textBox,
TextOptions options,
float wrappingLength,
in FontRectangle bounds,
int lineIndex = -1)
{
renderer.BeginText(in bounds);
GlyphRendererVisitor visitor = new(renderer, options, lineIndex);
TextLayout.LayoutText(textBox, options, wrappingLength, ref visitor);
renderer.EndText();
}
///
/// Measures the rendered glyph bounds of an already line-broken text box.
///
/// The shaped and line-broken text box.
/// The text options used for layout.
/// The wrapping length in pixels. Use -1 to disable wrapping.
/// The union of the rendered glyph bounds.
private static FontRectangle GetBounds(TextBox textBox, TextOptions options, float wrappingLength)
{
if (textBox.TextLines.Count == 0)
{
return FontRectangle.Empty;
}
RenderedRectangleAccumulator visitor = new(options.Dpi);
TextLayout.LayoutText(textBox, options, wrappingLength, ref visitor);
return visitor.Result();
}
///
/// Gets per-line layout metrics for an already line-broken text box.
///
/// The shaped and line-broken text box.
/// The text options used to calculate line metrics.
/// The wrapping length in pixels. Use -1 to disable wrapping.
/// An array of in pixel units.
private static LineMetrics[] GetLineMetrics(TextBox textBox, TextOptions options, float wrappingLength)
{
if (textBox.TextLines.Count == 0)
{
return [];
}
LineMetrics[] metrics = new LineMetrics[textBox.TextLines.Count];
// Determine the line-box extent used for alignment within the flow direction.
float maxScaledAdvance = textBox.ScaledMaxAdvance();
if (options.TextAlignment != TextAlignment.Start && wrappingLength > 0)
{
maxScaledAdvance = MathF.Max(wrappingLength / options.Dpi, maxScaledAdvance);
}
TextDirection direction = textBox.TextDirection();
LayoutMode layoutMode = options.LayoutMode;
bool isHorizontalLayout = layoutMode.IsHorizontal();
float lineOffset = isHorizontalLayout ? options.Origin.Y : options.Origin.X;
bool reverseLineOrder = layoutMode is
LayoutMode.HorizontalBottomTop
or LayoutMode.VerticalRightLeft
or LayoutMode.VerticalMixedRightLeft;
int i = reverseLineOrder ? textBox.TextLines.Count - 1 : 0;
int step = reverseLineOrder ? -1 : 1;
int graphemeOffset = 0;
while (i >= 0 && i < textBox.TextLines.Count)
{
TextLine line = textBox.TextLines[i];
// Calculate the line start position in the current flow direction.
float offset = isHorizontalLayout
? TextLayout.CalculateLineOffsetX(
line.ScaledLineAdvance,
maxScaledAdvance,
options.HorizontalAlignment,
options.TextAlignment,
direction)
: TextLayout.CalculateLineOffsetY(
line.ScaledLineAdvance,
maxScaledAdvance,
options.VerticalAlignment,
options.TextAlignment,
direction);
// Delta captured during layout when ascender/descender were symmetrically
// adjusted to match browser-like line-box behavior.
float delta = line.ScaledMaxDelta;
// Core typographic region within the line box.
// We add back 2*delta to recover the pre-adjustment ascender+descender span
// used for deriving guide positions.
float coreHeight = line.ScaledMaxAscender + line.ScaledMaxDescender + (2 * delta);
// Additional leading in the line box (for example from line spacing).
float extra = line.ScaledMaxLineHeight - coreHeight;
// Baseline position within the line box.
float baseline = (extra * 0.5f) + line.ScaledMaxAscender + delta;
// Ascender line position relative to the same origin.
float ascender = baseline - line.ScaledMaxAscender + delta;
// Descender line position relative to the same origin.
float descender = baseline + line.ScaledMaxDescender + delta;
Vector2 start = isHorizontalLayout
? new(options.Origin.X + (offset * options.Dpi), lineOffset)
: new(lineOffset, options.Origin.Y + (offset * options.Dpi));
Vector2 extent = isHorizontalLayout
? new(line.ScaledLineAdvance * options.Dpi, line.ScaledMaxLineHeight * options.Dpi)
: new(line.ScaledMaxLineHeight * options.Dpi, line.ScaledLineAdvance * options.Dpi);
// Bidi reordering mutates entries into visual order, so the source
// start is the minimum original source index rather than line[0].
int stringIndex = line[0].StringIndex;
int graphemeIndex = line[0].GraphemeIndex;
for (int j = 1; j < line.Count; j++)
{
stringIndex = Math.Min(stringIndex, line[j].StringIndex);
graphemeIndex = Math.Min(graphemeIndex, line[j].GraphemeIndex);
}
metrics[i] = new LineMetrics(
ascender * options.Dpi,
baseline * options.Dpi,
descender * options.Dpi,
line.ScaledMaxLineHeight * options.Dpi,
start,
extent,
stringIndex,
graphemeIndex,
line.GraphemeCount,
graphemeOffset);
graphemeOffset += line.GraphemeCount;
lineOffset += line.ScaledMaxLineHeight * options.Dpi;
i += step;
}
return metrics;
}
///
/// Counts grapheme metrics entries across all lines in an already line-broken text box.
///
/// The shaped and line-broken text box.
/// The number of grapheme metrics entries.
private static int CountGraphemeMetrics(TextBox textBox)
{
int count = 0;
for (int i = 0; i < textBox.TextLines.Count; i++)
{
count += textBox.TextLines[i].GraphemeCount;
}
return count;
}
///
/// Gets grapheme metrics entries by streaming laid-out glyphs.
///
/// The shaped and line-broken text box.
/// The text options used for layout.
/// The wrapping length in pixels. Use -1 to disable wrapping.
/// The grapheme metrics entries.
internal static GraphemeMetrics[] GetGraphemeMetricsArray(
TextBox textBox,
TextOptions options,
float wrappingLength)
{
int count = CountGraphemeMetrics(textBox);
if (count == 0)
{
return [];
}
GraphemeMetrics[] graphemes = new GraphemeMetrics[count];
GraphemeMetricsVisitor visitor = new(options.Dpi, graphemes);
TextLayout.LayoutText(textBox, options, wrappingLength, ref visitor);
return graphemes;
}
///
/// Finds the source-order word-boundary range containing the supplied grapheme index.
///
/// The source-order word-boundary segments.
/// The grapheme index to locate.
/// The matching word metrics index, or -1 when no range contains the grapheme.
private static int FindWordMetricIndex(List wordSegments, int graphemeIndex)
{
int min = 0;
int max = wordSegments.Count - 1;
while (min <= max)
{
int mid = (min + max) >> 1;
WordSegmentRun segment = wordSegments[mid];
if (graphemeIndex < segment.GraphemeStart)
{
max = mid - 1;
continue;
}
if (graphemeIndex >= segment.GraphemeEnd)
{
min = mid + 1;
continue;
}
return mid;
}
return -1;
}
///
/// Gets a value indicating whether positioned metrics have been added to a word segment.
///
/// The word metrics to inspect.
/// when a grapheme has been accumulated for the segment.
private static bool HasWordMetrics(in WordMetrics metrics)
// Default WordMetrics has no source range. Any real word segment has an exclusive end
// index, so the range is the sentinel that avoids treating FontRectangle.Empty as geometry.
=> metrics.GraphemeEnd != 0 || metrics.StringEnd != 0;
///
/// Gets one per-glyph metrics collection by streaming laid-out glyphs.
///
/// The wrapping length in pixels. Use -1 to disable wrapping.
/// The positioned glyph metrics.
internal GlyphMetrics[] GetGlyphMetricsArray(float wrappingLength)
{
TextBox textBox = this.BreakLines(wrappingLength);
return GetGlyphMetricsArray(textBox, this.Options, wrappingLength);
}
///
/// Gets one per-glyph metrics collection by streaming laid-out glyphs.
///
/// The shaped and line-broken text box.
/// The text options used for layout.
/// The wrapping length in pixels. Use -1 to disable wrapping.
/// The line index to collect, or -1 to collect every line.
/// The positioned glyph metrics.
internal static GlyphMetrics[] GetGlyphMetricsArray(
TextBox textBox,
TextOptions options,
float wrappingLength,
int lineIndex = -1)
{
int count = lineIndex < 0 ? textBox.CountGlyphLayouts() : textBox.TextLines[lineIndex].CountGlyphLayouts();
if (count == 0)
{
return [];
}
GlyphMetrics[] result = new GlyphMetrics[count];
GlyphMetricsVisitor visitor = new(result, options.Dpi, lineIndex);
TextLayout.LayoutText(textBox, options, wrappingLength, ref visitor);
return result;
}
///
/// Measures the logical advance of an already line-broken text box.
///
/// The shaped and line-broken text box.
/// The target DPI.
/// Whether the layout direction is horizontal.
/// The logical advance rectangle.
private static FontRectangle GetAdvance(TextBox textBox, float dpi, bool isHorizontalLayout)
{
if (textBox.TextLines.Count == 0)
{
return FontRectangle.Empty;
}
if (isHorizontalLayout)
{
float width = 0;
float height = 0;
for (int i = 0; i < textBox.TextLines.Count; i++)
{
TextLine line = textBox.TextLines[i];
width = MathF.Max(width, line.ScaledLineAdvance);
height += line.ScaledMaxLineHeight;
}
return new FontRectangle(0, 0, width * dpi, height * dpi);
}
float verticalWidth = 0;
float verticalHeight = 0;
for (int i = 0; i < textBox.TextLines.Count; i++)
{
TextLine line = textBox.TextLines[i];
verticalWidth += line.ScaledMaxLineHeight;
verticalHeight = MathF.Max(verticalHeight, line.ScaledLineAdvance);
}
return new FontRectangle(0, 0, verticalWidth * dpi, verticalHeight * dpi);
}
}
}