Skip to content

Commit 9b3d1e1

Browse files
GillibaldMrJul
andcommitted
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 <[email protected]>
1 parent 64f61e3 commit 9b3d1e1

File tree

5 files changed

+182
-63
lines changed

5 files changed

+182
-63
lines changed

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

Lines changed: 78 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using System.Buffers;
33
using System.Collections;
44
using System.Collections.Generic;
5+
using System.Linq;
56
using System.Runtime.CompilerServices;
67
using Avalonia.Utilities;
78

@@ -89,90 +90,110 @@ public GlyphInfo this[int index]
8990

9091
public IEnumerator<GlyphInfo> GetEnumerator() => _glyphInfos.GetEnumerator();
9192

93+
internal void ResetBidiLevel(sbyte paragraphEmbeddingLevel) => BidiLevel = paragraphEmbeddingLevel;
94+
95+
int IReadOnlyCollection<GlyphInfo>.Count => _glyphInfos.Length;
96+
97+
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
98+
9299
/// <summary>
93-
/// Finds a glyph index for given character index.
100+
/// Splits the <see cref="TextRun"/> at specified length.
94101
/// </summary>
95-
/// <param name="characterIndex">The character index.</param>
96-
/// <returns>
97-
/// The glyph index.
98-
/// </returns>
99-
private int FindGlyphIndex(int characterIndex)
102+
/// <param name="textLength">The text length.</param>
103+
/// <returns>The split result.</returns>
104+
public SplitResult<ShapedBuffer> Split(int textLength)
100105
{
101-
if (characterIndex < _glyphInfos[0].GlyphCluster)
106+
// make sure we do not overshoot
107+
textLength = Math.Min(Text.Length, textLength);
108+
109+
if (textLength <= 0)
102110
{
103-
return 0;
111+
var emptyBuffer = new ShapedBuffer(
112+
Text.Slice(0, 0), _glyphInfos.Slice(_glyphInfos.Start, 0),
113+
GlyphTypeface, FontRenderingEmSize, BidiLevel);
114+
115+
return new SplitResult<ShapedBuffer>(emptyBuffer, this);
104116
}
105117

106-
if (characterIndex > _glyphInfos[_glyphInfos.Length - 1].GlyphCluster)
118+
// nothing to split
119+
if (textLength == Text.Length)
107120
{
108-
return _glyphInfos.Length - 1;
121+
return new SplitResult<ShapedBuffer>(this, null);
109122
}
110123

111-
var comparer = GlyphInfo.ClusterAscendingComparer;
112-
124+
var sliceStart = _glyphInfos.Start;
113125
var glyphInfos = _glyphInfos.Span;
126+
var glyphInfosLength = _glyphInfos.Length;
114127

115-
var searchValue = new GlyphInfo(default, characterIndex, default);
128+
// the first glyph’s cluster is our “zero” for this sub‐buffer.
129+
// we want an absolute target cluster = baseCluster + textLength
130+
var baseCluster = glyphInfos[0].GlyphCluster;
131+
var targetCluster = baseCluster + textLength;
116132

117-
var start = glyphInfos.BinarySearch(searchValue, comparer);
133+
// binary‐search for a dummy with cluster == targetCluster
134+
var searchValue = new GlyphInfo(0, targetCluster, 0, default);
135+
var foundIndex = glyphInfos.BinarySearch(searchValue, GlyphInfo.ClusterAscendingComparer);
118136

119-
if (start < 0)
120-
{
121-
while (characterIndex > 0 && start < 0)
122-
{
123-
characterIndex--;
137+
int splitGlyphIndex; // how many glyph‐slots go into "leading"
138+
int splitCharCount; // how many chars go into "leading" Text
124139

125-
searchValue = new GlyphInfo(default, characterIndex, default);
126-
127-
start = glyphInfos.BinarySearch(searchValue, comparer);
128-
}
140+
if (foundIndex >= 0)
141+
{
142+
// found a glyph info whose cluster == targetCluster
143+
// back up to the start of the cluster
144+
var i = foundIndex;
129145

130-
if (start < 0)
146+
while (i > 0 && glyphInfos[i - 1].GlyphCluster == targetCluster)
131147
{
132-
return -1;
148+
i--;
133149
}
150+
151+
splitGlyphIndex = i;
152+
splitCharCount = targetCluster - baseCluster;
134153
}
135-
136-
while (start > 0 && glyphInfos[start - 1].GlyphCluster == glyphInfos[start].GlyphCluster)
154+
else
137155
{
138-
start--;
139-
}
140-
141-
return start;
142-
}
156+
// no exact match need to invert so ~foundIndex is the insertion point
157+
// the first cluster > targetCluster
158+
var invertedIndex = ~foundIndex;
143159

144-
/// <summary>
145-
/// Splits the <see cref="TextRun"/> at specified length.
146-
/// </summary>
147-
/// <param name="length">The length.</param>
148-
/// <returns>The split result.</returns>
149-
internal SplitResult<ShapedBuffer> Split(int length)
150-
{
151-
if (Text.Length == length)
152-
{
153-
return new SplitResult<ShapedBuffer>(this, null);
160+
if (invertedIndex >= glyphInfosLength)
161+
{
162+
// happens only if targetCluster ≥ lastCluster
163+
// put everything into leading
164+
splitGlyphIndex = glyphInfosLength;
165+
splitCharCount = Text.Length;
166+
}
167+
else
168+
{
169+
// snap to the start of that next cluster
170+
splitGlyphIndex = invertedIndex;
171+
var nextCluster = glyphInfos[invertedIndex].GlyphCluster;
172+
splitCharCount = nextCluster - baseCluster;
173+
}
154174
}
155175

156-
var firstCluster = _glyphInfos[0].GlyphCluster;
157-
var lastCluster = _glyphInfos[_glyphInfos.Length - 1].GlyphCluster;
176+
var firstGlyphs = _glyphInfos.Slice(sliceStart, splitGlyphIndex);
177+
var secondGlyphs = _glyphInfos.Slice(sliceStart + splitGlyphIndex, glyphInfosLength - splitGlyphIndex);
158178

159-
var start = firstCluster < lastCluster ? firstCluster : lastCluster;
179+
var firstText = Text.Slice(0, splitCharCount);
180+
var secondText = Text.Slice(splitCharCount);
160181

161-
var glyphCount = FindGlyphIndex(start + length);
182+
var leading = new ShapedBuffer(
183+
firstText, firstGlyphs,
184+
GlyphTypeface, FontRenderingEmSize, BidiLevel);
162185

163-
var first = new ShapedBuffer(Text.Slice(0, length),
164-
_glyphInfos.Take(glyphCount), GlyphTypeface, FontRenderingEmSize, BidiLevel);
186+
// this happens if we try to find a position inside a cluster and we moved to the end
187+
if(secondText.Length == 0)
188+
{
189+
return new SplitResult<ShapedBuffer>(leading, null);
190+
}
165191

166-
var second = new ShapedBuffer(Text.Slice(length),
167-
_glyphInfos.Skip(glyphCount), GlyphTypeface, FontRenderingEmSize, BidiLevel);
192+
var trailing = new ShapedBuffer(
193+
secondText, secondGlyphs,
194+
GlyphTypeface, FontRenderingEmSize, BidiLevel);
168195

169-
return new SplitResult<ShapedBuffer>(first, second);
196+
return new SplitResult<ShapedBuffer>(leading, trailing);
170197
}
171-
172-
internal void ResetBidiLevel(sbyte paragraphEmbeddingLevel) => BidiLevel = paragraphEmbeddingLevel;
173-
174-
int IReadOnlyCollection<GlyphInfo>.Count => _glyphInfos.Length;
175-
176-
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
177198
}
178199
}

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -182,9 +182,9 @@ internal SplitResult<ShapedTextRun> Split(int length)
182182

