From 93305198210faa6ceb41ac3baef7428d1e3cb177 Mon Sep 17 00:00:00 2001 From: Benedikt Stebner Date: Thu, 9 Apr 2026 20:47:28 +0200 Subject: [PATCH] Ensure glyph clusters are slice relative (#21089) Co-authored-by: Julien Lebosquain --- .../Media/TextFormatting/TextLineImpl.cs | 105 ++++++++++-------- .../Avalonia.HarfBuzz/HarfBuzzTextShaper.cs | 5 +- .../TextFormatting/HarfbuzzTextShaperTests.cs | 23 ++++ .../Media/TextFormatting/TextLayoutTests.cs | 37 +++++- .../Media/TextFormatting/TextLineTests.cs | 11 +- 5 files changed, 124 insertions(+), 57 deletions(-) diff --git a/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs b/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs index 12ae974c26..8809a3fc48 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs +++ b/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs @@ -208,57 +208,40 @@ namespace Avalonia.Media.TextFormatting distance -= Start; var lastIndex = _textRuns.Length - 1; - var lineLength = Length; - if (_textRuns[lastIndex] is TextEndOfLine textEndOfLine) + if (_textRuns[lastIndex] is TextEndOfLine) { lastIndex--; - lineLength -= textEndOfLine.Length; } - var currentPosition = FirstTextSourceIndex; - if (lastIndex < 0) { - return new CharacterHit(currentPosition); + return new CharacterHit(FirstTextSourceIndex); } if (distance <= 0) { - var firstRun = _textRuns[0]; - - if (_paragraphProperties.FlowDirection == FlowDirection.RightToLeft) - { - currentPosition += lineLength - firstRun.Length; - } - - return GetRunCharacterHit(firstRun, currentPosition, 0); + return GetRunCharacterHit(_textRuns[0], GetRunTextSourcePosition(0), 0); } if (distance >= WidthIncludingTrailingWhitespace) { - var lastRun = _textRuns[lastIndex]; - - if (_paragraphProperties.FlowDirection == FlowDirection.LeftToRight) - { - currentPosition += lineLength - lastRun.Length; - } - - return GetRunCharacterHit(lastRun, currentPosition, distance); + return GetRunCharacterHit(_textRuns[lastIndex], GetRunTextSourcePosition(lastIndex), distance); } // process hit that happens within the line var characterHit = new CharacterHit(); var currentDistance = 0.0; + var currentVisualIndex = 0; for (var i = 0; i <= lastIndex; i++) { var currentRun = _textRuns[i]; + currentVisualIndex = i; if (currentRun is ShapedTextRun shapedRun && !shapedRun.ShapedBuffer.IsLeftToRight) { var rightToLeftIndex = i; - currentPosition += currentRun.Length; while (rightToLeftIndex + 1 <= _textRuns.Length - 1) { @@ -269,8 +252,6 @@ namespace Avalonia.Media.TextFormatting break; } - currentPosition += nextShaped.Length; - rightToLeftIndex++; } @@ -282,6 +263,7 @@ namespace Avalonia.Media.TextFormatting } currentRun = _textRuns[j]; + currentVisualIndex = j; if (currentRun is not ShapedTextRun) { @@ -293,16 +275,15 @@ namespace Avalonia.Media.TextFormatting if (currentDistance + shapedRun.Size.Width <= distance) { currentDistance += shapedRun.Size.Width; - currentPosition -= currentRun.Length; continue; } - return GetRunCharacterHit(currentRun, currentPosition, distance - currentDistance); + return GetRunCharacterHit(currentRun, GetRunTextSourcePosition(j), distance - currentDistance); } } - characterHit = GetRunCharacterHit(currentRun, currentPosition, distance - currentDistance); + characterHit = GetRunCharacterHit(currentRun, GetRunTextSourcePosition(currentVisualIndex), distance - currentDistance); if (currentRun is DrawableTextRun drawableTextRun) { @@ -310,15 +291,11 @@ namespace Avalonia.Media.TextFormatting { currentDistance += drawableTextRun.Size.Width; - currentPosition += currentRun.Length; - continue; } } else { - currentPosition += currentRun.Length; - continue; } @@ -338,12 +315,7 @@ namespace Avalonia.Media.TextFormatting { characterHit = shapedRun.GlyphRun.GetCharacterHitFromDistance(distance, out _); - var offset = 0; - - if (shapedRun.GlyphRun.IsLeftToRight) - { - offset = Math.Max(0, currentPosition - shapedRun.GlyphRun.Metrics.FirstCluster); - } + var offset = Math.Max(0, currentPosition - shapedRun.GlyphRun.Metrics.FirstCluster); characterHit = new CharacterHit(offset + characterHit.FirstCharacterIndex, characterHit.TrailingLength); @@ -515,18 +487,33 @@ namespace Avalonia.Media.TextFormatting { case ShapedTextRun shapedRun: { - var offset = Math.Max(0, currentPosition - shapedRun.GlyphRun.Metrics.FirstCluster - characterHit.TrailingLength); + var offset = Math.Max(0, currentPosition - shapedRun.GlyphRun.Metrics.FirstCluster); - if (offset > 0) + if (characterHit.FirstCharacterIndex < currentPosition && offset > 0) { - currentCharacterrHit = new CharacterHit(Math.Max(0, characterHit.FirstCharacterIndex - offset), characterHit.TrailingLength); + // Crossing from a previous run: find the nearest character hit at the first cluster + var nearest = shapedRun.GlyphRun.FindNearestCharacterHit( + shapedRun.GlyphRun.Metrics.FirstCluster, out _); + nextCharacterHit = new CharacterHit( + nearest.FirstCharacterIndex + offset, nearest.TrailingLength); } + else + { + if (offset > 0) + { + currentCharacterrHit = new CharacterHit( + Math.Max(0, characterHit.FirstCharacterIndex - offset), + characterHit.TrailingLength); + } - nextCharacterHit = shapedRun.GlyphRun.GetNextCaretCharacterHit(currentCharacterrHit); + nextCharacterHit = shapedRun.GlyphRun.GetNextCaretCharacterHit(currentCharacterrHit); - if (offset > 0) - { - nextCharacterHit = new CharacterHit(nextCharacterHit.FirstCharacterIndex + offset, nextCharacterHit.TrailingLength); + if (offset > 0) + { + nextCharacterHit = new CharacterHit( + nextCharacterHit.FirstCharacterIndex + offset, + nextCharacterHit.TrailingLength); + } } break; } @@ -569,6 +556,34 @@ namespace Avalonia.Media.TextFormatting return currentDirection; } + /// + /// Gets the text source character index for the run at the given visual position in . + /// + /// The index of the run in the visual-order array. + /// The text source character index where the run starts. + private int GetRunTextSourcePosition(int visualRunIndex) + { + if (_indexedTextRuns != null) + { + for (var i = 0; i < _indexedTextRuns.Count; i++) + { + if (_indexedTextRuns[i].RunIndex == visualRunIndex) + { + return _indexedTextRuns[i].TextSourceCharacterIndex; + } + } + } + + var position = FirstTextSourceIndex; + + for (var i = 0; i < visualRunIndex && i < _textRuns.Length; i++) + { + position += _textRuns[i].Length; + } + + return position; + } + /// /// Get the last consecutive visual run index that shares the same direction as the current direction. /// diff --git a/src/HarfBuzz/Avalonia.HarfBuzz/HarfBuzzTextShaper.cs b/src/HarfBuzz/Avalonia.HarfBuzz/HarfBuzzTextShaper.cs index ee8ee2a531..b1bc41237c 100644 --- a/src/HarfBuzz/Avalonia.HarfBuzz/HarfBuzzTextShaper.cs +++ b/src/HarfBuzz/Avalonia.HarfBuzz/HarfBuzzTextShaper.cs @@ -86,13 +86,14 @@ namespace Avalonia.Harfbuzz var glyphIndex = (ushort)sourceInfo.Codepoint; - var glyphCluster = (int)sourceInfo.Cluster; + var originalCluster = (int)sourceInfo.Cluster; + var glyphCluster = originalCluster - start; var glyphAdvance = GetGlyphAdvance(glyphPositions, i, textScale) + options.LetterSpacing; var glyphOffset = GetGlyphOffset(glyphPositions, i, textScale); - if (glyphCluster < containingText.Length && containingText[glyphCluster] == '\t') + if (originalCluster < containingText.Length && containingText[originalCluster] == '\t') { glyphIndex = glyphTypeface.CharacterToGlyphMap[' ']; diff --git a/tests/Avalonia.Base.UnitTests/Media/TextFormatting/HarfbuzzTextShaperTests.cs b/tests/Avalonia.Base.UnitTests/Media/TextFormatting/HarfbuzzTextShaperTests.cs index 6467d9a13f..0babde2ed5 100644 --- a/tests/Avalonia.Base.UnitTests/Media/TextFormatting/HarfbuzzTextShaperTests.cs +++ b/tests/Avalonia.Base.UnitTests/Media/TextFormatting/HarfbuzzTextShaperTests.cs @@ -100,6 +100,29 @@ public class HarfBuzzTextShaperTests } } + [Fact] + public void ShapeText_WithSlicedMemory_ClusterValuesAreSliceRelative() + { + using (UnitTestApplication.Start(Services)) + { + var fullString = new string('A', 1000) + "Hello" + new string('B', 1000); + var sliced = fullString.AsMemory().Slice(1000, 5); + + var options = CreateTextShaperOptions(); + + var result = _shaper.ShapeText(sliced, options); + + Assert.NotNull(result); + Assert.Equal(5, result.Length); + + for (var i = 0; i < result.Length; i++) + { + Assert.True(result[i].GlyphCluster >= 0 && result[i].GlyphCluster < 5, + $"Glyph cluster at index {i} was {result[i].GlyphCluster}, expected a value in [0, 5)."); + } + } + } + private TextShaperOptions CreateTextShaperOptions( sbyte bidiLevel = 0, double letterSpacing = 0, diff --git a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLayoutTests.cs b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLayoutTests.cs index ed5947690f..3c688d8e79 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLayoutTests.cs +++ b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLayoutTests.cs @@ -475,8 +475,8 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting [Theory] [InlineData("☝🏿", new int[] { 0 })] - [InlineData("☝🏿 ab", new int[] { 0, 3, 4, 5 })] - [InlineData("ab ☝🏿", new int[] { 0, 1, 2, 3 })] + [InlineData("☝🏿 ab", new int[] { 0, 3, 0, 1 })] + [InlineData("ab ☝🏿", new int[] { 0, 1, 2, 0 })] public void Should_Create_Valid_Clusters_For_Text(string text, int[] clusters) { using (Start()) @@ -876,11 +876,12 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting Assert.Equal(4, hit.TextPosition); + var firstRunOffset = TextTestHelper.GetStartCharIndex(firstRun.Text); var currentX = 0.0; for (var i = 0; i < firstRun.GlyphRun.GlyphInfos.Count; i++) { - var cluster = firstRun.GlyphRun.GlyphInfos[i].GlyphCluster; + var cluster = firstRun.GlyphRun.GlyphInfos[i].GlyphCluster + firstRunOffset; var advance = firstRun.GlyphRun.GlyphInfos[i].GlyphAdvance; hit = layout.HitTestPoint(new Point(currentX, 0)); @@ -906,11 +907,12 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting Assert.Equal(0, hit.TextPosition); + var secondRunOffset = TextTestHelper.GetStartCharIndex(secondRun.Text); currentX = firstRun.Size.Width + 0.5; for (var i = 0; i < secondRun.GlyphRun.GlyphInfos.Count; i++) { - var cluster = secondRun.GlyphRun.GlyphInfos[i].GlyphCluster; + var cluster = secondRun.GlyphRun.GlyphInfos[i].GlyphCluster + secondRunOffset; var advance = secondRun.GlyphRun.GlyphInfos[i].GlyphAdvance; hit = layout.HitTestPoint(new Point(currentX, 0)); @@ -1002,7 +1004,29 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting var shapedRuns = textLine.TextRuns.Cast().ToList(); - var clusters = shapedRuns.SelectMany(x => x.ShapedBuffer, (_, glyph) => glyph.GlyphCluster).ToList(); + var runStarts = textLine + .GetTextBounds(textLine.FirstTextSourceIndex, textLine.Length) + .SelectMany(bounds => bounds.TextRunBounds) + .Where(bounds => bounds.TextRun is ShapedTextRun) + .ToDictionary(bounds => (ShapedTextRun)bounds.TextRun, bounds => bounds.TextSourceCharacterIndex); + + var clusters = shapedRuns.SelectMany(run => + { + var rawClusters = run.ShapedBuffer.Select(glyph => glyph.GlyphCluster).ToList(); + + if (!runStarts.TryGetValue(run, out var runStart) || rawClusters.Count == 0) + { + return rawClusters; + } + + // Clusters can be either run-local or text-source relative depending on split history. + if (rawClusters.Min() < runStart) + { + return rawClusters.Select(cluster => cluster + runStart); + } + + return rawClusters; + }).ToList(); var glyphAdvances = shapedRuns.SelectMany(x => x.ShapedBuffer, (_, glyph) => glyph.GlyphAdvance).ToList(); @@ -1016,7 +1040,8 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting var characterHit = textLine.GetCharacterHitFromDistance(currentX); - Assert.Equal(cluster, characterHit.FirstCharacterIndex + characterHit.TrailingLength); + Assert.True(cluster == characterHit.FirstCharacterIndex + characterHit.TrailingLength, + $"grapheme={i - grapheme.Length}, j={j}, cluster={cluster}, hit={characterHit.FirstCharacterIndex}+{characterHit.TrailingLength}, currentX={currentX}, textLen={text.Length}, runs={shapedRuns.Count}, clusters=[{string.Join(",", clusters)}]"); var distance = textLine.GetDistanceFromCharacterHit(new CharacterHit(cluster)); diff --git a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs index fa2884bacc..7b58320e17 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs +++ b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs @@ -101,8 +101,9 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting foreach (var textRun in textLine.TextRuns.OrderBy(x => TextTestHelper.GetStartCharIndex(x.Text))) { var shapedRun = (ShapedTextRun)textRun; + var runOffset = TextTestHelper.GetStartCharIndex(shapedRun.Text); - var runClusters = shapedRun.ShapedBuffer.Select(glyph => glyph.GlyphCluster); + var runClusters = shapedRun.ShapedBuffer.Select(glyph => glyph.GlyphCluster + runOffset); clusters.AddRange(shapedRun.IsReversed ? runClusters.Reverse() : runClusters); } @@ -150,8 +151,9 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting foreach (var textRun in textLine.TextRuns.OrderBy(x => TextTestHelper.GetStartCharIndex(x.Text))) { var shapedRun = (ShapedTextRun)textRun; + var runOffset = TextTestHelper.GetStartCharIndex(shapedRun.Text); - var runClusters = shapedRun.ShapedBuffer.Select(glyph => glyph.GlyphCluster); + var runClusters = shapedRun.ShapedBuffer.Select(glyph => glyph.GlyphCluster + runOffset); clusters.AddRange(shapedRun.IsReversed ? runClusters.Reverse() : runClusters); } @@ -262,7 +264,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting var clusters = textLine.TextRuns .Cast() - .SelectMany(x => x.ShapedBuffer, (_, glyph) => glyph.GlyphCluster) + .SelectMany(x => x.ShapedBuffer, (run, glyph) => glyph.GlyphCluster + TextTestHelper.GetStartCharIndex(run.Text)) .ToArray(); var previousCharacterHit = new CharacterHit(text.Length); @@ -1377,9 +1379,10 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting foreach (var textRun in shapedTextRuns) { + var runOffset = TextTestHelper.GetStartCharIndex(textRun.Text); var shapedBuffer = textRun.ShapedBuffer; - var currentClusters = shapedBuffer.Select(glyph => glyph.GlyphCluster).ToList(); + var currentClusters = shapedBuffer.Select(glyph => glyph.GlyphCluster + runOffset).ToList(); foreach (var currentCluster in currentClusters) {