Răsfoiți Sursa

Multiple TextLine.GetTextRunBounds fixes (#18749)

* Adjust TextLineImpl.GetTextRunBounds so it properly handles substitutions
Adjust TextLineImpl.GetTextRunBounds so it properly reports out of text range bounds
Adjust TextLineImpl.GetTextRunBounds so it properly reports text source run indices

* Remove redundant comments

* Add requested changes
Benedikt Stebner 5 luni în urmă
părinte
comite
aaf6fe9cb6

+ 180 - 118
src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs

@@ -602,101 +602,40 @@ namespace Avalonia.Media.TextFormatting
 
         public override IReadOnlyList<TextBounds> GetTextBounds(int firstTextSourceIndex, int textLength)
         {
-            if (_indexedTextRuns is null || _indexedTextRuns.Count == 0)
+            if(textLength == 0)
             {
-                return Array.Empty<TextBounds>();
+                throw new ArgumentOutOfRangeException(nameof(textLength), textLength, $"{nameof(textLength)} ('0') must be a non-zero value. ");
             }
 
-            var result = new List<TextBounds>();
+            if (_indexedTextRuns is null || _indexedTextRuns.Count == 0)
+            {
+                return [];
+            }
 
             var currentPosition = FirstTextSourceIndex;
             var remainingLength = textLength;
 
-            TextBounds? lastBounds = null;
-
-            static FlowDirection GetDirection(TextRun textRun, FlowDirection currentDirection)
+            //We can return early if the requested text range is before the line's text range.
+            if (firstTextSourceIndex + textLength < FirstTextSourceIndex)
             {
-                if (textRun is ShapedTextRun shapedTextRun)
-                {
-                    return shapedTextRun.ShapedBuffer.IsLeftToRight ?
-                        FlowDirection.LeftToRight :
-                        FlowDirection.RightToLeft;
-                }
+                var indexedTextRun = _indexedTextRuns[0];
+                var currentDirection = GetDirection(indexedTextRun.TextRun, _resolvedFlowDirection);
 
-                return currentDirection;
+                return [new TextBounds(new Rect(0,0,0, Height), currentDirection, [])];
             }
 
-            IndexedTextRun FindIndexedRun()
+            //We can return early if the requested text range is after the line's text range.
+            if (firstTextSourceIndex >= FirstTextSourceIndex + Length)
             {
-                var i = 0;
+                var indexedTextRun = _indexedTextRuns[_indexedTextRuns.Count - 1];
+                var currentDirection = GetDirection(indexedTextRun.TextRun, _resolvedFlowDirection);
 
-                IndexedTextRun currentIndexedRun = _indexedTextRuns[i];
-
-                while (currentIndexedRun.TextSourceCharacterIndex != currentPosition)
-                {
-                    if (i + 1 == _indexedTextRuns.Count)
-                    {
-                        break;
-                    }
-
-                    i++;
-
-                    currentIndexedRun = _indexedTextRuns[i];
-                }
-
-                return currentIndexedRun;
-            }
-
-            double GetPreceedingDistance(int firstIndex)
-            {
-                var distance = 0.0;
-
-                for (var i = 0; i < firstIndex; i++)
-                {
-                    var currentRun = _textRuns[i];
-
-                    if (currentRun is DrawableTextRun drawableTextRun)
-                    {
-                        distance += drawableTextRun.Size.Width;
-                    }
-                }
-
-                return distance;
+                return [new TextBounds(new Rect(WidthIncludingTrailingWhitespace, 0, 0, Height), currentDirection, [])];
             }
 
-            bool TryMergeWithLastBounds(TextBounds currentBounds, TextBounds lastBounds)
-            {
-                if (currentBounds.FlowDirection != lastBounds.FlowDirection)
-                {
-                    return false;
-                }
-
-                if (currentBounds.Rectangle.Left == lastBounds.Rectangle.Right)
-                {
-                    foreach (var runBounds in currentBounds.TextRunBounds)
-                    {
-                        lastBounds.TextRunBounds.Add(runBounds);
-                    }
-
-                    lastBounds.Rectangle = lastBounds.Rectangle.Union(currentBounds.Rectangle);
-
-                    return true;
-                }
-
-                if (currentBounds.Rectangle.Right == lastBounds.Rectangle.Left)
-                {
-                    for (int i = 0; i < currentBounds.TextRunBounds.Count; i++)
-                    {
-                        lastBounds.TextRunBounds.Insert(i, currentBounds.TextRunBounds[i]);
-                    }
-
-                    lastBounds.Rectangle = lastBounds.Rectangle.Union(currentBounds.Rectangle);
-
-                    return true;
-                }
+            var result = new List<TextBounds>();
 
-                return false;
-            }
+            TextBounds? lastBounds = null;
 
             while (remainingLength > 0 && currentPosition < FirstTextSourceIndex + Length)
             {
@@ -733,8 +672,8 @@ namespace Avalonia.Media.TextFormatting
                     directionalWidth = currentDrawable.Size.Width;
                 }
 
+                TextBounds currentBounds;
                 int coveredLength;
-                TextBounds? currentBounds;
 
                 switch (currentDirection)
                 {
@@ -754,12 +693,6 @@ namespace Avalonia.Media.TextFormatting
                         }
                 }
 
-                if (coveredLength == 0)
-                {
-                    //This should never happen
-                    break;
-                }
-
                 if (lastBounds != null && TryMergeWithLastBounds(currentBounds, lastBounds))
                 {
                     currentBounds = lastBounds;
@@ -779,6 +712,90 @@ namespace Avalonia.Media.TextFormatting
             result.Sort(TextBoundsComparer);
 
             return result;
+
+            static FlowDirection GetDirection(TextRun? textRun, FlowDirection currentDirection)
+            {
+                if (textRun is ShapedTextRun shapedTextRun)
+                {
+                    return shapedTextRun.ShapedBuffer.IsLeftToRight ?
+                        FlowDirection.LeftToRight :
+                        FlowDirection.RightToLeft;
+                }
+
+                return currentDirection;
+            }
+
+            IndexedTextRun FindIndexedRun()
+            {
+                var i = 0;
+
+                var currentIndexedRun = _indexedTextRuns[i];
+
+                while (currentIndexedRun.TextSourceCharacterIndex != currentPosition)
+                {
+                    if (i + 1 == _indexedTextRuns.Count)
+                    {
+                        break;
+                    }
+
+                    i++;
+
+                    currentIndexedRun = _indexedTextRuns[i];
+                }
+
+                return currentIndexedRun;
+            }
+
+            double GetPreceedingDistance(int firstIndex)
+            {
+                var distance = 0.0;
+
+                for (var i = 0; i < firstIndex; i++)
+                {
+                    var currentRun = _textRuns[i];
+
+                    if (currentRun is DrawableTextRun drawableTextRun)
+                    {
+                        distance += drawableTextRun.Size.Width;
+                    }
+                }
+
+                return distance;
+            }
+
+            bool TryMergeWithLastBounds(TextBounds currentBounds, TextBounds lastBounds)
+            {
+                if (currentBounds.FlowDirection != lastBounds.FlowDirection)
+                {
+                    return false;
+                }
+
+                if (currentBounds.Rectangle.Left == lastBounds.Rectangle.Right)
+                {
+                    foreach (var runBounds in currentBounds.TextRunBounds)
+                    {
+                        lastBounds.TextRunBounds.Add(runBounds);
+                    }
+
+                    lastBounds.Rectangle = lastBounds.Rectangle.Union(currentBounds.Rectangle);
+
+                    return true;
+                }
+
+                if (currentBounds.Rectangle.Right == lastBounds.Rectangle.Left)
+                {
+                    for (int i = 0; i < currentBounds.TextRunBounds.Count; i++)
+                    {
+                        lastBounds.TextRunBounds.Insert(i, currentBounds.TextRunBounds[i]);
+                    }
+
+                    lastBounds.Rectangle = lastBounds.Rectangle.Union(currentBounds.Rectangle);
+
+                    return true;
+                }
+
+                return false;
+            }
         }
 
         private CharacterHit GetPreviousCharacterHit(CharacterHit characterHit, bool useGraphemeBoundaries)
@@ -885,7 +902,10 @@ namespace Avalonia.Media.TextFormatting
                 {
                     var runBounds = GetRunBoundsRightToLeft(shapedTextRun, startX, firstTextSourceIndex, remainingLength, currentPosition, out var offset);
 
-                    textRunBounds.Insert(0, runBounds);
+                    if (runBounds.TextSourceCharacterIndex < FirstTextSourceIndex + Length)
+                    {
+                        textRunBounds.Insert(0, runBounds);
+                    }
 
                     if (offset > 0)
                     {
@@ -904,20 +924,25 @@ namespace Avalonia.Media.TextFormatting
                 }
                 else
                 {
-                    if (currentRun is DrawableTextRun drawableTextRun)
+                    if (currentPosition < FirstTextSourceIndex + Length)
                     {
-                        startX -= drawableTextRun.Size.Width;
+                        if (currentRun is DrawableTextRun drawableTextRun)
+                        {
+                            startX -= drawableTextRun.Size.Width;
 
-                        textRunBounds.Insert(0,
-                          new TextRunBounds(
-                              new Rect(startX, 0, drawableTextRun.Size.Width, Height), currentPosition, currentRun.Length, currentRun));
-                    }
-                    else
-                    {
-                        //Add potential TextEndOfParagraph
-                        textRunBounds.Add(
-                           new TextRunBounds(
-                               new Rect(endX, 0, 0, Height), currentPosition, currentRun.Length, currentRun));
+                            var runBounds = new TextRunBounds(
+                             new Rect(startX, 0, drawableTextRun.Size.Width, Height), currentPosition, currentRun.Length, currentRun);
+
+                            textRunBounds.Insert(0, runBounds);
+                        }
+                        else
+                        {
+                            //Add potential TextEndOfParagraph
+                            var runBounds = new TextRunBounds(
+                              new Rect(endX, 0, 0, Height), currentPosition, currentRun.Length, currentRun);
+
+                            textRunBounds.Add(runBounds);
+                        }
                     }
 
                     currentPosition += currentRun.Length;
@@ -946,7 +971,7 @@ namespace Avalonia.Media.TextFormatting
            int firstTextSourceIndex, int currentPosition, int remainingLength, out int coveredLength, out int newPosition)
         {
             coveredLength = 0;
-            var textRunBounds = new List<TextRunBounds>();
+            var textRunBounds = new List<TextRunBounds>(1);
             var endX = startX;
 
             for (int i = firstRunIndex; i <= lastRunIndex; i++)
@@ -957,7 +982,10 @@ namespace Avalonia.Media.TextFormatting
                 {
                     var runBounds = GetRunBoundsLeftToRight(shapedTextRun, endX, firstTextSourceIndex, remainingLength, currentPosition, out var offset);
 
-                    textRunBounds.Add(runBounds);
+                    if(runBounds.TextSourceCharacterIndex < FirstTextSourceIndex + Length)
+                    {
+                        textRunBounds.Add(runBounds);
+                    }
 
                     if (offset > 0)
                     {
@@ -976,20 +1004,26 @@ namespace Avalonia.Media.TextFormatting
                 }
                 else
                 {
-                    if (currentRun is DrawableTextRun drawableTextRun)
+                    if (currentPosition < FirstTextSourceIndex + Length)
                     {
-                        textRunBounds.Add(
-                            new TextRunBounds(
-                                new Rect(endX, 0, drawableTextRun.Size.Width, Height), currentPosition, currentRun.Length, currentRun));
+                        if (currentRun is DrawableTextRun drawableTextRun)
+                        {
+                            var runBounds = new TextRunBounds(
+                              new Rect(endX, 0, drawableTextRun.Size.Width, Height), currentPosition, currentRun.Length, currentRun);
 
-                        endX += drawableTextRun.Size.Width;
-                    }
-                    else
-                    {
-                        //Add potential TextEndOfParagraph
-                        textRunBounds.Add(
-                           new TextRunBounds(
-                               new Rect(endX, 0, 0, Height), currentPosition, currentRun.Length, currentRun));
+                            textRunBounds.Add(runBounds);
+
+
+                            endX += drawableTextRun.Size.Width;
+                        }
+                        else
+                        {
+                            //Add potential TextEndOfParagraph
+                            var runBounds = new TextRunBounds(
+                               new Rect(endX, 0, 0, Height), currentPosition, currentRun.Length, currentRun);
+
+                            textRunBounds.Add(runBounds);
+                        }
                     }
 
                     currentPosition += currentRun.Length;
@@ -1032,6 +1066,20 @@ namespace Avalonia.Media.TextFormatting
                 startIndex += offset;
             }
 
+            //Make sure we start the hit test at the start of the possible cluster.
+            var clusterStartHit = currentRun.GlyphRun.GetPreviousCaretCharacterHit(new CharacterHit(startIndex));
+            var clusterEndHit = currentRun.GlyphRun.GetNextCaretCharacterHit(clusterStartHit);
+
+            var clusterOffset = 0;
+
+            if (startIndex > clusterStartHit.FirstCharacterIndex && startIndex < clusterEndHit.FirstCharacterIndex + clusterEndHit.TrailingLength)
+            {
+                clusterOffset = clusterEndHit.FirstCharacterIndex + clusterEndHit.TrailingLength - startIndex;
+
+                //We need to move the startIndex to the start of the cluster.
+                startIndex -= clusterOffset;
+            }
+
             var startOffset = currentRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex));
             var endOffset = currentRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex + remainingLength));
 
