Browse Source

Ensure glyph clusters are slice relative (#21089)

Co-authored-by: Julien Lebosquain <julien@lebosquain.net>
pull/21144/head
Benedikt Stebner 6 months ago
committed by GitHub
parent
commit
9330519821
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 105
      src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs
  2. 5
      src/HarfBuzz/Avalonia.HarfBuzz/HarfBuzzTextShaper.cs
  3. 23
      tests/Avalonia.Base.UnitTests/Media/TextFormatting/HarfbuzzTextShaperTests.cs
  4. 37
      tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLayoutTests.cs
  5. 11
      tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs

105
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;
}
/// <summary>
/// Gets the text source character index for the run at the given visual position in <see cref="_textRuns"/>.
/// </summary>
/// <param name="visualRunIndex">The index of the run in the visual-order <see cref="_textRuns"/> array.</param>
/// <returns>The text source character index where the run starts.</returns>
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;
}
/// <summary>
/// Get the last consecutive visual run index that shares the same direction as the current direction.
/// </summary>

5
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[' '];

23
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,

37
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<ShapedTextRun>().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));

11
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<ShapedTextRun>()
.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)
{

Loading…
Cancel
Save