Browse Source

Properly handle in cluster ShapedBuffer split (#19090)

* Properly handle in cluster ShapedBuffer split

* Allow ShapedBuffer split for empty text length

---------

Co-authored-by: Julien Lebosquain <julien@lebosquain.net>
release/11.3.2
Benedikt Stebner 1 year ago
committed by Julien Lebosquain
parent
commit
9b3d1e1966
  1. 135
      src/Avalonia.Base/Media/TextFormatting/ShapedBuffer.cs
  2. 4
      src/Avalonia.Base/Media/TextFormatting/ShapedTextRun.cs
  3. 37
      src/Avalonia.Base/Media/TextFormatting/TextFormatterImpl.cs
  4. BIN
      tests/Avalonia.Skia.UnitTests/Fonts/CascadiaCode.ttf
  5. 69
      tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextShaperTests.cs

135
src/Avalonia.Base/Media/TextFormatting/ShapedBuffer.cs

@ -2,6 +2,7 @@
using System.Buffers; using System.Buffers;
using System.Collections; using System.Collections;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using Avalonia.Utilities; using Avalonia.Utilities;
@ -89,90 +90,110 @@ namespace Avalonia.Media.TextFormatting
public IEnumerator<GlyphInfo> GetEnumerator() => _glyphInfos.GetEnumerator(); public IEnumerator<GlyphInfo> GetEnumerator() => _glyphInfos.GetEnumerator();
internal void ResetBidiLevel(sbyte paragraphEmbeddingLevel) => BidiLevel = paragraphEmbeddingLevel;
int IReadOnlyCollection<GlyphInfo>.Count => _glyphInfos.Length;
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
/// <summary> /// <summary>
/// Finds a glyph index for given character index. /// Splits the <see cref="TextRun"/> at specified length.
/// </summary> /// </summary>
/// <param name="characterIndex">The character index.</param> /// <param name="textLength">The text length.</param>
/// <returns> /// <returns>The split result.</returns>
/// The glyph index. public SplitResult<ShapedBuffer> Split(int textLength)
/// </returns>
private int FindGlyphIndex(int characterIndex)
{ {
if (characterIndex < _glyphInfos[0].GlyphCluster) // make sure we do not overshoot
textLength = Math.Min(Text.Length, textLength);
if (textLength <= 0)
{ {
return 0; var emptyBuffer = new ShapedBuffer(
Text.Slice(0, 0), _glyphInfos.Slice(_glyphInfos.Start, 0),
GlyphTypeface, FontRenderingEmSize, BidiLevel);
return new SplitResult<ShapedBuffer>(emptyBuffer, this);
} }
if (characterIndex > _glyphInfos[_glyphInfos.Length - 1].GlyphCluster) // nothing to split
if (textLength == Text.Length)
{ {
return _glyphInfos.Length - 1; return new SplitResult<ShapedBuffer>(this, null);
} }
var comparer = GlyphInfo.ClusterAscendingComparer; var sliceStart = _glyphInfos.Start;
var glyphInfos = _glyphInfos.Span; var glyphInfos = _glyphInfos.Span;
var glyphInfosLength = _glyphInfos.Length;
var searchValue = new GlyphInfo(default, characterIndex, default); // the first glyph’s cluster is our “zero” for this sub‐buffer.
// we want an absolute target cluster = baseCluster + textLength
var baseCluster = glyphInfos[0].GlyphCluster;
var targetCluster = baseCluster + textLength;
var start = glyphInfos.BinarySearch(searchValue, comparer); // binary‐search for a dummy with cluster == targetCluster
var searchValue = new GlyphInfo(0, targetCluster, 0, default);
var foundIndex = glyphInfos.BinarySearch(searchValue, GlyphInfo.ClusterAscendingComparer);
if (start < 0) int splitGlyphIndex; // how many glyph‐slots go into "leading"
{ int splitCharCount; // how many chars go into "leading" Text
while (characterIndex > 0 && start < 0)
{
characterIndex--;
searchValue = new GlyphInfo(default, characterIndex, default); if (foundIndex >= 0)
{
start = glyphInfos.BinarySearch(searchValue, comparer); // found a glyph info whose cluster == targetCluster
} // back up to the start of the cluster
var i = foundIndex;
if (start < 0) while (i > 0 && glyphInfos[i - 1].GlyphCluster == targetCluster)
{ {
return -1; i--;
} }
splitGlyphIndex = i;
splitCharCount = targetCluster - baseCluster;
} }
else
while (start > 0 && glyphInfos[start - 1].GlyphCluster == glyphInfos[start].GlyphCluster)
{ {
start--; // no exact match need to invert so ~foundIndex is the insertion point
} // the first cluster > targetCluster
var invertedIndex = ~foundIndex;
return start;
}
/// <summary> if (invertedIndex >= glyphInfosLength)
/// Splits the <see cref="TextRun"/> at specified length. {
/// </summary> // happens only if targetCluster ≥ lastCluster
/// <param name="length">The length.</param> // put everything into leading
/// <returns>The split result.</returns> splitGlyphIndex = glyphInfosLength;
internal SplitResult<ShapedBuffer> Split(int length) splitCharCount = Text.Length;
{ }
if (Text.Length == length) else
{ {
return new SplitResult<ShapedBuffer>(this, null); // snap to the start of that next cluster
splitGlyphIndex = invertedIndex;
var nextCluster = glyphInfos[invertedIndex].GlyphCluster;
splitCharCount = nextCluster - baseCluster;
}
} }
var firstCluster = _glyphInfos[0].GlyphCluster; var firstGlyphs = _glyphInfos.Slice(sliceStart, splitGlyphIndex);
var lastCluster = _glyphInfos[_glyphInfos.Length - 1].GlyphCluster; var secondGlyphs = _glyphInfos.Slice(sliceStart + splitGlyphIndex, glyphInfosLength - splitGlyphIndex);
var start = firstCluster < lastCluster ? firstCluster : lastCluster; var firstText = Text.Slice(0, splitCharCount);
var secondText = Text.Slice(splitCharCount);
var glyphCount = FindGlyphIndex(start + length); var leading = new ShapedBuffer(
firstText, firstGlyphs,
GlyphTypeface, FontRenderingEmSize, BidiLevel);
var first = new ShapedBuffer(Text.Slice(0, length), // this happens if we try to find a position inside a cluster and we moved to the end
_glyphInfos.Take(glyphCount), GlyphTypeface, FontRenderingEmSize, BidiLevel); if(secondText.Length == 0)
{
return new SplitResult<ShapedBuffer>(leading, null);
}
var second = new ShapedBuffer(Text.Slice(length), var trailing = new ShapedBuffer(
_glyphInfos.Skip(glyphCount), GlyphTypeface, FontRenderingEmSize, BidiLevel); secondText, secondGlyphs,
GlyphTypeface, FontRenderingEmSize, BidiLevel);
return new SplitResult<ShapedBuffer>(first, second); return new SplitResult<ShapedBuffer>(leading, trailing);
} }
internal void ResetBidiLevel(sbyte paragraphEmbeddingLevel) => BidiLevel = paragraphEmbeddingLevel;
int IReadOnlyCollection<GlyphInfo>.Count => _glyphInfos.Length;
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
} }
} }