@@ -1041,7 +1089,8 @@ namespace Avalonia.Media.TextFormatting
             var startHit = currentRun.GlyphRun.GetCharacterHitFromDistance(startOffset, out _);
             var endHit = currentRun.GlyphRun.GetCharacterHitFromDistance(endOffset, out _);
 
-            var characterLength = Math.Abs(startHit.FirstCharacterIndex + startHit.TrailingLength - endHit.FirstCharacterIndex - endHit.TrailingLength);
+            //Adjust characterLength by the cluster offset to only cover the remaining length of the cluster.
+            var characterLength = Math.Abs(startHit.FirstCharacterIndex + startHit.TrailingLength - endHit.FirstCharacterIndex - endHit.TrailingLength) - clusterOffset;
 
             if (characterLength == 0 && currentRun.Text.Length > 0 && startIndex < currentRun.Text.Length)
             {
@@ -1075,7 +1124,7 @@ namespace Avalonia.Media.TextFormatting
 
             var runWidth = endX - startX;
 
-            var textSourceIndex = offset + startHit.FirstCharacterIndex;
+            var textSourceIndex = startIndex + Math.Max(0, currentPosition - firstCluster) + clusterOffset;
 
             return new TextRunBounds(new Rect(startX, 0, runWidth, Height), textSourceIndex, characterLength, currentRun);
         }