183183
#if DEBUG
184184

185-
if (first.Length != length)
185+
if (first.Length < length)
186186
{
187-
throw new InvalidOperationException("Split length mismatch.");
187+
throw new InvalidOperationException("Split length too small.");
188188
}
189189
#endif
190190
var second = new ShapedTextRun(splitBuffer.Second!, Properties);

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

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -371,15 +371,31 @@ private static void ShapeTogether(IReadOnlyList<UnshapedTextRun> textRuns, ReadO
371371
{
372372
var shapedBuffer = textShaper.ShapeText(text, options);
373373

374+
var previousLength = 0;
375+
374376
for (var i = 0; i < textRuns.Count; i++)
375377
{
376378
var currentRun = textRuns[i];
377379

378-
var splitResult = shapedBuffer.Split(currentRun.Length);
380+
var splitResult = shapedBuffer.Split(previousLength + currentRun.Length);
379381

380-
results.Add(new ShapedTextRun(splitResult.First, currentRun.Properties));
382+
if(splitResult.First.Length == 0)
383+
{
384+
previousLength += currentRun.Length;
385+
}
386+
else
387+
{
388+
previousLength = 0;
381389

382-
shapedBuffer = splitResult.Second!;
390+
results.Add(new ShapedTextRun(splitResult.First, currentRun.Properties));
391+
}
392+
393+
if(splitResult.Second is null)
394+
{
395+
return;
396+
}
397+
398+
shapedBuffer = splitResult.Second;
383399
}
384400
}
385401

@@ -921,7 +937,20 @@ private static TextLineImpl PerformTextWrapping(List<TextRun> textRuns, bool can
921937
ResetTrailingWhitespaceBidiLevels(preSplitRuns, paragraphProperties.FlowDirection, objectPool);
922938
}
923939

924-
var textLine = new TextLineImpl(preSplitRuns.ToArray(), firstTextSourceIndex, measuredLength,
940+
var remainingTextRuns = new TextRun[preSplitRuns.Count];
941+
//Measured lenght might have changed after a possible line break was found so we need to calculate the real length
942+
var splitLength = 0;
943+
944+
for(var i = 0; i < preSplitRuns.Count; i++)
945+
{
946+
var currentRun = preSplitRuns[i];
947+
948+
remainingTextRuns[i] = currentRun;
949+
950+
splitLength += currentRun.Length;
951+
}
952+
953+
var textLine = new TextLineImpl(remainingTextRuns, firstTextSourceIndex, splitLength,
925954
paragraphWidth, paragraphProperties, resolvedFlowDirection,
926955
textLineBreak);
927956

Binary file not shown.

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

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using System.Globalization;
33
using Avalonia.Media;
44
using Avalonia.Media.TextFormatting;
5+
using Avalonia.Media.TextFormatting.Unicode;
56
using Avalonia.UnitTests;
67
using Xunit;
78

@@ -40,6 +41,74 @@ public void Should_Apply_IncrementalTabWidth()
4041
}
4142
}
4243