4
src/Avalonia.Base/Media/TextFormatting/ShapedTextRun.cs

@ -182,9 +182,9 @@ namespace Avalonia.Media.TextFormatting
#if DEBUG #if DEBUG
if (first.Length != length) if (first.Length < length)
{ {
throw new InvalidOperationException("Split length mismatch."); throw new InvalidOperationException("Split length too small.");
} }
#endif #endif
var second = new ShapedTextRun(splitBuffer.Second!, Properties); var second = new ShapedTextRun(splitBuffer.Second!, Properties);

37
src/Avalonia.Base/Media/TextFormatting/TextFormatterImpl.cs

@ -371,15 +371,31 @@ namespace Avalonia.Media.TextFormatting
{ {
var shapedBuffer = textShaper.ShapeText(text, options); var shapedBuffer = textShaper.ShapeText(text, options);
var previousLength = 0;
for (var i = 0; i < textRuns.Count; i++) for (var i = 0; i < textRuns.Count; i++)
{ {
var currentRun = textRuns[i]; var currentRun = textRuns[i];
var splitResult = shapedBuffer.Split(currentRun.Length); var splitResult = shapedBuffer.Split(previousLength + currentRun.Length);
results.Add(new ShapedTextRun(splitResult.First, currentRun.Properties)); if(splitResult.First.Length == 0)
{
previousLength += currentRun.Length;
}
else
{
previousLength = 0;
shapedBuffer = splitResult.Second!; results.Add(new ShapedTextRun(splitResult.First, currentRun.Properties));
}
if(splitResult.Second is null)
{
return;
}
shapedBuffer = splitResult.Second;
} }
} }
@ -921,7 +937,20 @@ namespace Avalonia.Media.TextFormatting
ResetTrailingWhitespaceBidiLevels(preSplitRuns, paragraphProperties.FlowDirection, objectPool); ResetTrailingWhitespaceBidiLevels(preSplitRuns, paragraphProperties.FlowDirection, objectPool);
} }
var textLine = new TextLineImpl(preSplitRuns.ToArray(), firstTextSourceIndex, measuredLength, var remainingTextRuns = new TextRun[preSplitRuns.Count];
//Measured lenght might have changed after a possible line break was found so we need to calculate the real length
var splitLength = 0;
for(var i = 0; i < preSplitRuns.Count; i++)
{
var currentRun = preSplitRuns[i];
remainingTextRuns[i] = currentRun;
splitLength += currentRun.Length;
}
var textLine = new TextLineImpl(remainingTextRuns, firstTextSourceIndex, splitLength,
paragraphWidth, paragraphProperties, resolvedFlowDirection, paragraphWidth, paragraphProperties, resolvedFlowDirection,
textLineBreak); textLineBreak);

