From e97fcca84d6184db002ab06e8798d91fe830615b Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Wed, 22 Apr 2026 21:24:42 +1000 Subject: [PATCH] Add streaming layout visitors and TextMetrics --- src/SixLabors.Fonts/TextLayout.Visitors.cs | 107 ++++ src/SixLabors.Fonts/TextLayout.cs | 537 ++++++++++++++++-- src/SixLabors.Fonts/TextMeasurer.cs | 139 ++++- src/SixLabors.Fonts/TextMetrics.cs | 140 +++++ .../TextMeasurerReferenceTests.cs | 482 ++++++++++++++++ .../SixLabors.Fonts.Tests/TextMetricsTests.cs | 237 ++++++++ 6 files changed, 1592 insertions(+), 50 deletions(-) create mode 100644 src/SixLabors.Fonts/TextLayout.Visitors.cs create mode 100644 src/SixLabors.Fonts/TextMetrics.cs create mode 100644 tests/SixLabors.Fonts.Tests/TextMeasurerReferenceTests.cs create mode 100644 tests/SixLabors.Fonts.Tests/TextMetricsTests.cs 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); + } + } +}