@@ -1101,7 +1150,6 @@ namespace Avalonia.Media.TextFormatting
             }
 
             var endOffset = currentRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex));
-
             var startOffset = currentRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex + remainingLength));
 
             startX -= currentRun.Size.Width - startOffset;
@@ -1110,7 +1158,21 @@ namespace Avalonia.Media.TextFormatting
             var endHit = currentRun.GlyphRun.GetCharacterHitFromDistance(endOffset, out _);
             var startHit = currentRun.GlyphRun.GetCharacterHitFromDistance(startOffset, out _);
 
-            var characterLength = Math.Abs(startHit.FirstCharacterIndex + startHit.TrailingLength - endHit.FirstCharacterIndex - endHit.TrailingLength);
+            //Make sure we start the hit test at the start of the possible cluster.
+            var clusterStartHit = currentRun.GlyphRun.GetNextCaretCharacterHit(new CharacterHit(startIndex));
+            var clusterEndHit = currentRun.GlyphRun.GetPreviousCaretCharacterHit(startHit);
+
+            var clusterOffset = 0;
+
+            if (startIndex > clusterStartHit.FirstCharacterIndex && startIndex < clusterEndHit.FirstCharacterIndex + clusterEndHit.TrailingLength)
+            {
+                clusterOffset = clusterEndHit.FirstCharacterIndex + clusterEndHit.TrailingLength - startIndex;
+
+                //We need to move the startIndex to the start of the cluster.
+                startIndex -= clusterOffset;
+            }
+
+            var characterLength = Math.Abs(startHit.FirstCharacterIndex + startHit.TrailingLength - endHit.FirstCharacterIndex - endHit.TrailingLength) - clusterOffset;
 
             if (characterLength == 0 && currentRun.Text.Length > 0 && startIndex < currentRun.Text.Length)
             {
@@ -1149,7 +1211,7 @@ namespace Avalonia.Media.TextFormatting
 
             var runWidth = endX - startX;
 
-            var textSourceIndex = offset + startHit.FirstCharacterIndex;
+            var textSourceIndex = startIndex + Math.Max(0, currentPosition - firstCluster) + clusterOffset;
 
             return new TextRunBounds(new Rect(startX, 0, runWidth, Height), textSourceIndex, characterLength, currentRun);
         }