44+
[Fact]
45+
public void Should_Not_Split_Cluster()
46+
{
47+
using (Start())
48+
{
49+
var typeface = new Typeface(FontFamily.Parse("resm:Avalonia.Skia.UnitTests.Fonts?assembly=Avalonia.Skia.UnitTests#Cascadia Code"));
50+
51+
var buffer = TextShaper.Current.ShapeText("a\"๊a", new TextShaperOptions(typeface.GlyphTypeface));
52+
53+
var splitResult = buffer.Split(1);
54+
55+
Assert.Equal(1, splitResult.First.Length);
56+
57+
buffer = splitResult.Second;
58+
59+
Assert.NotNull(buffer);
60+
61+
//\"๊
62+
splitResult = buffer.Split(1);
63+
64+
Assert.Equal(2, splitResult.First.Length);
65+
66+
buffer = splitResult.Second;
67+
68+
Assert.NotNull(buffer);
69+
}
70+
}
71+
72+
[Fact]
73+
public void Should_Split_RightToLeft()
74+
{
75+
var text = "أَبْجَدِيَّة عَرَبِيَّة";
76+
77+
using (Start())
78+
{
79+
var codePoint = Codepoint.ReadAt(text, 0, out _);
80+
81+
Assert.True(FontManager.Current.TryMatchCharacter(codePoint, FontStyle.Normal, FontWeight.Normal, FontStretch.Normal, null, null, out var typeface));
82+
83+
var buffer = TextShaper.Current.ShapeText(text, new TextShaperOptions(typeface.GlyphTypeface));
84+
85+
var splitResult = buffer.Split(6);
86+
87+
var first = splitResult.First;
88+
89+
Assert.Equal(6, first.Length);
90+
}
91+
}
92+
93+
[Fact]
94+
public void Should_Split_Zero_Length()
95+
{
96+
var text = "ABC";
97+
98+
using (Start())
99+
{
100+
var buffer = TextShaper.Current.ShapeText(text, new TextShaperOptions(Typeface.Default.GlyphTypeface));
101+
102+
var splitResult = buffer.Split(0);
103+
104+
Assert.Equal(0, splitResult.First.Length);
105+
106+
Assert.NotNull(splitResult.Second);
107+
108+
Assert.Equal(text.Length, splitResult.Second.Length);
109+
}
110+
}
111+
43112
private static IDisposable Start()
44113
{
45114
var disposable = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface

0 commit comments

Comments
 (0)