From b92bb933aca599539e61514682627c96fa23cb7a Mon Sep 17 00:00:00 2001 From: Benedikt Stebner Date: Fri, 5 Sep 2025 15:04:34 +0200 Subject: [PATCH] TextLine.GetTextBounds zero width run fixes (#19602) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Rework GetRunBounds * Reduce duplication * Update tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs Co-authored-by: Jan Kučera <10546952+miloush@users.noreply.github.com> --------- Co-authored-by: Jan Kučera <10546952+miloush@users.noreply.github.com> --- .../Media/TextFormatting/TextLineImpl.cs | 130 +++++------------- .../Media/TextFormatting/TextLineTests.cs | 36 +++++ 2 files changed, 70 insertions(+), 96 deletions(-) diff --git a/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs b/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs index 2e434cd078..71cff3c483 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs +++ b/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs @@ -916,7 +916,7 @@ namespace Avalonia.Media.TextFormatting if (currentRun is ShapedTextRun shapedTextRun) { - var runBounds = GetRunBoundsRightToLeft(shapedTextRun, startX, firstTextSourceIndex, remainingLength, currentPosition); + var runBounds = GetRunBounds(shapedTextRun, startX, firstTextSourceIndex, remainingLength, currentPosition); if (runBounds.TextSourceCharacterIndex < FirstTextSourceIndex + Length) { @@ -996,7 +996,7 @@ namespace Avalonia.Media.TextFormatting if (currentRun is ShapedTextRun shapedTextRun) { - var runBounds = GetRunBoundsLeftToRight(shapedTextRun, endX, firstTextSourceIndex, remainingLength, currentPosition); + var runBounds = GetRunBounds(shapedTextRun, endX, firstTextSourceIndex, remainingLength, currentPosition); if(runBounds.TextSourceCharacterIndex < FirstTextSourceIndex + Length) { @@ -1062,12 +1062,15 @@ namespace Avalonia.Media.TextFormatting return new TextBounds(bounds, FlowDirection.LeftToRight, textRunBounds); } - private TextRunBounds GetRunBoundsLeftToRight(ShapedTextRun currentRun, double startX, - int firstTextSourceIndex, int remainingLength, int currentPosition) + private TextRunBounds GetRunBounds(ShapedTextRun currentRun, double currentX, int firstTextSourceIndex, int remainingLength, int currentPosition) { - //Determine the start of the first hit in local positions. + bool isLeftToRight = currentRun.BidiLevel % 2 == 0; + + double startX = currentX; + double endX = currentX; + + // Determine the start of the first hit in local positions var runOffset = Math.Max(0, firstTextSourceIndex - currentPosition); - var firstCluster = currentRun.GlyphRun.Metrics.FirstCluster; //The start index needs to be relative to the first cluster @@ -1081,15 +1084,13 @@ namespace Avalonia.Media.TextFormatting var clusterOffset = 0; - //Cluster boundary correction - if (runOffset > 0) - { + // Cluster boundary correction + if (runOffset > 0) + { var characterHit = currentRun.GlyphRun.FindNearestCharacterHit(startIndex, out _); - var clusterStart = characterHit.FirstCharacterIndex; var clusterEnd = clusterStart + characterHit.TrailingLength; - //Test against left and right edge if (clusterStart < startIndex && clusterEnd > startIndex) { //Remember the cluster correction offset @@ -1100,106 +1101,43 @@ namespace Avalonia.Media.TextFormatting } } - //Find the visual start and end position we want to hit test against + //Find the visual start and end position of the hit var startOffset = currentRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex)); var endOffset = currentRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(endIndex)); - // Preserve non-zero width for zero-advance ranges - if (startOffset == endOffset && startIndex != endIndex) + if (isLeftToRight) { - //We need to make sure a zero width text line is hit test at the end so we add some delta - endOffset += MathUtilities.DoubleEpsilon; + endX = startX + endOffset; + startX += startOffset; } - - var endX = startX + endOffset; - startX += startOffset; - - //Hit test against visual positions - var startHit = currentRun.GlyphRun.GetCharacterHitFromDistance(startOffset, out _); - var endHit = currentRun.GlyphRun.GetCharacterHitFromDistance(endOffset, out _); - - var startHitIndex = startHit.FirstCharacterIndex + startHit.TrailingLength; - var endHitIndex = endHit.FirstCharacterIndex + endHit.TrailingLength; - - //Adjust characterLength by the cluster offset to only cover the remaining length of the cluster. - var characterLength = Math.Max(0, Math.Abs(startHitIndex - endHitIndex) - clusterOffset); - - // Normalize bounds - if (endX < startX) + else { - (endX, startX) = (startX, endX); + //We need the distance from right to left and GetDistanceFromCharacterHit returs a distance from left to right so we need to adjust the offsets + startX -= currentRun.Size.Width - startOffset; + endX -= currentRun.Size.Width - endOffset; } - var runWidth = endX - startX; - - //We need to adjust the local position to the text source - var textSourceIndex = textSourceOffset + startHitIndex + clusterOffset; - - return new TextRunBounds(new Rect(startX, 0, runWidth, Height), textSourceIndex, characterLength, currentRun); - } - - private TextRunBounds GetRunBoundsRightToLeft(ShapedTextRun currentRun, double endX, int firstTextSourceIndex, int remainingLength, int currentPosition) - { - // We start from the right edge of the run - var startX = endX; - - //Determine the start of the first hit in local positions. - var runOffset = Math.Max(0, firstTextSourceIndex - currentPosition); - - var firstCluster = currentRun.GlyphRun.Metrics.FirstCluster; - - //The start index needs to be relative to the first cluster - var startIndex = firstCluster + runOffset; - var endIndex = startIndex + remainingLength; + // Find the start of the hit + var startHit = currentRun.GlyphRun.GetCharacterHitFromDistance(startOffset, out _); + var startHitIndex = startHit.FirstCharacterIndex + startHit.TrailingLength; - //Current position is a text source index and first cluster is relative to the GlyphRun's buffer. - var textSourceOffset = currentPosition - firstCluster; - Debug.Assert(textSourceOffset >= 0); + //Find the next possible position that contains the endIndex + var nearestCharacterHit = currentRun.GlyphRun.FindNearestCharacterHit(endIndex, out _); - var clusterOffset = 0; + int endHitIndex; - //Cluster boundary correction - if (runOffset > 0) + if (nearestCharacterHit.FirstCharacterIndex < endIndex) { - var characterHit = currentRun.GlyphRun.FindNearestCharacterHit(startIndex, out _); - - var clusterStart = characterHit.FirstCharacterIndex; - var clusterEnd = clusterStart + characterHit.TrailingLength; - - //Test against left and right edge - if (clusterStart < startIndex && clusterEnd > startIndex) - { - //Remember the cluster correction offset - clusterOffset = startIndex - clusterStart; - - //Move to the start of the cluster - startIndex -= clusterOffset; - } + //The hit is inside or at the trailing edge + endHitIndex = nearestCharacterHit.FirstCharacterIndex + nearestCharacterHit.TrailingLength; } - - //Find the visual start and end position we want to hit test against - var startOffset = currentRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex)); - var endOffset = currentRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(endIndex)); - - //We need the distance from right to left and GetDistanceFromCharacterHit returs a distance from left to right so we need to adjust the offsets - startX -= currentRun.Size.Width - startOffset; - endX -= currentRun.Size.Width - endOffset; - - // Preserve non-zero width for zero-advance ranges - if (startOffset == endOffset && startIndex != endIndex) + else { - //We need to make sure a zero width text line is hit test at the end so we add some delta - endOffset += MathUtilities.DoubleEpsilon; + //The hit is at the leading edge + endHitIndex = nearestCharacterHit.FirstCharacterIndex; } - //Hit test against visual positions - var startHit = currentRun.GlyphRun.GetCharacterHitFromDistance(startOffset, out _); - var endHit = currentRun.GlyphRun.GetCharacterHitFromDistance(endOffset, out _); - - var startHitIndex = startHit.FirstCharacterIndex + startHit.TrailingLength; - var endHitIndex = endHit.FirstCharacterIndex + endHit.TrailingLength; - - var characterLength = Math.Max(0, Math.Abs(startHitIndex - endHitIndex) - clusterOffset); + var coveredLength = Math.Max(0, Math.Abs(startHitIndex - endHitIndex) - clusterOffset); // Normalize bounds if (endX < startX) @@ -1212,7 +1150,7 @@ namespace Avalonia.Media.TextFormatting //We need to adjust the local position to the text source var textSourceIndex = textSourceOffset + startHitIndex + clusterOffset; - return new TextRunBounds(new Rect(startX, 0, runWidth, Height), textSourceIndex, characterLength, currentRun); + return new TextRunBounds(new Rect(startX, 0, runWidth, Height), textSourceIndex, coveredLength, currentRun); } public override void Dispose() diff --git a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs index 7985c631fc..d5f4b47f4d 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs +++ b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs @@ -2011,6 +2011,42 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting } } + [Fact] + public void Should_Get_TextBounds_With_Glue() + { + using (Start()) + { + var typeface = Typeface.Default; + + var defaultProperties = new GenericTextRunProperties(typeface); + var text = "a\u202C\u202C\u202C\u202Cb"; + var shaperOption = new TextShaperOptions(typeface.GlyphTypeface); + + var textSource = new SingleBufferTextSource(text, defaultProperties); + + var formatter = new TextFormatterImpl(); + + var textLine = + formatter.FormatLine(textSource, 0, double.PositiveInfinity, + new GenericTextParagraphProperties(defaultProperties)); + + Assert.NotNull(textLine); + + var textBounds = textLine.GetTextBounds(1, 1); + + Assert.NotEmpty(textBounds); + + var firstTextBounds = textBounds[0]; + + Assert.NotEmpty(firstTextBounds.TextRunBounds); + + var firstRunBounds = firstTextBounds.TextRunBounds[0]; + + Assert.Equal(1, firstRunBounds.TextSourceCharacterIndex); + Assert.Equal(1, firstRunBounds.Length); + } + } + [Fact] public void Should_Get_TextBounds_Tamil() {