diff --git a/src/SixLabors.Fonts/GlyphBounds.cs b/src/SixLabors.Fonts/GlyphBounds.cs index 69d8a0d0..87730e98 100644 --- a/src/SixLabors.Fonts/GlyphBounds.cs +++ b/src/SixLabors.Fonts/GlyphBounds.cs @@ -15,10 +15,14 @@ public readonly struct GlyphBounds /// /// The Unicode codepoint for the glyph. /// The glyph bounds. - public GlyphBounds(CodePoint codePoint, in FontRectangle bounds) + /// The index of the grapheme in original text. + /// The index of the codepoint in original text.. + public GlyphBounds(CodePoint codePoint, in FontRectangle bounds, int graphemeIndex, int stringIndex) { this.Codepoint = codePoint; this.Bounds = bounds; + this.GraphemeIndex = graphemeIndex; + this.StringIndex = stringIndex; } /// @@ -31,6 +35,16 @@ public GlyphBounds(CodePoint codePoint, in FontRectangle bounds) /// public FontRectangle Bounds { get; } + /// + /// Gets grapheme index of glyph in original text. + /// + public int GraphemeIndex { get; } + + /// + /// Gets string index of glyph in original text. + /// + public int StringIndex { get; } + /// public override string ToString() => $"Codepoint: {this.Codepoint}, Bounds: {this.Bounds}."; diff --git a/src/SixLabors.Fonts/GlyphLayout.cs b/src/SixLabors.Fonts/GlyphLayout.cs index 050c8e82..2b0c3ba8 100644 --- a/src/SixLabors.Fonts/GlyphLayout.cs +++ b/src/SixLabors.Fonts/GlyphLayout.cs @@ -19,7 +19,9 @@ internal GlyphLayout( float advanceWidth, float advanceHeight, GlyphLayoutMode layoutMode, - bool isStartOfLine) + bool isStartOfLine, + int graphemeIndex, + int stringIndex) { this.Glyph = glyph; this.CodePoint = glyph.GlyphMetrics.CodePoint; @@ -30,6 +32,8 @@ internal GlyphLayout( this.AdvanceY = advanceHeight; this.LayoutMode = layoutMode; this.IsStartOfLine = isStartOfLine; + this.GraphemeIndex = graphemeIndex; + this.StringIndex = stringIndex; } /// @@ -78,6 +82,16 @@ internal GlyphLayout( /// public bool IsStartOfLine { get; } + /// + /// Gets grapheme index of glyph in original text. + /// + public int GraphemeIndex { get; } + + /// + /// Gets string index of glyph in original text. + /// + public int StringIndex { get; } + /// /// Gets a value indicating whether the glyph represents a whitespace character. /// diff --git a/src/SixLabors.Fonts/TextLayout.cs b/src/SixLabors.Fonts/TextLayout.cs index 84d4af37..a9b1b02d 100644 --- a/src/SixLabors.Fonts/TextLayout.cs +++ b/src/SixLabors.Fonts/TextLayout.cs @@ -420,7 +420,9 @@ private static IEnumerable LayoutLineHorizontal( data.ScaledAdvance, advanceY, GlyphLayoutMode.Horizontal, - i == 0 && j == 0)); + i == 0 && j == 0, + data.GraphemeIndex, + data.StringIndex)); j++; } @@ -556,7 +558,9 @@ private static IEnumerable LayoutLineVertical( advanceX, data.ScaledAdvance, GlyphLayoutMode.Vertical, - i == 0 && j == 0)); + i == 0 && j == 0, + data.GraphemeIndex, + data.StringIndex)); j++; } @@ -689,7 +693,9 @@ private static IEnumerable LayoutLineVerticalMixed( advanceX, data.ScaledAdvance, GlyphLayoutMode.VerticalRotated, - i == 0 && j == 0)); + i == 0 && j == 0, + data.GraphemeIndex, + data.StringIndex)); j++; } @@ -712,7 +718,9 @@ private static IEnumerable LayoutLineVerticalMixed( advanceX, data.ScaledAdvance, GlyphLayoutMode.Vertical, - i == 0 && j == 0)); + i == 0 && j == 0, + data.GraphemeIndex, + data.StringIndex)); j++; } @@ -895,6 +903,7 @@ private static TextBox BreakLines( List textLines = new(); TextLine textLine = new(); int glyphCount = 0; + int stringIndex = 0; // No glyph should contain more than 64 metrics. // We do a sanity check below just in case. @@ -1205,12 +1214,15 @@ private static TextBox BreakLines( graphemeIndex, codePointIndex, isRotated, - isDecomposed); + isDecomposed, + stringIndex); } codePointIndex++; graphemeCodePointIndex++; } + + stringIndex += graphemeEnumerator.Current.Length; } // Add the final line. @@ -1268,7 +1280,8 @@ public void Add( int graphemeIndex, int offset, bool isRotated, - bool isDecomposed) + bool isDecomposed, + int stringIndex) { // Reset metrics. // We track the maximum metrics for each line to ensure glyphs can be aligned. @@ -1288,7 +1301,8 @@ public void Add( graphemeIndex, offset, isRotated, - isDecomposed)); + isDecomposed, + stringIndex)); } public TextLine SplitAt(LineBreak lineBreak, bool keepAll) @@ -1627,7 +1641,8 @@ public GlyphLayoutData( int graphemeIndex, int offset, bool isRotated, - bool isDecomposed) + bool isDecomposed, + int stringIndex) { this.Metrics = metrics; this.PointSize = pointSize; @@ -1640,6 +1655,7 @@ public GlyphLayoutData( this.Offset = offset; this.IsRotated = isRotated; this.IsDecomposed = isDecomposed; + this.StringIndex = stringIndex; } public readonly CodePoint CodePoint => this.Metrics[0].CodePoint; @@ -1668,6 +1684,8 @@ public GlyphLayoutData( public bool IsDecomposed { get; } + public int StringIndex { get; } + public readonly bool IsNewLine => CodePoint.IsNewLine(this.CodePoint); private readonly string DebuggerDisplay => FormattableString diff --git a/src/SixLabors.Fonts/TextMeasurer.cs b/src/SixLabors.Fonts/TextMeasurer.cs index 746a232d..12162095 100644 --- a/src/SixLabors.Fonts/TextMeasurer.cs +++ b/src/SixLabors.Fonts/TextMeasurer.cs @@ -265,7 +265,7 @@ internal static bool TryGetCharacterAdvances(IReadOnlyList glyphLay GlyphLayout glyph = glyphLayouts[i]; FontRectangle bounds = new(0, 0, glyph.AdvanceX * dpi, glyph.AdvanceY * dpi); hasSize |= bounds.Width > 0 || bounds.Height > 0; - characterBoundsList[i] = new GlyphBounds(glyph.Glyph.GlyphMetrics.CodePoint, in bounds); + characterBoundsList[i] = new GlyphBounds(glyph.Glyph.GlyphMetrics.CodePoint, in bounds, glyph.GraphemeIndex, glyph.StringIndex); } characterBounds = characterBoundsList; @@ -290,7 +290,7 @@ internal static bool TryGetCharacterSizes(IReadOnlyList glyphLayout bounds = new(0, 0, bounds.Width, bounds.Height); hasSize |= bounds.Width > 0 || bounds.Height > 0; - characterBoundsList[i] = new GlyphBounds(g.Glyph.GlyphMetrics.CodePoint, in bounds); + characterBoundsList[i] = new GlyphBounds(g.Glyph.GlyphMetrics.CodePoint, in bounds, g.GraphemeIndex, g.StringIndex); } characterBounds = characterBoundsList; @@ -312,7 +312,7 @@ internal static bool TryGetCharacterBounds(IReadOnlyList glyphLayou GlyphLayout g = glyphLayouts[i]; FontRectangle bounds = g.BoundingBox(dpi); hasSize |= bounds.Width > 0 || bounds.Height > 0; - characterBoundsList[i] = new GlyphBounds(g.Glyph.GlyphMetrics.CodePoint, in bounds); + characterBoundsList[i] = new GlyphBounds(g.Glyph.GlyphMetrics.CodePoint, in bounds, g.GraphemeIndex, g.StringIndex); } characterBounds = characterBoundsList; diff --git a/tests/SixLabors.Fonts.Tests/TextLayoutTests.cs b/tests/SixLabors.Fonts.Tests/TextLayoutTests.cs index 5d0c89a8..b3f6b1dc 100644 --- a/tests/SixLabors.Fonts.Tests/TextLayoutTests.cs +++ b/tests/SixLabors.Fonts.Tests/TextLayoutTests.cs @@ -3,7 +3,6 @@ using System.Globalization; using System.Numerics; -using System.Text; using SixLabors.Fonts.Tests.Fakes; using SixLabors.Fonts.Unicode; @@ -241,10 +240,10 @@ public void TryMeasureCharacterBounds() const string text = "a b\nc"; GlyphBounds[] expectedGlyphMetrics = { - new(new CodePoint('a'), new FontRectangle(10, 0, 10, 10)), - new(new CodePoint(' '), new FontRectangle(40, 0, 30, 10)), - new(new CodePoint('b'), new FontRectangle(70, 0, 10, 10)), - new(new CodePoint('c'), new FontRectangle(10, 30, 10, 10)), + new(new CodePoint('a'), new FontRectangle(10, 0, 10, 10), 0, 0), + new(new CodePoint(' '), new FontRectangle(40, 0, 30, 10), 1, 1), + new(new CodePoint('b'), new FontRectangle(70, 0, 10, 10), 2, 2), + new(new CodePoint('c'), new FontRectangle(10, 30, 10, 10), 3, 3), }; Font font = CreateFont(text); @@ -996,6 +995,73 @@ public void CanMeasureMultilineCharacterLayouts() } } + [Fact] + public void DoesMeasureCharacterLayoutIncludeStringIndex() + { + FontFamily family = new FontCollection().Add(TestFonts.OpenSansFile); + family.TryGetMetrics(FontStyle.Regular, out FontMetrics metrics); + + TextOptions options = new(family.CreateFont(metrics.UnitsPerEm)) + { + LineSpacing = 1.5F + }; + + const string text = "The quick๐Ÿ‘ฉ๐Ÿฝโ€๐Ÿš’ brown fox jumps over \r\n the lazy dog"; + + Assert.True(TextMeasurer.TryMeasureCharacterAdvances(text, options, out ReadOnlySpan advances)); + Assert.True(TextMeasurer.TryMeasureCharacterSizes(text, options, out ReadOnlySpan sizes)); + Assert.True(TextMeasurer.TryMeasureCharacterBounds(text, options, out ReadOnlySpan bounds)); + + Assert.Equal(advances.Length, sizes.Length); + Assert.Equal(advances.Length, bounds.Length); + + int stringIndex = -1; + + for (int i = 0; i < advances.Length; i++) + { + GlyphBounds advance = advances[i]; + GlyphBounds size = sizes[i]; + GlyphBounds bound = bounds[i]; + + Assert.Equal(bound.StringIndex, advance.StringIndex); + Assert.Equal(bound.StringIndex, size.StringIndex); + + Assert.Equal(bound.GraphemeIndex, advance.GraphemeIndex); + Assert.Equal(bound.GraphemeIndex, size.GraphemeIndex); + + if (bound.Codepoint == new CodePoint("k"[0])) + { + stringIndex = text.IndexOf("k", StringComparison.InvariantCulture); + Assert.Equal(stringIndex, bound.StringIndex); + Assert.Equal(stringIndex, bound.GraphemeIndex); + } + + // after emoji + if (bound.Codepoint == new CodePoint("b"[0])) + { + stringIndex = text.IndexOf("b", StringComparison.InvariantCulture); + Assert.NotEqual(bound.StringIndex, bound.GraphemeIndex); + Assert.Equal(stringIndex, bound.StringIndex); + Assert.Equal(11, bound.GraphemeIndex); + } + } + + SpanGraphemeEnumerator graphemeEnumerator = new(text); + int graphemeCount = 0; + while (graphemeEnumerator.MoveNext()) + { + graphemeCount += 1; + } + + GlyphBounds firstBound = bounds[0]; + Assert.Equal(0, firstBound.StringIndex); + Assert.Equal(0, firstBound.GraphemeIndex); + + GlyphBounds lastBound = bounds[^1]; + Assert.Equal(text.Length - 1, lastBound.StringIndex); + Assert.Equal(graphemeCount - 1, lastBound.GraphemeIndex); + } + private static readonly Font OpenSansTTF = new FontCollection().Add(TestFonts.OpenSansFile).CreateFont(10); private static readonly Font OpenSansWoff = new FontCollection().Add(TestFonts.OpenSansFile).CreateFont(10);