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)
{