diff --git a/src/SixLabors.Fonts/TextLayout.Visitors.cs b/src/SixLabors.Fonts/TextLayout.Visitors.cs
new file mode 100644
index 00000000..f2678587
--- /dev/null
+++ b/src/SixLabors.Fonts/TextLayout.Visitors.cs
@@ -0,0 +1,107 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+namespace SixLabors.Fonts;
+
+///
+/// Visitor types for streaming laid-out glyphs through .
+///
+internal static partial class TextLayout
+{
+ ///
+ /// Receives laid-out glyphs streamed from .
+ /// Implementations are value types so the generic dispatch is specialized by the JIT and no boxing or
+ /// delegate allocation is required.
+ ///
+ internal interface IGlyphLayoutVisitor
+ {
+ ///
+ /// Invoked once for each laid-out glyph in layout order.
+ ///
+ /// The laid-out glyph.
+ public void Visit(in GlyphLayout glyph);
+ }
+
+ ///
+ /// Collects streamed glyphs into a .
+ ///
+ internal readonly struct GlyphLayoutCollector : IGlyphLayoutVisitor
+ {
+ ///
+ /// Initializes a new instance of the struct.
+ ///
+ /// The list to collect streamed glyphs into.
+ public GlyphLayoutCollector(List glyphs) => this.Glyphs = glyphs;
+
+ ///
+ /// Gets the accumulated glyphs.
+ ///
+ public List Glyphs { get; }
+
+ ///
+ public readonly void Visit(in GlyphLayout glyph) => this.Glyphs.Add(glyph);
+ }
+
+ ///
+ /// Accumulates the union of glyph ink bounds as glyphs are streamed, avoiding the allocation
+ /// of a and a second iteration pass.
+ ///
+ internal struct GlyphBoundsAccumulator : IGlyphLayoutVisitor
+ {
+ private readonly float dpi;
+ private float left;
+ private float top;
+ private float right;
+ private float bottom;
+ private bool any;
+
+ ///
+ /// Initializes a new instance of the struct.
+ ///
+ /// The device-independent pixels per unit for the containing .
+ public GlyphBoundsAccumulator(float dpi)
+ {
+ this.dpi = dpi;
+ this.left = float.MaxValue;
+ this.top = float.MaxValue;
+ this.right = float.MinValue;
+ this.bottom = float.MinValue;
+ this.any = false;
+ }
+
+ ///
+ public void Visit(in GlyphLayout glyph)
+ {
+ FontRectangle box = glyph.BoundingBox(this.dpi);
+
+ if (box.Left < this.left)
+ {
+ this.left = box.Left;
+ }
+
+ if (box.Top < this.top)
+ {
+ this.top = box.Top;
+ }
+
+ if (box.Right > this.right)
+ {
+ this.right = box.Right;
+ }
+
+ if (box.Bottom > this.bottom)
+ {
+ this.bottom = box.Bottom;
+ }
+
+ this.any = true;
+ }
+
+ ///
+ /// Returns the accumulated ink bounds, or if no glyphs were visited.
+ ///
+ /// The union of the ink bounds of all visited glyphs.
+ public readonly FontRectangle Result()
+ => this.any ? FontRectangle.FromLTRB(this.left, this.top, this.right, this.bottom) : FontRectangle.Empty;
+ }
+}
diff --git a/src/SixLabors.Fonts/TextLayout.cs b/src/SixLabors.Fonts/TextLayout.cs
index 760428c1..7fbab6c0 100644
--- a/src/SixLabors.Fonts/TextLayout.cs
+++ b/src/SixLabors.Fonts/TextLayout.cs
@@ -12,8 +12,19 @@ namespace SixLabors.Fonts;
///
/// Encapsulated logic or laying out text.
///
-internal static class TextLayout
+internal static partial class TextLayout
{
+ ///
+ /// Shapes the supplied text and returns every laid-out glyph in layout order.
+ ///
+ ///
+ /// Equivalent to followed by .
+ /// Prefer for callers that only need aggregate ink bounds,
+ /// or the streaming overload to avoid materializing the glyph list.
+ ///
+ /// The text to lay out.
+ /// The text shaping and layout options.
+ /// The laid-out glyphs in layout order, or an empty list when is empty.
public static IReadOnlyList GenerateLayout(ReadOnlySpan text, TextOptions options)
{
if (text.IsEmpty)
@@ -25,6 +36,17 @@ public static IReadOnlyList GenerateLayout(ReadOnlySpan text,
return LayoutText(textBox, options);
}
+ ///
+ /// Resolves the ordered sequence of instances that cover .
+ ///
+ ///
+ /// If is or empty, a single run covering the entire
+ /// grapheme range of using is returned. Otherwise the
+ /// supplied runs are ordered, gaps are filled with default-font runs, and overlapping ranges are trimmed.
+ ///
+ /// The text to partition into runs.
+ /// The text shaping options supplying the default font and optional user-defined runs.
+ /// The resolved runs that together cover the entire grapheme range of .
public static IReadOnlyList BuildTextRuns(ReadOnlySpan text, TextOptions options)
{
if (options.TextRuns is null || options.TextRuns.Count == 0)
@@ -85,6 +107,18 @@ public static IReadOnlyList BuildTextRuns(ReadOnlySpan text, Text
return textRuns;
}
+ ///
+ /// Shapes and line-breaks into a ready for layout.
+ ///
+ ///
+ /// Performs the font-run build, bidi analysis, GSUB/GPOS shaping (including fallback font
+ /// resolution for unmapped codepoints), and line breaking. The result is a sequence of
+ /// entries with resolved glyph metrics but no pen positioning — positioning
+ /// is applied later by .
+ ///
+ /// The text to process.
+ /// The text shaping options.
+ /// The shaped, line-broken text ready for glyph positioning.
internal static TextBox ProcessText(ReadOnlySpan text, TextOptions options)
{
// Gather the font and fallbacks.
@@ -197,10 +231,62 @@ internal static TextBox ProcessText(ReadOnlySpan text, TextOptions options
return BreakLines(text, options, bidiRuns, bidiMap, positionings, layoutMode);
}
- private static List LayoutText(TextBox textBox, TextOptions options)
+ ///
+ /// Lays out the supplied and materializes every glyph into a
+ /// .
+ ///
+ ///
+ /// Prefer the streaming overload when the caller only needs
+ /// aggregated state (for example ink bounds), to avoid allocating the list.
+ ///
+ /// The shaped and line-broken text.
+ /// The text shaping options used to shape .
+ /// The laid-out glyphs in layout order.
+ internal static List LayoutText(TextBox textBox, TextOptions options)
+ {
+ GlyphLayoutCollector visitor = new([]);
+ LayoutText(textBox, options, ref visitor);
+ return visitor.Glyphs;
+ }
+
+ ///
+ /// Lays out the supplied and returns the union of the ink bounds of
+ /// every emitted glyph in a single streaming pass.
+ ///
+ ///
+ /// Equivalent to iterating and unioning each
+ /// glyph's , but avoids materializing the glyph list and
+ /// the second iteration pass.
+ ///
+ /// The shaped and line-broken text.
+ /// The text shaping options used to shape .
+ ///
+ /// The union of the ink bounds of every laid-out glyph, or
+ /// if no glyphs were emitted.
+ ///
+ internal static FontRectangle GetBounds(TextBox textBox, TextOptions options)
+ {
+ GlyphBoundsAccumulator visitor = new(options.Dpi);
+ LayoutText(textBox, options, ref visitor);
+ return visitor.Result();
+ }
+
+ ///
+ /// Lays out the supplied , streaming each laid-out glyph through the
+ /// supplied in layout order.
+ ///
+ ///
+ /// The visitor type is constrained to a struct implementing
+ /// so the JIT specializes dispatch per visitor — no boxing or delegate allocation.
+ ///
+ /// The concrete visitor struct type.
+ /// The shaped and line-broken text.
+ /// The text shaping options used to shape .
+ /// The visitor that receives each laid-out glyph.
+ internal static void LayoutText(TextBox textBox, TextOptions options, ref TVisitor visitor)
+ where TVisitor : struct, IGlyphLayoutVisitor
{
LayoutMode layoutMode = options.LayoutMode;
- List glyphs = [];
Vector2 boxLocation = options.Origin / options.Dpi;
Vector2 penLocation = boxLocation;
@@ -219,7 +305,7 @@ private static List LayoutText(TextBox textBox, TextOptions options
{
for (int i = 0; i < textBox.TextLines.Count; i++)
{
- glyphs.AddRange(LayoutLineHorizontal(
+ LayoutLineHorizontal(
textBox,
textBox.TextLines[i],
direction,
@@ -227,7 +313,8 @@ private static List LayoutText(TextBox textBox, TextOptions options
options,
i,
ref boxLocation,
- ref penLocation));
+ ref penLocation,
+ ref visitor);
}
}
else if (layoutMode == LayoutMode.HorizontalBottomTop)
@@ -235,7 +322,7 @@ private static List LayoutText(TextBox textBox, TextOptions options
int index = 0;
for (int i = textBox.TextLines.Count - 1; i >= 0; i--)
{
- glyphs.AddRange(LayoutLineHorizontal(
+ LayoutLineHorizontal(
textBox,
textBox.TextLines[i],
direction,
@@ -243,14 +330,15 @@ private static List LayoutText(TextBox textBox, TextOptions options
options,
index++,
ref boxLocation,
- ref penLocation));
+ ref penLocation,
+ ref visitor);
}
}
else if (layoutMode is LayoutMode.VerticalLeftRight)
{
for (int i = 0; i < textBox.TextLines.Count; i++)
{
- glyphs.AddRange(LayoutLineVertical(
+ LayoutLineVertical(
textBox,
textBox.TextLines[i],
direction,
@@ -258,7 +346,8 @@ private static List LayoutText(TextBox textBox, TextOptions options
options,
i,
ref boxLocation,
- ref penLocation));
+ ref penLocation,
+ ref visitor);
}
}
else if (layoutMode is LayoutMode.VerticalRightLeft)
@@ -266,7 +355,7 @@ private static List LayoutText(TextBox textBox, TextOptions options
int index = 0;
for (int i = textBox.TextLines.Count - 1; i >= 0; i--)
{
- glyphs.AddRange(LayoutLineVertical(
+ LayoutLineVertical(
textBox,
textBox.TextLines[i],
direction,
@@ -274,14 +363,15 @@ private static List LayoutText(TextBox textBox, TextOptions options
options,
index++,
ref boxLocation,
- ref penLocation));
+ ref penLocation,
+ ref visitor);
}
}
else if (layoutMode is LayoutMode.VerticalMixedLeftRight)
{
for (int i = 0; i < textBox.TextLines.Count; i++)
{
- glyphs.AddRange(LayoutLineVerticalMixed(
+ LayoutLineVerticalMixed(
textBox,
textBox.TextLines[i],
direction,
@@ -289,7 +379,8 @@ private static List LayoutText(TextBox textBox, TextOptions options
options,
i,
ref boxLocation,
- ref penLocation));
+ ref penLocation,
+ ref visitor);
}
}
else
@@ -297,7 +388,7 @@ private static List LayoutText(TextBox textBox, TextOptions options
int index = 0;
for (int i = textBox.TextLines.Count - 1; i >= 0; i--)
{
- glyphs.AddRange(LayoutLineVerticalMixed(
+ LayoutLineVerticalMixed(
textBox,
textBox.TextLines[i],
direction,
@@ -305,14 +396,28 @@ private static List LayoutText(TextBox textBox, TextOptions options
options,
index++,
ref boxLocation,
- ref penLocation));
+ ref penLocation,
+ ref visitor);
}
}
-
- return glyphs;
}
- private static List LayoutLineHorizontal(
+ ///
+ /// Positions one line of horizontal text. Applies vertical-block alignment (on the first line),
+ /// horizontal-block alignment, per-line text alignment, and any first-line ink-overshoot
+ /// compensation, then streams each positioned glyph through .
+ ///
+ /// The concrete visitor struct type.
+ /// The containing text box (used to look up sibling lines for block alignment).
+ /// The line being laid out.
+ /// The resolved text direction for this line.
+ /// The widest scaled line advance in the block (or wrapping length).
+ /// The text shaping and layout options.
+ /// The zero-based visual index of this line within the block.
+ /// The running top-left position of the glyph boxes; advanced by this method.
+ /// The running pen position used for glyph placement; advanced by this method.
+ /// The visitor that receives each positioned glyph.
+ private static void LayoutLineHorizontal(
TextBox textBox,
TextLine textLine,
TextDirection direction,
@@ -320,7 +425,9 @@ private static List LayoutLineHorizontal(
TextOptions options,
int index,
ref Vector2 boxLocation,
- ref Vector2 penLocation)
+ ref Vector2 penLocation,
+ ref TVisitor visitor)
+ where TVisitor : struct, IGlyphLayoutVisitor
{
// Offset the location to center the line vertically.
bool isFirstLine = index == 0;
@@ -415,13 +522,13 @@ private static List LayoutLineHorizontal(
penLocation.X += offsetX;
- List glyphs = [];
+ bool emitted = false;
for (int i = 0; i < textLine.Count; i++)
{
TextLine.GlyphLayoutData data = textLine[i];
if (data.IsNewLine)
{
- glyphs.Add(new GlyphLayout(
+ visitor.Visit(new GlyphLayout(
new Glyph(data.Metrics[0], data.PointSize),
boxLocation,
penLocation,
@@ -437,13 +544,13 @@ private static List LayoutLineHorizontal(
penLocation.Y += yLineAdvance;
boxLocation.X = originX;
boxLocation.Y += advanceY;
- return glyphs;
+ return;
}
int j = 0;
foreach (GlyphMetrics metric in data.Metrics)
{
- glyphs.Add(new GlyphLayout(
+ visitor.Visit(new GlyphLayout(
new Glyph(metric, data.PointSize),
boxLocation,
penLocation + new Vector2(0, textLine.ScaledMaxAscender),
@@ -455,6 +562,7 @@ private static List LayoutLineHorizontal(
data.GraphemeIndex,
data.StringIndex));
+ emitted = true;
j++;
}
@@ -464,16 +572,30 @@ private static List LayoutLineHorizontal(
boxLocation.X = originX;
penLocation.X = originX;
- if (glyphs.Count > 0)
+ if (emitted)
{
penLocation.Y += yLineAdvance;
boxLocation.Y += advanceY;
}
-
- return glyphs;
}
- private static List LayoutLineVertical(
+ ///
+ /// Positions one line of vertical text ( and
+ /// ). All glyphs are treated as naturally vertical —
+ /// transformed (rotated) graphemes receive grapheme-level horizontal centering based on the
+ /// collective ink width of every entry sharing a grapheme index.
+ ///
+ /// The concrete visitor struct type.
+ /// The containing text box (used to look up sibling lines for block alignment).
+ /// The line being laid out.
+ /// The resolved text direction for this line.
+ /// The longest scaled line advance in the block (or wrapping length).
+ /// The text shaping and layout options.
+ /// The zero-based visual index of this line within the block.
+ /// The running top-left position of the glyph boxes; advanced by this method.
+ /// The running pen position used for glyph placement; advanced by this method.
+ /// The visitor that receives each positioned glyph.
+ private static void LayoutLineVertical(
TextBox textBox,
TextLine textLine,
TextDirection direction,
@@ -481,7 +603,9 @@ private static List LayoutLineVertical(
TextOptions options,
int index,
ref Vector2 boxLocation,
- ref Vector2 penLocation)
+ ref Vector2 penLocation,
+ ref TVisitor visitor)
+ where TVisitor : struct, IGlyphLayoutVisitor
{
float originX = penLocation.X;
float originY = penLocation.Y;
@@ -570,7 +694,7 @@ private static List LayoutLineVertical(
float lineOriginX = penLocation.X;
- List glyphs = new(textLine.Count);
+ bool emitted = false;
// Grapheme-scoped state for transformed glyph alignment.
//
@@ -591,7 +715,7 @@ private static List LayoutLineVertical(
TextLine.GlyphLayoutData data = textLine[i];
if (data.IsNewLine)
{
- glyphs.Add(new GlyphLayout(
+ visitor.Visit(new GlyphLayout(
new Glyph(data.Metrics[0], data.PointSize),
boxLocation,
penLocation,
@@ -607,7 +731,7 @@ private static List LayoutLineVertical(
boxLocation.Y = originY;
penLocation.X += xLineAdvance;
penLocation.Y = originY;
- return glyphs;
+ return;
}
int j = 0;
@@ -727,7 +851,7 @@ private static List LayoutLineVertical(
advanceW = scale.X * metric.AdvanceWidth;
}
- glyphs.Add(new GlyphLayout(
+ visitor.Visit(new GlyphLayout(
new Glyph(metric, data.PointSize),
boxLocation,
penLocation + new Vector2((unscaledLineHeight - (data.ScaledLineHeight / options.LineSpacing)) * .5F, 0),
@@ -739,6 +863,7 @@ private static List LayoutLineVertical(
data.GraphemeIndex,
data.StringIndex));
+ emitted = true;
j++;
}
@@ -759,16 +884,30 @@ private static List LayoutLineVertical(
boxLocation.Y = originY;
penLocation.Y = originY;
- if (glyphs.Count > 0)
+ if (emitted)
{
boxLocation.X += advanceX;
penLocation.X += xLineAdvance;
}
-
- return glyphs;
}
- private static List LayoutLineVerticalMixed(
+ ///
+ /// Positions one line of vertical-mixed text (
+ /// and ). Transformed entries are rotated 90°
+ /// and laid out sideways using the font's horizontal metrics while the pen still advances
+ /// along Y; naturally-vertical entries are positioned using their vertical metrics.
+ ///
+ /// The concrete visitor struct type.
+ /// The containing text box (used to look up sibling lines for block alignment).
+ /// The line being laid out.
+ /// The resolved text direction for this line.
+ /// The longest scaled line advance in the block (or wrapping length).
+ /// The text shaping and layout options.
+ /// The zero-based visual index of this line within the block.
+ /// The running top-left position of the glyph boxes; advanced by this method.
+ /// The running pen position used for glyph placement; advanced by this method.
+ /// The visitor that receives each positioned glyph.
+ private static void LayoutLineVerticalMixed(
TextBox textBox,
TextLine textLine,
TextDirection direction,
@@ -776,7 +915,9 @@ private static List LayoutLineVerticalMixed(
TextOptions options,
int index,
ref Vector2 boxLocation,
- ref Vector2 penLocation)
+ ref Vector2 penLocation,
+ ref TVisitor visitor)
+ where TVisitor : struct, IGlyphLayoutVisitor
{
float originY = penLocation.Y;
float offsetY = 0;
@@ -862,13 +1003,13 @@ private static List LayoutLineVerticalMixed(
penLocation.Y += offsetY;
penLocation.X += offsetX;
- List glyphs = [];
+ bool emitted = false;
for (int i = 0; i < textLine.Count; i++)
{
TextLine.GlyphLayoutData data = textLine[i];
if (data.IsNewLine)
{
- glyphs.Add(new GlyphLayout(
+ visitor.Visit(new GlyphLayout(
new Glyph(data.Metrics[0], data.PointSize),
boxLocation,
penLocation,
@@ -884,7 +1025,7 @@ private static List LayoutLineVerticalMixed(
boxLocation.Y = originY;
penLocation.X += xLineAdvance;
penLocation.Y = originY;
- return glyphs;
+ return;
}
if (data.IsTransformed)
@@ -908,7 +1049,7 @@ private static List LayoutLineVerticalMixed(
float centerOffsetX = baselineDelta + descenderAbs + descenderDelta;
- glyphs.Add(new GlyphLayout(
+ visitor.Visit(new GlyphLayout(
new Glyph(metric, data.PointSize),
boxLocation,
penLocation + new Vector2(centerOffsetX, 0),
@@ -920,6 +1061,7 @@ private static List LayoutLineVerticalMixed(
data.GraphemeIndex,
data.StringIndex));
+ emitted = true;
j++;
}
}
@@ -932,7 +1074,7 @@ private static List LayoutLineVerticalMixed(
Vector2 scale = new Vector2(data.PointSize) / metric.ScaleFactor;
Vector2 offset = new(0, (metric.Bounds.Max.Y + metric.TopSideBearing) * scale.Y);
- glyphs.Add(new GlyphLayout(
+ visitor.Visit(new GlyphLayout(
new Glyph(metric, data.PointSize),
boxLocation,
penLocation + new Vector2((unscaledLineHeight - (data.ScaledLineHeight / options.LineSpacing)) * .5F, 0),
@@ -944,6 +1086,7 @@ private static List LayoutLineVerticalMixed(
data.GraphemeIndex,
data.StringIndex));
+ emitted = true;
j++;
}
}
@@ -953,15 +1096,37 @@ private static List LayoutLineVerticalMixed(
boxLocation.Y = originY;
penLocation.Y = originY;
- if (glyphs.Count > 0)
+ if (emitted)
{
boxLocation.X += advanceX;
penLocation.X += xLineAdvance;
}
-
- return glyphs;
}
+ ///
+ /// Shapes a single font run — maps codepoints in to glyph ids using
+ /// , then runs GSUB substitution and GPOS positioning. Codepoints that
+ /// the font cannot map are recorded for a later fallback pass.
+ ///
+ /// The run-relative text slice to shape.
+ /// The starting grapheme index (absolute within the original input).
+ /// The ordered list of resolved text runs.
+ /// The index of the current text run; advanced as the enumerator crosses run boundaries.
+ /// The running codepoint index (absolute within the original input).
+ /// The running bidi run index.
+ ///
+ /// if this call is the fallback-font pass (in which case unmapped codepoints
+ /// may still emit .notdef glyphs).
+ ///
+ /// The font to shape with.
+ /// The resolved bidi runs covering the whole input.
+ /// A codepoint → bidi-run mapping accumulated across shaping passes.
+ /// The GSUB substitution collection to write into.
+ /// The GPOS positioning collection to write into.
+ ///
+ /// if every codepoint mapped successfully; if any
+ /// codepoint remains unmapped (so a fallback-font pass is needed).
+ ///
private static bool DoFontRun(
ReadOnlySpan text,
int start,
@@ -1057,6 +1222,13 @@ private static bool DoFontRun(
: positionings.TryUpdate(font, substitutions);
}
+ ///
+ /// Substitutes mirrored bracket glyphs (for example ( ↔ )) inside right-to-left
+ /// bidi runs, per Unicode Bidirectional Algorithm rule L4. Relies on the font's rtlm
+ /// feature when available and falls back to the Unicode mirror table otherwise.
+ ///
+ /// The font metrics used to look up mirrored glyph ids.
+ /// The substitution collection whose glyphs will be rewritten in place.
private static void SubstituteBidiMirrors(FontMetrics fontMetrics, GlyphSubstitutionCollection collection)
{
for (int i = 0; i < collection.Count; i++)
@@ -1106,6 +1278,20 @@ private static void SubstituteBidiMirrors(FontMetrics fontMetrics, GlyphSubstitu
}
}
+ ///
+ /// Assembles shaped glyphs into instances, applying line-break
+ /// opportunities derived from and the configured
+ /// / settings.
+ /// Finalizes each line (trimming trailing whitespace and applying bidi reordering) and applies
+ /// justification where requested.
+ ///
+ /// The original source text.
+ /// The text shaping and layout options.
+ /// The resolved bidi runs covering the whole input.
+ /// The codepoint → bidi-run mapping built during shaping.
+ /// The GPOS positioning collection containing the shaped entries.
+ /// The active layout mode (drives horizontal vs vertical metrics selection).
+ /// The shaped, line-broken, finalized text box ready for glyph placement.
private static TextBox BreakLines(
ReadOnlySpan text,
TextOptions options,
@@ -1574,6 +1760,21 @@ VerticalOrientationType.Rotate or
return new TextBox(textLines);
}
+ ///
+ /// Calculates the X offset to apply to a single line of horizontal text so that it is positioned
+ /// within the wrapping block according to the requested horizontal and text alignment.
+ ///
+ ///
+ /// The returned offset is in unscaled (pre-Dpi) units and is combined with the pen location at
+ /// layout time. The result depends on the text direction because
+ /// and flip under right-to-left text.
+ ///
+ /// The scaled advance of the current line.
+ /// The scaled advance of the widest line (or wrapping length, whichever is greater).
+ /// Block-level horizontal alignment of the whole text.
+ /// Per-line alignment within the block.
+ /// The resolved text direction for this line.
+ /// The X offset to add to the line's pen location.
internal static float CalculateLineOffsetX(
float lineAdvance,
float maxScaledAdvance,
@@ -1623,6 +1824,21 @@ internal static float CalculateLineOffsetX(
return offsetX;
}
+ ///
+ /// Calculates the Y offset to apply to a single line of vertical text so that it is positioned
+ /// within the wrapping block according to the requested vertical and text alignment.
+ ///
+ ///
+ /// The returned offset is in unscaled (pre-Dpi) units and is combined with the pen location at
+ /// layout time. The result depends on the text direction because
+ /// and flip under right-to-left text.
+ ///
+ /// The scaled advance of the current line.
+ /// The scaled advance of the longest line (or wrapping length, whichever is greater).
+ /// Block-level vertical alignment of the whole text.
+ /// Per-line alignment within the block.
+ /// The resolved text direction for this line.
+ /// The Y offset to add to the line's pen location.
internal static float CalculateLineOffsetY(
float lineAdvance,
float maxScaledAdvance,
@@ -1675,53 +1891,147 @@ internal static float CalculateLineOffsetY(
return offsetY;
}
+ ///
+ /// A shaped and line-broken block of text produced by and consumed
+ /// by .
+ ///
internal sealed class TextBox
{
private float? scaledMaxAdvance;
private float? minY;
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The shaped, line-broken lines that make up this text box.
public TextBox(IReadOnlyList textLines)
=> this.TextLines = textLines;
+ ///
+ /// Gets the shaped and line-broken lines that make up the text.
+ ///
public IReadOnlyList TextLines { get; }
+ ///
+ /// Returns the widest scaled line advance across all lines. The result is memoized.
+ ///
+ /// The widest scaled line advance.
public float ScaledMaxAdvance()
=> this.scaledMaxAdvance ??= this.TextLines.Max(x => x.ScaledLineAdvance);
+ ///
+ /// Returns the smallest (most negative) scaled Y position encountered across all lines.
+ /// Used to detect ink that extends above the typographic ascender (stacked marks in Tibetan etc.).
+ /// The result is memoized.
+ ///
+ /// The smallest scaled Y position in the text box.
public float ScaledMinY()
=> this.minY ??= this.TextLines.Min(x => x.ScaledMinY);
+ ///
+ /// Returns the resolved text direction of the first glyph in the first line. Used as the
+ /// block-level direction for alignment calculations.
+ ///
+ /// The block-level text direction.
public TextDirection TextDirection() => this.TextLines[0][0].TextDirection;
}
+ ///
+ /// A shaped line of text — an ordered sequence of entries plus
+ /// per-line aggregate metrics (advance, ascender, descender, etc.) used to position the line
+ /// during layout.
+ ///
internal sealed class TextLine
{
private readonly List data;
private readonly Dictionary advances = [];
+ ///
+ /// Initializes a new instance of the class with a small default capacity.
+ ///
public TextLine() => this.data = new(16);
+ ///
+ /// Initializes a new instance of the class with the specified initial
+ /// entry capacity.
+ ///
+ /// Initial capacity for the internal entry list.
public TextLine(int capacity) => this.data = new(capacity);
+ ///
+ /// Gets the number of entries in this line.
+ ///
public int Count => this.data.Count;
+ ///
+ /// Gets a value indicating whether this line should be skipped during text justification.
+ /// Set by for lines that end a paragraph.
+ ///
public bool SkipJustification { get; private set; }
+ ///
+ /// Gets the sum of scaled advances across all entries in this line.
+ ///
public float ScaledLineAdvance { get; private set; }
+ ///
+ /// Gets the greatest scaled line height across all entries, multiplied by the configured
+ /// line-spacing factor.
+ ///
public float ScaledMaxLineHeight { get; private set; } = -1;
+ ///
+ /// Gets the greatest scaled ascender across all entries in this line.
+ ///
public float ScaledMaxAscender { get; private set; } = -1;
+ ///
+ /// Gets the greatest scaled descender across all entries in this line.
+ ///
public float ScaledMaxDescender { get; private set; } = -1;
+ ///
+ /// Gets the greatest scaled symmetric-metrics delta across all entries in this line.
+ /// Browsers adjust ascender/descender symmetrically for baseline alignment; this captures
+ /// that adjustment.
+ ///
public float ScaledMaxDelta { get; private set; } = float.MinValue;
+ ///
+ /// Gets the smallest (most negative) scaled Y position across all entries in this line.
+ /// Used to detect ink that extends above the typographic ascender (for example stacked
+ /// marks in Tibetan) so the layout engine can reserve extra ascent.
+ ///
public float ScaledMinY { get; private set; }
+ ///
+ /// Gets the entry at the given index.
+ ///
+ /// The zero-based index into this line.
+ /// The entry at the given index.
public GlyphLayoutData this[int index] => this.data[index];
+ ///
+ /// Appends a shaped entry to this line, updating the aggregated line-level metrics.
+ ///
+ /// The glyph metrics produced by shaping this entry's codepoint.
+ /// The point size at which the entry is rendered.
+ /// The scaled advance contributed by this entry.
+ /// The scaled line height contributed by this entry (before line-spacing).
+ /// The scaled typographic ascender.
+ /// The scaled typographic descender.
+ /// The symmetric metrics delta applied during line-box construction.
+ /// The bidi run this entry belongs to.
+ /// The grapheme index in the source text.
+ /// Whether this entry is the last codepoint in its grapheme cluster.
+ /// The codepoint index in the source text.
+ /// The index of the codepoint within its grapheme cluster.
+ /// Whether the entry participates in a transformed (rotated) vertical layout.
+ /// Whether the entry was produced by Unicode decomposition.
+ /// The character index in the source string.
+ /// The glyph-level layout mode to use for ink bounds computation.
+ /// The line-spacing factor to apply to .
public void Add(
IReadOnlyList metrics,
float pointSize,
@@ -1803,12 +2113,26 @@ public void Add(
stringIndex));
}
+ ///
+ /// Inserts all entries from into this line at the given index
+ /// and recomputes aggregated metrics.
+ ///
+ /// The zero-based index at which to insert.
+ /// The line whose entries should be inserted.
public void InsertAt(int index, TextLine textLine)
{
this.data.InsertRange(index, textLine.data);
RecalculateLineMetrics(this);
}
+ ///
+ /// Returns the cumulative scaled advance up to and including the glyph at the given index.
+ /// Whitespace entries at or after are skipped so the returned value
+ /// represents the advance at the last non-whitespace glyph before a potential line break.
+ ///
+ /// Results are memoized by index.
+ /// The zero-based index to measure up to.
+ /// The cumulative scaled advance.
public float MeasureAt(int index)
{
if (this.advances.TryGetValue(index, out float advance))
@@ -1838,6 +2162,14 @@ public float MeasureAt(int index)
return advance;
}
+ ///
+ /// Splits this line at the first non-whitespace glyph whose cumulative advance meets or
+ /// exceeds . On success, the split-off tail is returned as a new
+ /// line and removed from this one; both lines have their aggregated metrics recomputed.
+ ///
+ /// The scaled advance threshold at which to split.
+ /// The trailing portion of the split, or if no split was performed.
+ /// if a split occurred; otherwise .
public bool TrySplitAt(float length, [NotNullWhen(true)] out TextLine? result)
{
float advance = this.data[0].ScaledAdvance;
@@ -1871,6 +2203,15 @@ public bool TrySplitAt(float length, [NotNullWhen(true)] out TextLine? result)
return false;
}
+ ///
+ /// Splits this line at the glyph immediately preceding the supplied
+ /// wrap position. When is set and the preceding glyph is CJK,
+ /// the split is delayed until the nearest non-CJK codepoint to preserve word integrity.
+ ///
+ /// The resolved line-break opportunity.
+ /// When , avoid breaking between CJK codepoints.
+ /// The trailing portion of the split, or if no split was performed.
+ /// if a split occurred; otherwise .
public bool TrySplitAt(LineBreak lineBreak, bool keepAll, [NotNullWhen(true)] out TextLine? result)
{
int index = this.data.Count;
@@ -1923,6 +2264,10 @@ public bool TrySplitAt(LineBreak lineBreak, bool keepAll, [NotNullWhen(true)] ou
return true;
}
+ ///
+ /// Removes trailing breaking-whitespace entries from this line. Non-breaking spaces are
+ /// preserved and the first entry is always kept even when whitespace.
+ ///
private void TrimTrailingWhitespace()
{
int count = this.data.Count;
@@ -1945,6 +2290,15 @@ private void TrimTrailingWhitespace()
}
}
+ ///
+ /// Finalizes this line after line-breaking: trims trailing breaking whitespace, applies
+ /// bidi reordering so entries are in visual order, and recomputes aggregated metrics.
+ ///
+ ///
+ /// When , marks the line so becomes a no-op
+ /// (used for paragraph-final lines).
+ ///
+ /// This line, for fluent chaining.
public TextLine Finalize(bool skipJustification = false)
{
this.SkipJustification = skipJustification;
@@ -1954,6 +2308,17 @@ public TextLine Finalize(bool skipJustification = false)
return this;
}
+ ///
+ /// Distributes the remaining space between the line advance and the wrapping length across
+ /// either inter-character or inter-word gaps, as configured by
+ /// .
+ ///
+ ///
+ /// No-op when the line was finalized with skipJustification, when wrapping is
+ /// disabled, when no justification style is selected, or when the line is already at or
+ /// beyond the wrapping length.
+ ///
+ /// The text shaping options supplying the wrapping length and justification style.
public void Justify(TextOptions options)
{
if (options.WrappingLength == -1F || options.TextJustification == TextJustification.None)
@@ -2039,6 +2404,10 @@ public void Justify(TextOptions options)
RecalculateLineMetrics(this);
}
+ ///
+ /// Re-orders the entries in this line from logical to visual order according to the
+ /// Unicode Bidirectional Algorithm (, rules L1 and L2).
+ ///
public void BidiReOrder()
{
// Build up the collection of ordered runs.
@@ -2119,6 +2488,12 @@ public void BidiReOrder()
}
}
+ ///
+ /// Recomputes the aggregated per-line metrics (advance, max line height, ascender,
+ /// descender, delta, min-Y) from the current entries. Called after any mutation that
+ /// can affect these — split, insert, trim, justify.
+ ///
+ /// The line to recompute metrics for.
private static void RecalculateLineMetrics(TextLine textLine)
{
// Lastly recalculate this line metrics.
@@ -2210,9 +2585,33 @@ private static OrderedBidiRun LinearReOrder(OrderedBidiRun? line)
return range!.Left!;
}
+ ///
+ /// Per-codepoint shaping data stored inside a .
+ /// Each entry corresponds to a single codepoint — complex scripts may map one grapheme to
+ /// multiple entries (tracked via ).
+ ///
[DebuggerDisplay("{DebuggerDisplay,nq}")]
internal struct GlyphLayoutData
{
+ ///
+ /// Initializes a new instance of the struct.
+ ///
+ /// The shaped glyph metrics for this codepoint.
+ /// The point size at which the glyph is rendered.
+ /// The scaled advance of this entry.
+ /// The scaled line height contributed by this entry.
+ /// The scaled typographic ascender.
+ /// The scaled typographic descender.
+ /// The symmetric metrics delta applied during line-box construction.
+ /// The minimum scaled Y (topmost ink) across .
+ /// The resolved bidi run this entry belongs to.
+ /// The grapheme index in the source text.
+ /// Whether this is the last codepoint in its grapheme cluster.
+ /// The codepoint index in the source text.
+ /// The index of this codepoint within its grapheme cluster.
+ /// Whether the entry participates in a transformed vertical layout.
+ /// Whether the entry was produced by Unicode decomposition.
+ /// The UTF-16 character index in the source string.
public GlyphLayoutData(
IReadOnlyList metrics,
float pointSize,
@@ -2249,75 +2648,123 @@ public GlyphLayoutData(
this.StringIndex = stringIndex;
}
+ /// Gets the source codepoint for this entry.
public readonly CodePoint CodePoint => this.Metrics[0].CodePoint;
+ /// Gets the shaped glyph metrics produced for this codepoint (one codepoint may map to several glyphs).
public IReadOnlyList Metrics { get; }
+ /// Gets the point size at which this entry is rendered.
public float PointSize { get; }
+ /// Gets or sets the scaled advance of this entry (mutated by justification).
public float ScaledAdvance { get; set; }
+ /// Gets the scaled line height contributed by this entry, before line-spacing is applied.
public float ScaledLineHeight { get; }
+ /// Gets the scaled typographic ascender.
public float ScaledAscender { get; }
+ /// Gets the scaled typographic descender.
public float ScaledDescender { get; }
+ /// Gets the symmetric ascender/descender delta applied during line-box construction.
public float ScaledDelta { get; }
+ /// Gets the smallest (most negative) scaled Y across .
public float ScaledMinY { get; }
+ /// Gets the resolved bidi run this entry belongs to.
public BidiRun BidiRun { get; }
+ /// Gets the text direction derived from .
public readonly TextDirection TextDirection => (TextDirection)this.BidiRun.Direction;
+ /// Gets the grapheme index in the source text.
public int GraphemeIndex { get; }
+ /// Gets a value indicating whether this is the last codepoint in its grapheme cluster.
public bool IsLastInGrapheme { get; }
+ /// Gets the index of this codepoint within its grapheme cluster (0-based).
public int GraphemeCodePointIndex { get; }
+ /// Gets the codepoint index in the source text.
public int CodePointIndex { get; }
+ /// Gets a value indicating whether the entry participates in a transformed vertical layout.
public bool IsTransformed { get; }
+ /// Gets a value indicating whether the entry was produced by Unicode decomposition.
public bool IsDecomposed { get; }
+ /// Gets the UTF-16 character index in the source string.
public int StringIndex { get; }
+ /// Gets a value indicating whether the codepoint is a line-break character.
public readonly bool IsNewLine => CodePoint.IsNewLine(this.CodePoint);
private readonly string DebuggerDisplay => FormattableString
.Invariant($"{this.CodePoint.ToDebuggerDisplay()} : {this.TextDirection} : {this.CodePointIndex}, level: {this.BidiRun.Level}");
}
+ ///
+ /// A node in the linked list of contiguous same-level bidi runs used by .
+ /// Each node owns the glyph entries at its bidi embedding level and can be reversed in place.
+ ///
private sealed class OrderedBidiRun
{
private ArrayBuilder info;
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The bidi embedding level for this run.
public OrderedBidiRun(int level) => this.Level = level;
+ /// Gets the bidi embedding level of this run.
public int Level { get; }
+ /// Gets or sets the next run in visual order.
public OrderedBidiRun? Next { get; set; }
+ /// Appends an entry to this run.
+ /// The entry to append.
public void Add(GlyphLayoutData info) => this.info.Add(info);
+ /// Returns a slice view over this run's entries.
+ /// A slice over the entries.
public ArraySlice AsSlice() => this.info.AsSlice();
+ /// Reverses the entries in this run in place (for rule L2).
public void Reverse() => this.AsSlice().Span.Reverse();
}
+ ///
+ /// An intermediate grouping of links used by the linear-reorder
+ /// algorithm to stitch pairs of same-level ranges together.
+ ///
private sealed class BidiRange
{
+ /// Gets or sets the shared bidi embedding level for this range.
public int Level { get; set; }
+ /// Gets or sets the leftmost run in the range.
public OrderedBidiRun? Left { get; set; }
+ /// Gets or sets the rightmost run in the range.
public OrderedBidiRun? Right { get; set; }
+ /// Gets or sets the previous range in the processing stack.
public BidiRange? Previous { get; set; }
+ ///
+ /// Stitches the current range with its predecessor, producing a single merged range
+ /// whose internal orientation depends on the predecessor's embedding level parity.
+ ///
+ /// The current range whose will be merged.
+ /// The merged range (always the predecessor instance, reused in place).
public static BidiRange MergeWithPrevious(BidiRange? range)
{
BidiRange previous = range!.Previous!;
diff --git a/src/SixLabors.Fonts/TextMeasurer.cs b/src/SixLabors.Fonts/TextMeasurer.cs
index 45acf4fe..9b5e4e04 100644
--- a/src/SixLabors.Fonts/TextMeasurer.cs
+++ b/src/SixLabors.Fonts/TextMeasurer.cs
@@ -1,6 +1,8 @@
// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.
+using SixLabors.Fonts.Unicode;
+
namespace SixLabors.Fonts;
///
@@ -8,6 +10,118 @@ namespace SixLabors.Fonts;
///
public static class TextMeasurer
{
+ ///
+ /// Measures the full set of layout metrics for the supplied text in a single pass.
+ ///
+ /// The text.
+ /// The text shaping options.
+ /// A value containing every measurement for the laid-out text.
+ ///
+ /// This method is cheaper than calling multiple granular overloads back-to-back because the text is
+ /// shaped and laid out only once. Prefer the granular overloads (for example )
+ /// when only one or two values are required, because they avoid materializing the per-character and per-line arrays.
+ ///
+ public static TextMetrics Measure(string text, TextOptions options)
+ => Measure(text.AsSpan(), options);
+
+ ///
+ /// Measures the full set of layout metrics for the supplied text in a single pass.
+ ///
+ /// The text.
+ /// The text shaping options.
+ /// A value containing every measurement for the laid-out text.
+ ///
+ /// This method is cheaper than calling multiple granular overloads back-to-back because the text is
+ /// shaped and laid out only once. Prefer the granular overloads (for example )
+ /// when only one or two values are required, because they avoid materializing the per-character and per-line arrays.
+ ///
+ public static TextMetrics Measure(ReadOnlySpan text, TextOptions options)
+ {
+ if (text.IsEmpty)
+ {
+ return TextMetrics.Empty;
+ }
+
+ TextLayout.TextBox textBox = TextLayout.ProcessText(text, options);
+ List glyphLayouts = TextLayout.LayoutText(textBox, options);
+ float dpi = options.Dpi;
+ bool isHorizontal = options.LayoutMode.IsHorizontal();
+
+ FontRectangle advance = GetAdvance(textBox, dpi, isHorizontal);
+
+ int count = glyphLayouts.Count;
+ GlyphBounds[] characterAdvances = new GlyphBounds[count];
+ GlyphBounds[] characterSizes = new GlyphBounds[count];
+ GlyphBounds[] characterBounds = new GlyphBounds[count];
+ GlyphBounds[] characterRenderableBounds = new GlyphBounds[count];
+
+ float left = float.MaxValue;
+ float top = float.MaxValue;
+ float right = float.MinValue;
+ float bottom = float.MinValue;
+
+ for (int i = 0; i < count; i++)
+ {
+ GlyphLayout g = glyphLayouts[i];
+ FontRectangle glyphBox = g.BoundingBox(dpi);
+ FontRectangle advanceRect = new(g.BoxLocation.X * dpi, g.BoxLocation.Y * dpi, g.AdvanceX * dpi, g.AdvanceY * dpi);
+ FontRectangle renderableRect = FontRectangle.Union(advanceRect, glyphBox);
+
+ CodePoint codePoint = g.Glyph.GlyphMetrics.CodePoint;
+ int graphemeIndex = g.GraphemeIndex;
+ int stringIndex = g.StringIndex;
+
+ FontRectangle advanceBox = new(0, 0, g.AdvanceX * dpi, g.AdvanceY * dpi);
+ FontRectangle sizeBox = new(0, 0, glyphBox.Width, glyphBox.Height);
+
+ characterAdvances[i] = new GlyphBounds(codePoint, in advanceBox, graphemeIndex, stringIndex);
+ characterSizes[i] = new GlyphBounds(codePoint, in sizeBox, graphemeIndex, stringIndex);
+ characterBounds[i] = new GlyphBounds(codePoint, in glyphBox, graphemeIndex, stringIndex);
+ characterRenderableBounds[i] = new GlyphBounds(codePoint, in renderableRect, graphemeIndex, stringIndex);
+
+ if (glyphBox.Left < left)
+ {
+ left = glyphBox.Left;
+ }
+
+ if (glyphBox.Top < top)
+ {
+ top = glyphBox.Top;
+ }
+
+ if (glyphBox.Right > right)
+ {
+ right = glyphBox.Right;
+ }
+
+ if (glyphBox.Bottom > bottom)
+ {
+ bottom = glyphBox.Bottom;
+ }
+ }
+
+ FontRectangle bounds = count == 0
+ ? FontRectangle.Empty
+ : FontRectangle.FromLTRB(left, top, right, bottom);
+ FontRectangle size = new(0, 0, bounds.Width, bounds.Height);
+ FontRectangle absoluteAdvance = new(options.Origin.X, options.Origin.Y, advance.Width, advance.Height);
+ FontRectangle renderableBounds = FontRectangle.Union(absoluteAdvance, bounds);
+
+ LineMetrics[] lineMetrics = GetLineMetrics(textBox, options);
+
+ return new TextMetrics(
+ advance,
+ bounds,
+ size,
+ renderableBounds,
+ textBox.TextLines.Count,
+ characterAdvances,
+ characterSizes,
+ characterBounds,
+ characterRenderableBounds,
+ lineMetrics);
+ }
+
///
/// Measures the logical advance of the text in pixel units.
///
@@ -69,7 +183,10 @@ public static FontRectangle MeasureSize(string text, TextOptions options)
/// Use when the returned X and Y offset are also required.
///
public static FontRectangle MeasureSize(ReadOnlySpan text, TextOptions options)
- => GetSize(TextLayout.GenerateLayout(text, options), options.Dpi);
+ {
+ FontRectangle bounds = MeasureBounds(text, options);
+ return new FontRectangle(0, 0, bounds.Width, bounds.Height);
+ }
///
/// Measures the rendered glyph bounds of the text in pixel units.
@@ -115,7 +232,14 @@ public static FontRectangle MeasureRenderableBounds(string text, TextOptions opt
/// for the union of both.
///
public static FontRectangle MeasureBounds(ReadOnlySpan text, TextOptions options)
- => GetBounds(TextLayout.GenerateLayout(text, options), options.Dpi);
+ {
+ if (text.IsEmpty)
+ {
+ return FontRectangle.Empty;
+ }
+
+ return TextLayout.GetBounds(TextLayout.ProcessText(text, options), options);
+ }
///
/// Measures the full renderable bounds of the text in pixel units.
@@ -137,9 +261,10 @@ public static FontRectangle MeasureRenderableBounds(ReadOnlySpan text, Tex
return FontRectangle.Empty;
}
- FontRectangle advance = MeasureAdvance(text, options);
+ TextLayout.TextBox textBox = TextLayout.ProcessText(text, options);
+ FontRectangle advance = GetAdvance(textBox, options.Dpi, options.LayoutMode.IsHorizontal());
FontRectangle absoluteAdvance = new(options.Origin.X, options.Origin.Y, advance.Width, advance.Height);
- FontRectangle bounds = MeasureBounds(text, options);
+ FontRectangle bounds = TextLayout.GetBounds(textBox, options);
return FontRectangle.Union(absoluteAdvance, bounds);
}
@@ -330,7 +455,11 @@ public static LineMetrics[] GetLineMetrics(ReadOnlySpan text, TextOptions
return [];
}
- TextLayout.TextBox textBox = TextLayout.ProcessText(text, options);
+ return GetLineMetrics(TextLayout.ProcessText(text, options), options);
+ }
+
+ private static LineMetrics[] GetLineMetrics(TextLayout.TextBox textBox, TextOptions options)
+ {
LineMetrics[] metrics = new LineMetrics[textBox.TextLines.Count];
// Determine the line-box extent used for alignment within the flow direction.
diff --git a/src/SixLabors.Fonts/TextMetrics.cs b/src/SixLabors.Fonts/TextMetrics.cs
new file mode 100644
index 00000000..c86b2476
--- /dev/null
+++ b/src/SixLabors.Fonts/TextMetrics.cs
@@ -0,0 +1,140 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+namespace SixLabors.Fonts;
+
+///
+/// Encapsulates the full set of measurement results for laid-out text.
+///
+///
+///
+/// This type aggregates every measurement exposed by the granular overloads.
+/// Producing one instance is cheaper than calling multiple granular overloads
+/// back-to-back because the text is shaped and laid out only once.
+///
+///
+/// For callers that only require one or two values, the granular overloads on
+/// remain the most efficient choice because they avoid materializing the per-character and per-line arrays.
+///
+///
+public readonly struct TextMetrics
+{
+ ///
+ /// Represents an empty instance with zeroed rectangles and empty collections.
+ ///
+ public static readonly TextMetrics Empty = new(
+ FontRectangle.Empty,
+ FontRectangle.Empty,
+ FontRectangle.Empty,
+ FontRectangle.Empty,
+ 0,
+ [],
+ [],
+ [],
+ [],
+ []);
+
+ internal TextMetrics(
+ FontRectangle advance,
+ FontRectangle bounds,
+ FontRectangle size,
+ FontRectangle renderableBounds,
+ int lineCount,
+ GlyphBounds[] characterAdvances,
+ GlyphBounds[] characterSizes,
+ GlyphBounds[] characterBounds,
+ GlyphBounds[] characterRenderableBounds,
+ LineMetrics[] lines)
+ {
+ this.Advance = advance;
+ this.Bounds = bounds;
+ this.Size = size;
+ this.RenderableBounds = renderableBounds;
+ this.LineCount = lineCount;
+ this.CharacterAdvances = characterAdvances;
+ this.CharacterSizes = characterSizes;
+ this.CharacterBounds = characterBounds;
+ this.CharacterRenderableBounds = characterRenderableBounds;
+ this.Lines = lines;
+ }
+
+ ///
+ /// Gets the logical advance rectangle of the text in pixel units.
+ ///
+ ///
+ /// Reflects line-box height and horizontal or vertical text advance from the layout model.
+ /// Does not guarantee that all rendered glyph pixels fit within the returned rectangle.
+ ///
+ public FontRectangle Advance { get; }
+
+ ///
+ /// Gets the rendered glyph bounds of the text in pixel units.
+ ///
+ ///
+ /// This is the tight ink bounds enclosing all rendered glyphs and may be smaller or larger
+ /// than the logical advance. May have a non-zero origin.
+ ///
+ public FontRectangle Bounds { get; }
+
+ ///
+ /// Gets the normalized rendered size of the text in pixel units with the origin at (0, 0).
+ ///
+ ///
+ /// Equivalent to with a zeroed origin.
+ ///
+ public FontRectangle Size { get; }
+
+ ///
+ /// Gets the union of the logical advance rectangle (positioned at the text options origin)
+ /// and the rendered glyph bounds.
+ ///
+ ///
+ /// Use this rectangle when both typographic advance and rendered glyph overshoot
+ /// must fit within the same bounding box.
+ ///
+ public FontRectangle RenderableBounds { get; }
+
+ ///
+ /// Gets the number of laid-out lines in the text.
+ ///
+ public int LineCount { get; }
+
+ ///
+ /// Gets the logical advance of each laid-out character in pixel units.
+ ///
+ ///
+ /// Each entry reflects the typographic advance width and height for one character,
+ /// with an origin of (0, 0).
+ ///
+ public IReadOnlyList CharacterAdvances { get; }
+
+ ///
+ /// Gets the normalized rendered size of each laid-out character in pixel units.
+ ///
+ ///
+ /// Each entry is the tight ink bounds of one glyph with the origin normalized to (0, 0).
+ ///
+ public IReadOnlyList CharacterSizes { get; }
+
+ ///
+ /// Gets the rendered glyph bounds of each laid-out character in pixel units.
+ ///
+ ///
+ /// Each entry reflects the tight ink bounds of one rendered glyph.
+ ///
+ public IReadOnlyList CharacterBounds { get; }
+
+ ///
+ /// Gets the full renderable bounds of each laid-out character in pixel units.
+ ///
+ ///
+ /// Each entry is the union of the logical advance rectangle and the rendered glyph bounds
+ /// for the corresponding laid-out character.
+ ///
+ public IReadOnlyList CharacterRenderableBounds { get; }
+
+ ///
+ /// Gets the per-line layout metrics for the text.
+ ///
+ public IReadOnlyList Lines { get; }
+}
diff --git a/tests/SixLabors.Fonts.Tests/TextMeasurerReferenceTests.cs b/tests/SixLabors.Fonts.Tests/TextMeasurerReferenceTests.cs
new file mode 100644
index 00000000..1cc21acb
--- /dev/null
+++ b/tests/SixLabors.Fonts.Tests/TextMeasurerReferenceTests.cs
@@ -0,0 +1,482 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using System.Numerics;
+using SixLabors.Fonts.Unicode;
+
+namespace SixLabors.Fonts.Tests;
+
+///
+/// Characterization tests that pin the current output of 's
+/// granular overloads using the real OpenSans-Regular font at 12pt / 72 DPI.
+///
+///
+///
+/// The numeric values baked into the _Pinned theories are a snapshot of the current
+/// implementation. They are not derived from a spec — they guard against accidental drift
+/// in the measurement pipeline during refactors. If an intentional change alters the output,
+/// update the expected values to the new snapshot and document why in the commit message.
+///
+///
+/// The Invariant_ tests verify relationships that must hold regardless of font —
+/// cross-method consistency, origin handling, string/span parity, empty-text behaviour.
+///
+///
+public class TextMeasurerReferenceTests
+{
+ private static readonly ApproximateFloatComparer Comparer = new(0.001F);
+
+ private static Font Font => TextLayoutTests.CreateRenderingFont();
+
+ private static TextOptions Options(float originX = 0, float originY = 0, LayoutMode layoutMode = LayoutMode.HorizontalTopBottom)
+ => new(Font)
+ {
+ Origin = new Vector2(originX, originY),
+ LayoutMode = layoutMode
+ };
+
+ // ======================================================================
+ // Pinned outputs — OpenSans-Regular @ 12pt, Dpi = 72, Origin = (0, 0)
+ // ======================================================================
+ [Theory]
+ [InlineData("A", 0f, 0f, 7.5879f, 12f)]
+ [InlineData("Hello", 0f, 0f, 28.8633f, 12f)]
+ [InlineData("Hello, World!", 0f, 0f, 71.8301f, 12f)]
+ [InlineData("The quick brown fox", 0f, 0f, 113.502f, 12f)]
+ [InlineData("Hello\nWorld", 0f, 0f, 33.5742f, 24f)]
+ [InlineData("A\nB\nC", 0f, 0f, 7.752f, 36f)]
+ public void MeasureAdvance_Pinned(string text, float ex, float ey, float ew, float eh)
+ {
+ FontRectangle actual = TextMeasurer.MeasureAdvance(text, Options());
+ Assert.Equal(new FontRectangle(ex, ey, ew, eh), actual, Comparer);
+ }
+
+ [Theory]
+ [InlineData("A", 0f, 2.0537f, 7.5762f, 8.6016f)]
+ [InlineData("Hello", 1.1719f, 1.5381f, 27.0352f, 9.2344f)]
+ [InlineData("Hello, World!", 1.1719f, 1.5381f, 69.7617f, 10.6641f)]
+ [InlineData("The quick brown fox", 0.1055f, 1.4736f, 113.168f, 12.0527f)]
+ [InlineData("Hello\nWorld", 0.1758f, 1.5381f, 32.3672f, 21.2344f)]
+ [InlineData("A\nB\nC", 0f, 2.0537f, 7.5762f, 32.7188f)]
+ public void MeasureBounds_Pinned(string text, float ex, float ey, float ew, float eh)
+ {
+ FontRectangle actual = TextMeasurer.MeasureBounds(text, Options());
+ Assert.Equal(new FontRectangle(ex, ey, ew, eh), actual, Comparer);
+ }
+
+ [Theory]
+ [InlineData("A", 7.5762f, 8.6016f)]
+ [InlineData("Hello", 27.0352f, 9.2344f)]
+ [InlineData("Hello, World!", 69.7617f, 10.6641f)]
+ [InlineData("The quick brown fox", 113.168f, 12.0527f)]
+ [InlineData("Hello\nWorld", 32.3672f, 21.2344f)]
+ [InlineData("A\nB\nC", 7.5762f, 32.7188f)]
+ public void MeasureSize_Pinned(string text, float ew, float eh)
+ {
+ FontRectangle actual = TextMeasurer.MeasureSize(text, Options());
+ Assert.Equal(new FontRectangle(0, 0, ew, eh), actual, Comparer);
+ }
+
+ [Theory]
+ [InlineData("A", 0f, 0f, 7.5879f, 12f)]
+ [InlineData("Hello", 0f, 0f, 28.8633f, 12f)]
+ // "Hello, World!" has comma descender — renderable height (12.2022) exceeds advance height (12).
+ [InlineData("Hello, World!", 0f, 0f, 71.8301f, 12.2022f)]
+ // "The quick brown fox" has q, p descenders — renderable height (13.5264) exceeds advance (12).
+ [InlineData("The quick brown fox", 0f, 0f, 113.502f, 13.5264f)]
+ [InlineData("Hello\nWorld", 0f, 0f, 33.5742f, 24f)]
+ [InlineData("A\nB\nC", 0f, 0f, 7.752f, 36f)]
+ public void MeasureRenderableBounds_Pinned(string text, float ex, float ey, float ew, float eh)
+ {
+ FontRectangle actual = TextMeasurer.MeasureRenderableBounds(text, Options());
+ Assert.Equal(new FontRectangle(ex, ey, ew, eh), actual, Comparer);
+ }
+
+ // ======================================================================
+ // Pinned outputs with offset origin — advance independent, bounds shift
+ // ======================================================================
+ [Fact]
+ public void MeasureAdvance_OffsetOrigin_MatchesZeroOrigin()
+ {
+ const string text = "Hello, World!";
+ FontRectangle atZero = TextMeasurer.MeasureAdvance(text, Options());
+ FontRectangle atOffset = TextMeasurer.MeasureAdvance(text, Options(100, 50));
+ Assert.Equal(atZero, atOffset, Comparer);
+ }
+
+ [Fact]
+ public void MeasureBounds_OffsetOrigin_ShiftsByOrigin()
+ {
+ const string text = "Hello, World!";
+ FontRectangle atZero = TextMeasurer.MeasureBounds(text, Options());
+ FontRectangle atOffset = TextMeasurer.MeasureBounds(text, Options(100, 50));
+
+ Assert.Equal(atZero.X + 100, atOffset.X, Comparer);
+ Assert.Equal(atZero.Y + 50, atOffset.Y, Comparer);
+ Assert.Equal(atZero.Width, atOffset.Width, Comparer);
+ Assert.Equal(atZero.Height, atOffset.Height, Comparer);
+ }
+
+ [Fact]
+ public void MeasureRenderableBounds_OffsetOrigin_ShiftsByOrigin()
+ {
+ const string text = "Hello, World!";
+ FontRectangle atZero = TextMeasurer.MeasureRenderableBounds(text, Options());
+ FontRectangle atOffset = TextMeasurer.MeasureRenderableBounds(text, Options(100, 50));
+
+ Assert.Equal(atZero.X + 100, atOffset.X, Comparer);
+ Assert.Equal(atZero.Y + 50, atOffset.Y, Comparer);
+ Assert.Equal(atZero.Width, atOffset.Width, Comparer);
+ Assert.Equal(atZero.Height, atOffset.Height, Comparer);
+ }
+
+ // ======================================================================
+ // CountLines
+ // ======================================================================
+ [Theory]
+ [InlineData("", 0)]
+ [InlineData("Hello", 1)]
+ [InlineData("Hello, World!", 1)]
+ [InlineData("Hello\nWorld", 2)]
+ [InlineData("A\nB\nC", 3)]
+ [InlineData("A\nB\nC\nD", 4)]
+ public void CountLines_Pinned(string text, int expected)
+ {
+ Assert.Equal(expected, TextMeasurer.CountLines(text, Options()));
+ }
+
+ // ======================================================================
+ // GetLineMetrics — pinned counts and non-zero extents
+ // ======================================================================
+ [Theory]
+ [InlineData("Hello", 1)]
+ [InlineData("Hello\nWorld", 2)]
+ [InlineData("A\nB\nC\nD", 4)]
+ public void GetLineMetrics_Count_MatchesCountLines(string text, int expectedLines)
+ {
+ LineMetrics[] metrics = TextMeasurer.GetLineMetrics(text, Options());
+ Assert.Equal(expectedLines, metrics.Length);
+ Assert.Equal(expectedLines, TextMeasurer.CountLines(text, Options()));
+ }
+
+ [Fact]
+ public void GetLineMetrics_PerLineExtent_MatchesLineAdvance()
+ {
+ const string text = "Hello\nWorld world";
+ LineMetrics[] metrics = TextMeasurer.GetLineMetrics(text, Options());
+ Assert.Equal(2, metrics.Length);
+
+ FontRectangle line1Advance = TextMeasurer.MeasureAdvance("Hello", Options());
+ FontRectangle line2Advance = TextMeasurer.MeasureAdvance("World world", Options());
+ Assert.Equal(line1Advance.Width, metrics[0].Extent, Comparer);
+ Assert.Equal(line2Advance.Width, metrics[1].Extent, Comparer);
+ }
+
+ [Fact]
+ public void GetLineMetrics_AscenderBaselineDescender_AreOrdered()
+ {
+ LineMetrics[] metrics = TextMeasurer.GetLineMetrics("Hello", Options());
+ Assert.Single(metrics);
+ LineMetrics m = metrics[0];
+ Assert.True(m.Ascender < m.Baseline, $"Ascender ({m.Ascender}) should be < Baseline ({m.Baseline})");
+ Assert.True(m.Baseline < m.Descender, $"Baseline ({m.Baseline}) should be < Descender ({m.Descender})");
+ Assert.True(m.LineHeight > 0);
+ }
+
+ [Fact]
+ public void GetLineMetrics_EmptyText_ReturnsEmpty()
+ => Assert.Empty(TextMeasurer.GetLineMetrics(string.Empty, Options()));
+
+ // ======================================================================
+ // Per-character metadata (codepoints, indices) — character content pinned
+ // ======================================================================
+ [Fact]
+ public void TryMeasureCharacterBounds_Hi_Pinned()
+ {
+ const string text = "Hi!";
+ Assert.True(TextMeasurer.TryMeasureCharacterBounds(text, Options(), out ReadOnlySpan bounds));
+
+ GlyphBounds[] expected =
+ [
+ new(new CodePoint('H'), new FontRectangle(1.1719f, 2.0889f, 6.4922f, 8.5664f), 0, 0),
+ new(new CodePoint('i'), new FontRectangle(9.7852f, 1.8311f, 1.1719f, 8.8242f), 1, 1),
+ new(new CodePoint('!'), new FontRectangle(12.7559f, 2.0889f, 1.3945f, 8.7305f), 2, 2),
+ ];
+ AssertGlyphBoundsEqual(expected, bounds);
+ }
+
+ [Fact]
+ public void TryMeasureCharacterAdvances_Hi_Pinned()
+ {
+ const string text = "Hi!";
+ Assert.True(TextMeasurer.TryMeasureCharacterAdvances(text, Options(), out ReadOnlySpan advances));
+
+ GlyphBounds[] expected =
+ [
+ new(new CodePoint('H'), new FontRectangle(0, 0, 8.8477f, 12f), 0, 0),
+ new(new CodePoint('i'), new FontRectangle(0, 0, 3.0293f, 12f), 1, 1),
+ new(new CodePoint('!'), new FontRectangle(0, 0, 3.1699f, 12f), 2, 2),
+ ];
+ AssertGlyphBoundsEqual(expected, advances);
+ }
+
+ [Fact]
+ public void TryMeasureCharacterSizes_Hi_Pinned()
+ {
+ const string text = "Hi!";
+ Assert.True(TextMeasurer.TryMeasureCharacterSizes(text, Options(), out ReadOnlySpan sizes));
+
+ GlyphBounds[] expected =
+ [
+ new(new CodePoint('H'), new FontRectangle(0, 0, 6.4922f, 8.5664f), 0, 0),
+ new(new CodePoint('i'), new FontRectangle(0, 0, 1.1719f, 8.8242f), 1, 1),
+ new(new CodePoint('!'), new FontRectangle(0, 0, 1.3945f, 8.7305f), 2, 2),
+ ];
+ AssertGlyphBoundsEqual(expected, sizes);
+ }
+
+ [Fact]
+ public void TryMeasureCharacterRenderableBounds_Hi_Pinned()
+ {
+ const string text = "Hi!";
+ Assert.True(TextMeasurer.TryMeasureCharacterRenderableBounds(text, Options(), out ReadOnlySpan renderable));
+
+ GlyphBounds[] expected =
+ [
+ new(new CodePoint('H'), new FontRectangle(0f, 0f, 8.8477f, 12f), 0, 0),
+ new(new CodePoint('i'), new FontRectangle(8.8477f, 0f, 3.0293f, 12f), 1, 1),
+ new(new CodePoint('!'), new FontRectangle(11.877f, 0f, 3.1699f, 12f), 2, 2),
+ ];
+ AssertGlyphBoundsEqual(expected, renderable);
+ }
+
+ [Fact]
+ public void TryMeasureCharacterBounds_Codepoints_PreserveSourceOrder()
+ {
+ const string text = "Hi!";
+ Assert.True(TextMeasurer.TryMeasureCharacterBounds(text, Options(), out ReadOnlySpan bounds));
+
+ Assert.Equal(3, bounds.Length);
+ Assert.Equal(new CodePoint('H'), bounds[0].Codepoint);
+ Assert.Equal(new CodePoint('i'), bounds[1].Codepoint);
+ Assert.Equal(new CodePoint('!'), bounds[2].Codepoint);
+ Assert.Equal(0, bounds[0].StringIndex);
+ Assert.Equal(1, bounds[1].StringIndex);
+ Assert.Equal(2, bounds[2].StringIndex);
+ }
+
+ [Fact]
+ public void TryMeasureCharacterBounds_Newline_IsSkippedButAdvancesIndices()
+ {
+ const string text = "A\nB";
+ Assert.True(TextMeasurer.TryMeasureCharacterBounds(text, Options(), out ReadOnlySpan bounds));
+
+ Assert.Equal(2, bounds.Length);
+ Assert.Equal(new CodePoint('A'), bounds[0].Codepoint);
+ Assert.Equal(0, bounds[0].StringIndex);
+ Assert.Equal(new CodePoint('B'), bounds[1].Codepoint);
+ Assert.Equal(2, bounds[1].StringIndex);
+ }
+
+ [Fact]
+ public void TryMeasureCharacterAdvances_EmptyText_ReturnsFalse()
+ {
+ Assert.False(TextMeasurer.TryMeasureCharacterAdvances(string.Empty, Options(), out ReadOnlySpan advances));
+ Assert.Equal(0, advances.Length);
+ }
+
+ [Fact]
+ public void TryMeasureCharacterBounds_ShiftsWithOrigin()
+ {
+ const string text = "Hi!";
+ Assert.True(TextMeasurer.TryMeasureCharacterBounds(text, Options(), out ReadOnlySpan atZero));
+ GlyphBounds[] zeroCopy = atZero.ToArray();
+ Assert.True(TextMeasurer.TryMeasureCharacterBounds(text, Options(100, 50), out ReadOnlySpan atOffset));
+
+ Assert.Equal(zeroCopy.Length, atOffset.Length);
+ for (int i = 0; i < zeroCopy.Length; i++)
+ {
+ Assert.Equal(zeroCopy[i].Bounds.X + 100, atOffset[i].Bounds.X, Comparer);
+ Assert.Equal(zeroCopy[i].Bounds.Y + 50, atOffset[i].Bounds.Y, Comparer);
+ Assert.Equal(zeroCopy[i].Bounds.Width, atOffset[i].Bounds.Width, Comparer);
+ Assert.Equal(zeroCopy[i].Bounds.Height, atOffset[i].Bounds.Height, Comparer);
+ }
+ }
+
+ [Fact]
+ public void TryMeasureCharacterSizes_IsOriginIndependent()
+ {
+ const string text = "Hi!";
+ Assert.True(TextMeasurer.TryMeasureCharacterSizes(text, Options(), out ReadOnlySpan atZero));
+ GlyphBounds[] zeroCopy = atZero.ToArray();
+ Assert.True(TextMeasurer.TryMeasureCharacterSizes(text, Options(100, 50), out ReadOnlySpan atOffset));
+
+ Assert.Equal(zeroCopy.Length, atOffset.Length);
+ for (int i = 0; i < zeroCopy.Length; i++)
+ {
+ Assert.Equal(zeroCopy[i].Bounds, atOffset[i].Bounds, Comparer);
+ }
+ }
+
+ // ======================================================================
+ // Invariants — hold regardless of font or text
+ // ======================================================================
+ [Theory]
+ [InlineData("Hello", 0f, 0f)]
+ [InlineData("Hello, World!", 100, 50)]
+ [InlineData("Hello\nWorld", 10, 20)]
+ [InlineData("A\nB\nC", -20, 30)]
+ public void Invariant_SizeEqualsBoundsWithZeroedOrigin(string text, float ox, float oy)
+ {
+ TextOptions options = Options(ox, oy);
+ FontRectangle bounds = TextMeasurer.MeasureBounds(text, options);
+ FontRectangle size = TextMeasurer.MeasureSize(text, options);
+
+ Assert.Equal(0, size.X, Comparer);
+ Assert.Equal(0, size.Y, Comparer);
+ Assert.Equal(bounds.Width, size.Width, Comparer);
+ Assert.Equal(bounds.Height, size.Height, Comparer);
+ }
+
+ [Theory]
+ [InlineData("Hello", 0f, 0f)]
+ [InlineData("Hello, World!", 100, 50)]
+ [InlineData("Hello\nWorld", 10, 20)]
+ [InlineData("A\nB\nC", -20, 30)]
+ public void Invariant_RenderableBoundsEqualsUnionOfAdvanceAndBounds(string text, float ox, float oy)
+ {
+ TextOptions options = Options(ox, oy);
+ FontRectangle advance = TextMeasurer.MeasureAdvance(text, options);
+ FontRectangle absAdvance = new(options.Origin.X, options.Origin.Y, advance.Width, advance.Height);
+ FontRectangle bounds = TextMeasurer.MeasureBounds(text, options);
+ FontRectangle expected = FontRectangle.Union(absAdvance, bounds);
+
+ FontRectangle renderable = TextMeasurer.MeasureRenderableBounds(text, options);
+
+ Assert.Equal(expected, renderable, Comparer);
+ }
+
+ [Theory]
+ [InlineData("Hello")]
+ [InlineData("Hi!")]
+ [InlineData("A\nB\nC")]
+ [InlineData("Hello world")]
+ public void Invariant_AllPerCharArraysHaveSameLength(string text)
+ {
+ TextOptions options = Options();
+ TextMeasurer.TryMeasureCharacterAdvances(text, options, out ReadOnlySpan advances);
+ TextMeasurer.TryMeasureCharacterBounds(text, options, out ReadOnlySpan bounds);
+ TextMeasurer.TryMeasureCharacterSizes(text, options, out ReadOnlySpan sizes);
+ TextMeasurer.TryMeasureCharacterRenderableBounds(text, options, out ReadOnlySpan renderable);
+
+ Assert.Equal(advances.Length, bounds.Length);
+ Assert.Equal(advances.Length, sizes.Length);
+ Assert.Equal(advances.Length, renderable.Length);
+ }
+
+ [Theory]
+ [InlineData("Hello")]
+ [InlineData("Hi!")]
+ [InlineData("A\nB\nC")]
+ public void Invariant_PerCharMetadataMatchAcrossArrays(string text)
+ {
+ TextOptions options = Options();
+ TextMeasurer.TryMeasureCharacterAdvances(text, options, out ReadOnlySpan advances);
+ TextMeasurer.TryMeasureCharacterBounds(text, options, out ReadOnlySpan bounds);
+ TextMeasurer.TryMeasureCharacterSizes(text, options, out ReadOnlySpan sizes);
+ TextMeasurer.TryMeasureCharacterRenderableBounds(text, options, out ReadOnlySpan renderable);
+
+ for (int i = 0; i < advances.Length; i++)
+ {
+ Assert.Equal(advances[i].Codepoint, bounds[i].Codepoint);
+ Assert.Equal(advances[i].Codepoint, sizes[i].Codepoint);
+ Assert.Equal(advances[i].Codepoint, renderable[i].Codepoint);
+ Assert.Equal(advances[i].GraphemeIndex, bounds[i].GraphemeIndex);
+ Assert.Equal(advances[i].StringIndex, bounds[i].StringIndex);
+ Assert.Equal(advances[i].GraphemeIndex, sizes[i].GraphemeIndex);
+ Assert.Equal(advances[i].StringIndex, sizes[i].StringIndex);
+ Assert.Equal(advances[i].GraphemeIndex, renderable[i].GraphemeIndex);
+ Assert.Equal(advances[i].StringIndex, renderable[i].StringIndex);
+ }
+ }
+
+ [Theory]
+ [InlineData("Hello")]
+ [InlineData("Hello, World!")]
+ [InlineData("Hello\nWorld")]
+ public void Invariant_StringAndSpanOverloads_Match(string text)
+ {
+ TextOptions options = Options();
+
+ Assert.Equal(
+ TextMeasurer.MeasureAdvance(text, options),
+ TextMeasurer.MeasureAdvance(text.AsSpan(), options),
+ Comparer);
+ Assert.Equal(
+ TextMeasurer.MeasureBounds(text, options),
+ TextMeasurer.MeasureBounds(text.AsSpan(), options),
+ Comparer);
+ Assert.Equal(
+ TextMeasurer.MeasureSize(text, options),
+ TextMeasurer.MeasureSize(text.AsSpan(), options),
+ Comparer);
+ Assert.Equal(
+ TextMeasurer.MeasureRenderableBounds(text, options),
+ TextMeasurer.MeasureRenderableBounds(text.AsSpan(), options),
+ Comparer);
+ Assert.Equal(
+ TextMeasurer.CountLines(text, options),
+ TextMeasurer.CountLines(text.AsSpan(), options));
+ }
+
+ [Fact]
+ public void Invariant_EmptyText_AllMethodsReturnEmpty()
+ {
+ TextOptions options = Options();
+
+ Assert.Equal(FontRectangle.Empty, TextMeasurer.MeasureAdvance(string.Empty, options), Comparer);
+ Assert.Equal(FontRectangle.Empty, TextMeasurer.MeasureRenderableBounds(string.Empty, options), Comparer);
+ Assert.Equal(0, TextMeasurer.CountLines(string.Empty, options));
+ Assert.Empty(TextMeasurer.GetLineMetrics(string.Empty, options));
+ Assert.False(TextMeasurer.TryMeasureCharacterAdvances(string.Empty, options, out _));
+ Assert.False(TextMeasurer.TryMeasureCharacterBounds(string.Empty, options, out _));
+ Assert.False(TextMeasurer.TryMeasureCharacterSizes(string.Empty, options, out _));
+ Assert.False(TextMeasurer.TryMeasureCharacterRenderableBounds(string.Empty, options, out _));
+ }
+
+ [Theory]
+ [InlineData(LayoutMode.HorizontalTopBottom)]
+ [InlineData(LayoutMode.HorizontalBottomTop)]
+ [InlineData(LayoutMode.VerticalLeftRight)]
+ [InlineData(LayoutMode.VerticalRightLeft)]
+ [InlineData(LayoutMode.VerticalMixedLeftRight)]
+ [InlineData(LayoutMode.VerticalMixedRightLeft)]
+ public void Invariant_RenderableBoundsEqualsUnion_AcrossLayoutModes(LayoutMode layoutMode)
+ {
+ const string text = "Hello world";
+ TextOptions options = Options(15, 25, layoutMode);
+
+ FontRectangle advance = TextMeasurer.MeasureAdvance(text, options);
+ FontRectangle absAdvance = new(options.Origin.X, options.Origin.Y, advance.Width, advance.Height);
+ FontRectangle bounds = TextMeasurer.MeasureBounds(text, options);
+ FontRectangle expected = FontRectangle.Union(absAdvance, bounds);
+
+ FontRectangle renderable = TextMeasurer.MeasureRenderableBounds(text, options);
+
+ Assert.Equal(expected, renderable, Comparer);
+ }
+
+ private static void AssertGlyphBoundsEqual(GlyphBounds[] expected, ReadOnlySpan actual)
+ {
+ Assert.Equal(expected.Length, actual.Length);
+ for (int i = 0; i < expected.Length; i++)
+ {
+ GlyphBounds e = expected[i];
+ GlyphBounds a = actual[i];
+ Assert.Equal(e.Codepoint, a.Codepoint);
+ Assert.Equal(e.GraphemeIndex, a.GraphemeIndex);
+ Assert.Equal(e.StringIndex, a.StringIndex);
+ Assert.Equal(e.Bounds, a.Bounds, Comparer);
+ }
+ }
+}
diff --git a/tests/SixLabors.Fonts.Tests/TextMetricsTests.cs b/tests/SixLabors.Fonts.Tests/TextMetricsTests.cs
new file mode 100644
index 00000000..3c9eb554
--- /dev/null
+++ b/tests/SixLabors.Fonts.Tests/TextMetricsTests.cs
@@ -0,0 +1,237 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using SixLabors.Fonts.Unicode;
+
+namespace SixLabors.Fonts.Tests;
+
+public class TextMetricsTests
+{
+ private static readonly ApproximateFloatComparer Comparer = new(0.001F);
+
+ [Fact]
+ public void Empty_HasZeroedRectanglesAndEmptyCollections()
+ {
+ TextMetrics metrics = TextMetrics.Empty;
+
+ Assert.Equal(FontRectangle.Empty, metrics.Advance, Comparer);
+ Assert.Equal(FontRectangle.Empty, metrics.Bounds, Comparer);
+ Assert.Equal(FontRectangle.Empty, metrics.Size, Comparer);
+ Assert.Equal(FontRectangle.Empty, metrics.RenderableBounds, Comparer);
+ Assert.Equal(0, metrics.LineCount);
+ Assert.Empty(metrics.CharacterAdvances);
+ Assert.Empty(metrics.CharacterSizes);
+ Assert.Empty(metrics.CharacterBounds);
+ Assert.Empty(metrics.CharacterRenderableBounds);
+ Assert.Empty(metrics.Lines);
+ }
+
+ [Fact]
+ public void Measure_EmptyString_ReturnsEmptyMetrics()
+ {
+ Font font = TextLayoutTests.CreateFont("hello world");
+ TextOptions options = new(font) { Dpi = font.FontMetrics.ScaleFactor };
+
+ TextMetrics metrics = TextMeasurer.Measure(string.Empty, options);
+
+ Assert.Equal(FontRectangle.Empty, metrics.Advance, Comparer);
+ Assert.Equal(FontRectangle.Empty, metrics.Bounds, Comparer);
+ Assert.Equal(FontRectangle.Empty, metrics.Size, Comparer);
+ Assert.Equal(FontRectangle.Empty, metrics.RenderableBounds, Comparer);
+ Assert.Equal(0, metrics.LineCount);
+ Assert.Empty(metrics.CharacterAdvances);
+ Assert.Empty(metrics.CharacterSizes);
+ Assert.Empty(metrics.CharacterBounds);
+ Assert.Empty(metrics.CharacterRenderableBounds);
+ Assert.Empty(metrics.Lines);
+ }
+
+ [Fact]
+ public void Measure_StringAndSpanOverloads_Match()
+ {
+ const string text = "hello world\nhello world";
+ Font font = TextLayoutTests.CreateFont(text);
+ TextOptions options = new(font) { Dpi = font.FontMetrics.ScaleFactor };
+
+ TextMetrics fromString = TextMeasurer.Measure(text, options);
+ TextMetrics fromSpan = TextMeasurer.Measure(text.AsSpan(), options);
+
+ Assert.Equal(fromString.Advance, fromSpan.Advance, Comparer);
+ Assert.Equal(fromString.Bounds, fromSpan.Bounds, Comparer);
+ Assert.Equal(fromString.Size, fromSpan.Size, Comparer);
+ Assert.Equal(fromString.RenderableBounds, fromSpan.RenderableBounds, Comparer);
+ Assert.Equal(fromString.LineCount, fromSpan.LineCount);
+ Assert.Equal(fromString.CharacterAdvances.Count, fromSpan.CharacterAdvances.Count);
+ Assert.Equal(fromString.Lines.Count, fromSpan.Lines.Count);
+ }
+
+ [Theory]
+ [InlineData("h")]
+ [InlineData("hello")]
+ [InlineData("hello world")]
+ [InlineData("hello\nworld")]
+ [InlineData("hello world\nhello world")]
+ [InlineData("a b\nc")]
+ public void Measure_MatchesGranularRectangles(string text)
+ {
+ Font font = TextLayoutTests.CreateFont(text);
+ TextOptions options = new(font) { Dpi = font.FontMetrics.ScaleFactor };
+
+ TextMetrics metrics = TextMeasurer.Measure(text, options);
+
+ Assert.Equal(TextMeasurer.MeasureAdvance(text, options), metrics.Advance, Comparer);
+ Assert.Equal(TextMeasurer.MeasureBounds(text, options), metrics.Bounds, Comparer);
+ Assert.Equal(TextMeasurer.MeasureSize(text, options), metrics.Size, Comparer);
+ Assert.Equal(TextMeasurer.MeasureRenderableBounds(text, options), metrics.RenderableBounds, Comparer);
+ Assert.Equal(TextMeasurer.CountLines(text, options), metrics.LineCount);
+ }
+
+ [Theory]
+ [InlineData("h")]
+ [InlineData("hello")]
+ [InlineData("hello world")]
+ [InlineData("a b\nc")]
+ public void Measure_CharacterAdvances_MatchGranularOverload(string text)
+ {
+ Font font = TextLayoutTests.CreateFont(text);
+ TextOptions options = new(font) { Dpi = font.FontMetrics.ScaleFactor };
+
+ bool hasAdvances = TextMeasurer.TryMeasureCharacterAdvances(text, options, out ReadOnlySpan expected);
+ TextMetrics metrics = TextMeasurer.Measure(text, options);
+
+ Assert.Equal(hasAdvances, metrics.CharacterAdvances.Any(g => g.Bounds.Width > 0 || g.Bounds.Height > 0));
+ AssertGlyphBoundsEqual(expected, metrics.CharacterAdvances);
+ }
+
+ [Theory]
+ [InlineData("h")]
+ [InlineData("hello")]
+ [InlineData("hello world")]
+ [InlineData("a b\nc")]
+ public void Measure_CharacterBounds_MatchGranularOverload(string text)
+ {
+ Font font = TextLayoutTests.CreateFont(text);
+ TextOptions options = new(font) { Dpi = font.FontMetrics.ScaleFactor };
+
+ TextMeasurer.TryMeasureCharacterBounds(text, options, out ReadOnlySpan expected);
+ TextMetrics metrics = TextMeasurer.Measure(text, options);
+
+ AssertGlyphBoundsEqual(expected, metrics.CharacterBounds);
+ }
+
+ [Theory]
+ [InlineData("h")]
+ [InlineData("hello")]
+ [InlineData("hello world")]
+ [InlineData("a b\nc")]
+ public void Measure_CharacterSizes_MatchGranularOverload(string text)
+ {
+ Font font = TextLayoutTests.CreateFont(text);
+ TextOptions options = new(font) { Dpi = font.FontMetrics.ScaleFactor };
+
+ TextMeasurer.TryMeasureCharacterSizes(text, options, out ReadOnlySpan expected);
+ TextMetrics metrics = TextMeasurer.Measure(text, options);
+
+ AssertGlyphBoundsEqual(expected, metrics.CharacterSizes);
+ }
+
+ [Theory]
+ [InlineData("h")]
+ [InlineData("hello")]
+ [InlineData("hello world")]
+ [InlineData("a b\nc")]
+ public void Measure_CharacterRenderableBounds_MatchGranularOverload(string text)
+ {
+ Font font = TextLayoutTests.CreateFont(text);
+ TextOptions options = new(font) { Dpi = font.FontMetrics.ScaleFactor };
+
+ TextMeasurer.TryMeasureCharacterRenderableBounds(text, options, out ReadOnlySpan expected);
+ TextMetrics metrics = TextMeasurer.Measure(text, options);
+
+ AssertGlyphBoundsEqual(expected, metrics.CharacterRenderableBounds);
+ }
+
+ [Theory]
+ [InlineData("hello")]
+ [InlineData("hello\nworld")]
+ [InlineData("hello world\nhello world\nhello")]
+ public void Measure_Lines_MatchGetLineMetrics(string text)
+ {
+ Font font = TextLayoutTests.CreateFont(text);
+ TextOptions options = new(font) { Dpi = font.FontMetrics.ScaleFactor };
+
+ LineMetrics[] expected = TextMeasurer.GetLineMetrics(text, options);
+ TextMetrics metrics = TextMeasurer.Measure(text, options);
+
+ Assert.Equal(expected.Length, metrics.Lines.Count);
+ for (int i = 0; i < expected.Length; i++)
+ {
+ LineMetrics e = expected[i];
+ LineMetrics a = metrics.Lines[i];
+ Assert.Equal(e.Ascender, a.Ascender, Comparer);
+ Assert.Equal(e.Baseline, a.Baseline, Comparer);
+ Assert.Equal(e.Descender, a.Descender, Comparer);
+ Assert.Equal(e.LineHeight, a.LineHeight, Comparer);
+ Assert.Equal(e.Start, a.Start, Comparer);
+ Assert.Equal(e.Extent, a.Extent, Comparer);
+ }
+ }
+
+ [Fact]
+ public void Measure_PerCharacterArrays_HaveMatchingLengthAndCodepoints()
+ {
+ const string text = "hello world\nhello";
+ Font font = TextLayoutTests.CreateFont(text);
+ TextOptions options = new(font) { Dpi = font.FontMetrics.ScaleFactor };
+
+ TextMetrics metrics = TextMeasurer.Measure(text, options);
+
+ int count = metrics.CharacterAdvances.Count;
+ Assert.Equal(count, metrics.CharacterSizes.Count);
+ Assert.Equal(count, metrics.CharacterBounds.Count);
+ Assert.Equal(count, metrics.CharacterRenderableBounds.Count);
+
+ for (int i = 0; i < count; i++)
+ {
+ CodePoint cp = metrics.CharacterAdvances[i].Codepoint;
+ Assert.Equal(cp, metrics.CharacterSizes[i].Codepoint);
+ Assert.Equal(cp, metrics.CharacterBounds[i].Codepoint);
+ Assert.Equal(cp, metrics.CharacterRenderableBounds[i].Codepoint);
+ }
+ }
+
+ [Theory]
+ [InlineData(LayoutMode.HorizontalTopBottom)]
+ [InlineData(LayoutMode.VerticalLeftRight)]
+ [InlineData(LayoutMode.VerticalMixedLeftRight)]
+ public void Measure_MatchesGranularRectangles_AcrossLayoutModes(LayoutMode layoutMode)
+ {
+ const string text = "hello world";
+ Font font = TextLayoutTests.CreateFont(text);
+ TextOptions options = new(font)
+ {
+ Dpi = font.FontMetrics.ScaleFactor,
+ LayoutMode = layoutMode
+ };
+
+ TextMetrics metrics = TextMeasurer.Measure(text, options);
+
+ Assert.Equal(TextMeasurer.MeasureAdvance(text, options), metrics.Advance, Comparer);
+ Assert.Equal(TextMeasurer.MeasureBounds(text, options), metrics.Bounds, Comparer);
+ Assert.Equal(TextMeasurer.MeasureRenderableBounds(text, options), metrics.RenderableBounds, Comparer);
+ }
+
+ private static void AssertGlyphBoundsEqual(ReadOnlySpan expected, IReadOnlyList actual)
+ {
+ Assert.Equal(expected.Length, actual.Count);
+ for (int i = 0; i < expected.Length; i++)
+ {
+ GlyphBounds e = expected[i];
+ GlyphBounds a = actual[i];
+ Assert.Equal(e.Codepoint, a.Codepoint);
+ Assert.Equal(e.GraphemeIndex, a.GraphemeIndex);
+ Assert.Equal(e.StringIndex, a.StringIndex);
+ Assert.Equal(e.Bounds, a.Bounds, Comparer);
+ }
+ }
+}