+ 316 - 0
tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs

@@ -886,6 +886,322 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
             }
         }
 
+        [Fact]
+        public void Should_Throw_ArgumentOutOfRangeException_For_Zero_TextLength()
+        {
+            using (Start())
+            {
+                var typeface = Typeface.Default;
+
+                var defaultProperties = new GenericTextRunProperties(typeface);
+                var textSource = new CustomTextBufferTextSource(new TextCharacters("1234", defaultProperties));
+                var formatter = new TextFormatterImpl();
+
+                var textLine =
+                    formatter.FormatLine(textSource, 0, double.PositiveInfinity,
+                        new GenericTextParagraphProperties(defaultProperties));
+
+                Assert.NotNull(textLine);
+
+                Assert.Throws<ArgumentOutOfRangeException>(() => textLine.GetTextBounds(0, 0));
+            }     
+        }
+
+        [Fact]
+        public void Should_GetTextBounds_For_Negative_TextLength()
+        {
+            using (Start())
+            {
+                var typeface = Typeface.Default;
+
+                var defaultProperties = new GenericTextRunProperties(typeface);
+                var textSource = new CustomTextBufferTextSource(new TextCharacters("1234", defaultProperties));
+                var formatter = new TextFormatterImpl();
+
+                var textLine =
+                    formatter.FormatLine(textSource, 0, double.PositiveInfinity,
+                        new GenericTextParagraphProperties(defaultProperties));
+
+                Assert.NotNull(textLine);
+
+                var textBounds = textLine.GetTextBounds(0, -1);
+
+                Assert.NotNull(textBounds);
+
+                Assert.NotEmpty(textBounds);
+
+                var firstBounds = textBounds[0];
+
+                Assert.Empty(firstBounds.TextRunBounds);
+
+                Assert.Equal(0, firstBounds.Rectangle.Width);
+
+                Assert.Equal(0, firstBounds.Rectangle.Left);
+            }
+        }
+
+        [Fact]
+        public void Should_GetTextBounds_For_Exceeding_TextLength()
+        {
+            using (Start())
+            {
+                var typeface = Typeface.Default;
+
+                var defaultProperties = new GenericTextRunProperties(typeface);
+                var textSource = new CustomTextBufferTextSource(new TextCharacters("1234", defaultProperties));
+                var formatter = new TextFormatterImpl();
+
+                var textLine =
+                    formatter.FormatLine(textSource, 0, double.PositiveInfinity,
+                        new GenericTextParagraphProperties(defaultProperties));
+
+                Assert.NotNull(textLine);
+
+                var textBounds = textLine.GetTextBounds(10, 1);
+
+                Assert.NotNull(textBounds);
+
+                Assert.NotEmpty(textBounds);
+
+                var firstBounds = textBounds[0];
+
+                Assert.Empty(firstBounds.TextRunBounds);
+
+                Assert.Equal(0, firstBounds.Rectangle.Width);
+
+                Assert.Equal(textLine.WidthIncludingTrailingWhitespace, firstBounds.Rectangle.Right);
+            }
+        }
+
+        [Fact]
+        public void Should_GetTextBounds_For_Mixed_Hidden_Runs_With_Ligature()
+        {
+            using (Start())
+            {
+                var typeface = new Typeface(FontFamily.Parse("resm:Avalonia.Skia.UnitTests.Fonts?assembly=Avalonia.Skia.UnitTests#Manrope"));
+
+                var defaultProperties = new GenericTextRunProperties(typeface);
+                var textSource = new CustomTextBufferTextSource(
+                    new TextHidden(1), 
+                    new TextCharacters("Authenti", defaultProperties), 
+                    new TextHidden(1), 
+                    new TextHidden(1),
+                    new TextCharacters("ff", defaultProperties),
+                    new TextHidden(1), 
+                    new TextHidden(1));
+
+                var formatter = new TextFormatterImpl();
+
+                var textLine =
+                    formatter.FormatLine(textSource, 0, double.PositiveInfinity,
+                        new GenericTextParagraphProperties(defaultProperties));
+
+                Assert.NotNull(textLine);
+
+                var textBounds = textLine.GetTextBounds(12, 1);
+
+                Assert.NotEmpty(textBounds);
+
+                var firstBounds = textBounds[0];
+
+                Assert.NotNull(firstBounds.TextRunBounds);
+                Assert.NotEmpty(firstBounds.TextRunBounds);
+
+                var firstRun = firstBounds.TextRunBounds[0];
+
+                Assert.NotNull(firstRun);
+
+                Assert.Equal(12, firstRun.TextSourceCharacterIndex);
+            }
+        }
+
+        [Fact]
+        public void Should_GetTextBounds_For_Mixed_Hidden_Runs()
+        {
+            using (Start())
+            {
+                var typeface = new Typeface(FontFamily.Parse("resm:Avalonia.Skia.UnitTests.Fonts?assembly=Avalonia.Skia.UnitTests#Manrope"));
+
+                var defaultProperties = new GenericTextRunProperties(typeface);
+                var textSource = new CustomTextBufferTextSource(
+                    new TextHidden(1),
+                    new TextCharacters("Authenti", defaultProperties),
+                    new TextHidden(1),
+                    new TextHidden(1),
+                    new TextEndOfParagraph(1));
+
+                var formatter = new TextFormatterImpl();
+
+                var textLine =
+                    formatter.FormatLine(textSource, 0, double.PositiveInfinity,
+                        new GenericTextParagraphProperties(defaultProperties));
+
+                Assert.NotNull(textLine);
+
+                var textBounds = textLine.GetTextBounds(8, 1);
+
+                Assert.NotEmpty(textBounds);
+
+                var firstBounds = textBounds[0];
+
+                Assert.NotNull(firstBounds.TextRunBounds);
+                Assert.NotEmpty(firstBounds.TextRunBounds);
+
+                var firstRun = firstBounds.TextRunBounds[0];
+
+                Assert.NotNull(firstRun);
+
+                Assert.Equal(8, firstRun.TextSourceCharacterIndex);
+            }
+        }
+
+        [Win32Fact("Windows font")]
+        public void Should_GetTextBounds_Within_Cluster()
+        {
+            using (Start())
+            {
+                var typeface = new Typeface("Segoe UI Emoji");
+
+                var defaultProperties = new GenericTextRunProperties(typeface);
+                var textSource = new CustomTextBufferTextSource(new TextCharacters("🙈", defaultProperties));
+                var formatter = new TextFormatterImpl();
+
+                var textLine =
+                    formatter.FormatLine(textSource, 0, double.PositiveInfinity,
+                        new GenericTextParagraphProperties(defaultProperties));
+
+                Assert.NotNull(textLine);
+
+                var textBounds = textLine.GetTextBounds(0, 1);
+
+                Assert.NotEmpty(textBounds);
+
+                var runBounds = textBounds[0].TextRunBounds[0];
+
+                Assert.Equal(0, runBounds.TextSourceCharacterIndex);
+
+                textBounds = textLine.GetTextBounds(1, 1);
+
+                Assert.NotEmpty(textBounds);
+
+                runBounds = textBounds[0].TextRunBounds[0];
+
+                Assert.Equal(1, runBounds.TextSourceCharacterIndex);
+
+                textBounds = textLine.GetTextBounds(2, 1);
+
+                Assert.NotEmpty(textBounds);
+
+                Assert.NotNull(textBounds[0].TextRunBounds);
+
+                Assert.Empty(textBounds[0].TextRunBounds);
+            }
+        }
+
+        [Win32Fact("Windows font")]
+        public void Should_GetTextBounds_After_Last_Index()
+        {
+            using (Start())
+            {
+                var typeface = new Typeface("Segoe UI Emoji");
+
+                var defaultProperties = new GenericTextRunProperties(typeface);
+                var textSource = new CustomTextBufferTextSource(new TextCharacters("🙈", defaultProperties));
+                var formatter = new TextFormatterImpl();
+
+                var textLine =
+                    formatter.FormatLine(textSource, 0, double.PositiveInfinity,
+                        new GenericTextParagraphProperties(defaultProperties));
+
+                Assert.NotNull(textLine);
+
+                var textBounds = textLine.GetTextBounds(2, 1);
+
+                Assert.NotEmpty(textBounds);
+
+                var firstBounds = textBounds[0];
+
+                Assert.Equal(textLine.Width, firstBounds.Rectangle.Right);
+
+                Assert.NotNull(firstBounds.TextRunBounds);
+
+                Assert.Empty(firstBounds.TextRunBounds);
+            }
+        }
+
+        [Fact]
+        public void Should_Get_Run_Bounds()
+        {
+            using (Start())
+            {
+                var typeface = new Typeface(FontFamily.Parse("resm:Avalonia.Skia.UnitTests.Fonts?assembly=Avalonia.Skia.UnitTests#Manrope"));
+                var defaultProperties = new GenericTextRunProperties(typeface);
+                var textSource = new CustomTextBufferTextSource(
+                    new TextCharacters("He", defaultProperties), 
+                    new TextCharacters("Wo", defaultProperties),
+                    new TextCharacters("ff", 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);
+
+                textBounds = textLine.GetTextBounds(2, 1);
+
+                Assert.NotEmpty(textBounds);
+
+                textBounds = textLine.GetTextBounds(4, 1);
+
+                Assert.NotEmpty(textBounds);
+            }
+        }
+
+        private class TextHidden : TextRun
+        {
+            public TextHidden(int length)
+            {
+                Length = length;
+            }
+
+            public override int Length { get; }
+        }
+
+        private class CustomTextBufferTextSource : ITextSource
+        {
+            private IReadOnlyList<TextRun> _textRuns;
+
+            public CustomTextBufferTextSource(params TextRun[] textRuns)
+            {
+                _textRuns = textRuns;
+            }
+
+            public TextRun? GetTextRun(int textSourceIndex)
+            {
+                var pos = 0;
+
+                for(var i = 0; i < _textRuns.Count; i++)
+                {
+                    var currentRun = _textRuns[i];
+
+                    if(pos + currentRun.Length > textSourceIndex)
+                    {
+                        return currentRun;
+                    }
+
+                    pos += currentRun.Length;
+                }
+
+                return null;
+            }
+        }
+
         private class MixedTextBufferTextSource : ITextSource
         {
             public TextRun? GetTextRun(int textSourceIndex)