From 4b83190afe807d3b5a3c96978009c47d6fe37766 Mon Sep 17 00:00:00 2001 From: Benedikt Stebner Date: Wed, 20 Aug 2025 13:14:30 +0200 Subject: [PATCH] Fix buffer index calculation for the zero width character handling during hit testing (#19488) --- .../Media/TextFormatting/TextLineImpl.cs | 40 +++++--- .../Fonts/DF7segHMI.ttf | Bin 0 -> 9272 bytes .../Media/GlyphRunTests.cs | 89 ++++++++++++++++++ .../Media/TextFormatting/TextLineTests.cs | 34 +++++++ 4 files changed, 152 insertions(+), 11 deletions(-) create mode 100644 tests/Avalonia.Skia.UnitTests/Fonts/DF7segHMI.ttf diff --git a/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs b/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs index 6e7e39fe59..bebbe5d190 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs +++ b/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs @@ -1098,23 +1098,39 @@ namespace Avalonia.Media.TextFormatting var characterLength = Math.Max(0, Math.Abs(startHit.FirstCharacterIndex + startHit.TrailingLength - endHit.FirstCharacterIndex - endHit.TrailingLength) - clusterOffset); - if (characterLength == 0 && currentRun.Text.Length > 0 && startIndex < currentRun.Text.Length) + remainingLength -= characterLength; + + var runOffset = startIndex - firstCluster; + + //Make sure we are properly dealing with zero width space runs + if (remainingLength > 0 && currentRun.Text.Length > 0 && runOffset + characterLength < currentRun.Text.Length) { - //Make sure we are properly dealing with zero width space runs - var codepointEnumerator = new CodepointEnumerator(currentRun.Text.Span.Slice(startIndex)); + var glyphInfos = currentRun.GlyphRun.GlyphInfos; - while (remainingLength > 0 && codepointEnumerator.MoveNext(out var codepoint)) + for (int i = runOffset + characterLength; i < glyphInfos.Count; i++) { - if (codepoint.IsWhiteSpace) + var glyphInfo = glyphInfos[i]; + + if(glyphInfo.GlyphAdvance > 0) { - characterLength++; - remainingLength--; + break; } - else + + var graphemeEnumerator = new GraphemeEnumerator(currentRun.Text.Span.Slice(runOffset + characterLength)); + + if(!graphemeEnumerator.MoveNext(out var grapheme)) { break; } - } + + characterLength += grapheme.Length - clusterOffset; + remainingLength -= grapheme.Length; + + if(remainingLength <= 0) + { + break; + } + } } if (endX < startX) @@ -1181,10 +1197,12 @@ namespace Avalonia.Media.TextFormatting var characterLength = Math.Max(0, Math.Abs(startHit.FirstCharacterIndex + startHit.TrailingLength - endHit.FirstCharacterIndex - endHit.TrailingLength) - clusterOffset); - if (characterLength == 0 && currentRun.Text.Length > 0 && startIndex < currentRun.Text.Length) + var runOffset = startIndex - offset; + + if (characterLength == 0 && currentRun.Text.Length > 0 && runOffset < currentRun.Text.Length) { //Make sure we are properly dealing with zero width space runs - var codepointEnumerator = new CodepointEnumerator(currentRun.Text.Span.Slice(startIndex)); + var codepointEnumerator = new CodepointEnumerator(currentRun.Text.Span.Slice(runOffset)); while (remainingLength > 0 && codepointEnumerator.MoveNext(out var codepoint)) { diff --git a/tests/Avalonia.Skia.UnitTests/Fonts/DF7segHMI.ttf b/tests/Avalonia.Skia.UnitTests/Fonts/DF7segHMI.ttf new file mode 100644 index 0000000000000000000000000000000000000000..7c2b47bb0f83ac4d9d5c5da460c0b6583b0896c6 GIT binary patch literal 9272 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 WjtGgF;o3ia%d`A$T=@T(sQ&=`EXYRy literal 0 HcmV?d00001 diff --git a/tests/Avalonia.Skia.UnitTests/Media/GlyphRunTests.cs b/tests/Avalonia.Skia.UnitTests/Media/GlyphRunTests.cs index b807e1389b..1cad489f0d 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/GlyphRunTests.cs +++ b/tests/Avalonia.Skia.UnitTests/Media/GlyphRunTests.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Globalization; +using System.Linq; using Avalonia.Media; using Avalonia.Media.TextFormatting; using Avalonia.UnitTests; @@ -191,6 +192,94 @@ namespace Avalonia.Skia.UnitTests.Media } } + [Fact] + public void Should_Get_Distance_From_CharacterHit_Non_Trailing_RightToLeft() + { + const string text = "נִקּוּד"; + + using (Start()) + { + var typeface = new Typeface("resm:Avalonia.Skia.UnitTests.Assets?assembly=Avalonia.Skia.UnitTests#Inter"); + var options = new TextShaperOptions(typeface.GlyphTypeface, 14, 1); + var shapedBuffer = TextShaper.Current.ShapeText(text, options); + + var glyphRun = CreateGlyphRun(shapedBuffer); + + //Get the distance to the left side of the first glyph + var actualDistance = glyphRun.GetDistanceFromCharacterHit(new CharacterHit(text.Length)); + + var expectedDistance = 0.0; + + Assert.Equal(expectedDistance, actualDistance, 2); + + //Get the distance to the right side of the first glyph + actualDistance = glyphRun.GetDistanceFromCharacterHit(new CharacterHit(text.Length - 1)); + + expectedDistance = shapedBuffer[0].GlyphAdvance; + + Assert.Equal(expectedDistance, actualDistance, 2); + } + } + + [Fact] + public void Should_Get_Distance_From_CharacterHit_Zero_Width() + { + const string text = "נִקּוּד"; + + using (Start()) + { + var typeface = new Typeface("resm:Avalonia.Skia.UnitTests.Assets?assembly=Avalonia.Skia.UnitTests#Inter"); + var options = new TextShaperOptions(typeface.GlyphTypeface, 14, 1); + var shapedBuffer = TextShaper.Current.ShapeText(text, options); + + var glyphRun = CreateGlyphRun(shapedBuffer); + + var clusters = glyphRun.GlyphInfos.GroupBy(x => x.GlyphCluster); + + var rightSideDistances = new List(); + + var leftSideDistances = new List(); + + var currentX = 0.0; + + foreach (var cluster in clusters) + { + leftSideDistances.Add(currentX); + + currentX += cluster.Sum(x => x.GlyphAdvance); + + rightSideDistances.Add(currentX); + } + + var characterIndices = clusters.Select(x => x.First().GlyphCluster).ToList(); + + var characterHit = new CharacterHit(text.Length); + + for (var i = 0; i < characterIndices.Count; i++) + { + var characterIndex = characterIndices[i]; + + var leftSideDistance = leftSideDistances[i]; + + var leftSideCharacterHit = glyphRun.GetCharacterHitFromDistance(leftSideDistance, out _); + + var distance = glyphRun.GetDistanceFromCharacterHit(leftSideCharacterHit); + + Assert.Equal(leftSideDistance, distance, 2); + + var previousCharacterHit = glyphRun.GetPreviousCaretCharacterHit(characterHit); + + distance = glyphRun.GetDistanceFromCharacterHit(new CharacterHit(characterIndex)); + + var rightSideDistance = rightSideDistances[i]; + + Assert.Equal(rightSideDistance, distance, 2); + + characterHit = previousCharacterHit; + } + } + } + private static List BuildRects(GlyphRun glyphRun) { var height = glyphRun.Bounds.Height; diff --git a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs index ae74c76194..3df6c92928 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs +++ b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs @@ -1215,6 +1215,40 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting } } + [Fact] + public void Should_Get_TextBounds_With_Trailing_Zero_Advance() + { + const string df7Font = "resm:Avalonia.Skia.UnitTests.Fonts?assembly=Avalonia.Skia.UnitTests#DF7segHMI"; + + using (Start()) + { + var typeface = new Typeface(df7Font); + var defaultProperties = new GenericTextRunProperties(typeface); + var textSource = new SingleBufferTextSource("3,47-=?:#", defaultProperties); + + var formatter = new TextFormatterImpl(); + + var textLine = + formatter.FormatLine(textSource, 0, double.PositiveInfinity, + new GenericTextParagraphProperties(defaultProperties)); + + Assert.NotNull(textLine); + + var textBounds = textLine.GetTextBounds(0, 2); + + Assert.NotEmpty(textBounds); + + var textRunBounds = textBounds.First().TextRunBounds; + + Assert.NotEmpty(textBounds); + + var first = textRunBounds.First(); + + Assert.Equal(0, first.TextSourceCharacterIndex); + Assert.Equal(2, first.Length); + } + } + private class TextHidden : TextRun { public TextHidden(int length)