BIN
tests/Avalonia.Skia.UnitTests/Fonts/CascadiaCode.ttf

Binary file not shown.

69
tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextShaperTests.cs

@ -2,6 +2,7 @@
using System.Globalization; using System.Globalization;
using Avalonia.Media; using Avalonia.Media;
using Avalonia.Media.TextFormatting; using Avalonia.Media.TextFormatting;
using Avalonia.Media.TextFormatting.Unicode;
using Avalonia.UnitTests; using Avalonia.UnitTests;
using Xunit; using Xunit;
@ -40,6 +41,74 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
} }
} }
[Fact]
public void Should_Not_Split_Cluster()
{
using (Start())
{
var typeface = new Typeface(FontFamily.Parse("resm:Avalonia.Skia.UnitTests.Fonts?assembly=Avalonia.Skia.UnitTests#Cascadia Code"));
var buffer = TextShaper.Current.ShapeText("a\"๊a", new TextShaperOptions(typeface.GlyphTypeface));
var splitResult = buffer.Split(1);
Assert.Equal(1, splitResult.First.Length);
buffer = splitResult.Second;
Assert.NotNull(buffer);
//\"๊
splitResult = buffer.Split(1);
Assert.Equal(2, splitResult.First.Length);
buffer = splitResult.Second;
Assert.NotNull(buffer);
}
}
[Fact]
public void Should_Split_RightToLeft()
{
var text = "أَبْجَدِيَّة عَرَبِيَّة";
using (Start())
{
var codePoint = Codepoint.ReadAt(text, 0, out _);
Assert.True(FontManager.Current.TryMatchCharacter(codePoint, FontStyle.Normal, FontWeight.Normal, FontStretch.Normal, null, null, out var typeface));
var buffer = TextShaper.Current.ShapeText(text, new TextShaperOptions(typeface.GlyphTypeface));
var splitResult = buffer.Split(6);
var first = splitResult.First;
Assert.Equal(6, first.Length);
}
}
[Fact]
public void Should_Split_Zero_Length()
{
var text = "ABC";
using (Start())
{
var buffer = TextShaper.Current.ShapeText(text, new TextShaperOptions(Typeface.Default.GlyphTypeface));
var splitResult = buffer.Split(0);
Assert.Equal(0, splitResult.First.Length);
Assert.NotNull(splitResult.Second);
Assert.Equal(text.Length, splitResult.Second.Length);
}
}
private static IDisposable Start() private static IDisposable Start()
{ {
var disposable = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface var disposable = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface

Loading…
Cancel
Save