Skip to content

Commit 5b624da

Browse files
committed
Expose contiguous glyph indices span on ShapedBuffer
Consumers that need a ReadOnlySpan<ushort> of glyph IDs (the upcoming GlyphTypeface.TryGetGlyphBounds batch path, SKFont.GetGlyphWidths, atlas lookups...) previously had to allocate a parallel ushort[] and project GlyphInfo.GlyphIndex into it. Carry that array on the ShapedBuffer instead: - ShapedBuffer rents a parallel ushort[] from ArrayPool alongside the existing GlyphInfo[] rental, exposed as `public ReadOnlySpan<ushort> GlyphIndices`. - The indexer setter syncs both, so HarfBuzz shaping and post-shape mutators (InterWordJustification rewrites the same GlyphIndex) keep the parallel view in lockstep with no caller change. - Split / WithBidiLevel slice the indices alongside the GlyphInfos at the same offsets — both share the parent's pooled arrays. - Dispose returns both rentals. Consumer wiring: - GlyphRunImpl: copies ShapedBuffer.GlyphIndices once via CopyTo when the source is a ShapedBuffer, dropping the per-glyph extract from the position loop. - TextFormatterImpl.CreateEmptyTextLine: switched to the pooled public ctor + indexer (was passing a heap-allocated GlyphInfo[] to the internal slice ctor, which now requires a parallel ushort slice). Tests cover indexer sync, overwrite, ascending split alignment, empty-leading split, and dispose clearing the view.
1 parent 64eb86c commit 5b624da

4 files changed

Lines changed: 220 additions & 24 deletions

File tree

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

Lines changed: 59 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,16 @@ public void Dispose()
5555
// Split children and WithBidiLevel aliases clone this ref so the array
5656
// survives until every observer has been disposed.
5757
private IRef<PooledArray<GlyphInfo>>? _glyphRef;
58+
59+
// Ref-counted handle to the pooled ushort[] that backs _glyphIndices. The
60+
// parallel glyph-id array exists so consumers (GlyphRunImpl, the new
61+
// TryGetGlyphBounds batch path, SKFont.GetGlyphWidths) can take a
62+
// ReadOnlySpan<ushort> over the run's glyph IDs without walking the
63+
// GlyphInfo struct array. Lifetime mirrors _glyphRef: cloned on Split /
64+
// WithBidiLevel, disposed in Dispose, null on caller-owned storage.
65+
private IRef<PooledArray<ushort>>? _glyphIndicesRef;
5866
private ArraySlice<GlyphInfo> _glyphInfos;
67+
private ArraySlice<ushort> _glyphIndices;
5968

6069
// Lazily-computed cluster-width cache. MeasureLength and metrics
6170
// queries both fold multi-glyph clusters and accumulate per-cluster
@@ -131,15 +140,18 @@ public ShapedBuffer(ReadOnlyMemory<char> text, int bufferLength, GlyphTypeface g
131140
Text = text;
132141
_glyphRef = RefCountable.Create(new PooledArray<GlyphInfo>(bufferLength));
133142
_glyphInfos = new ArraySlice<GlyphInfo>(_glyphRef.Item.Array, 0, bufferLength);
143+
_glyphIndicesRef = RefCountable.Create(new PooledArray<ushort>(bufferLength));
144+
_glyphIndices = new ArraySlice<ushort>(_glyphIndicesRef.Item.Array, 0, bufferLength);
134145
GlyphTypeface = glyphTypeface;
135146
FontRenderingEmSize = fontRenderingEmSize;
136147
BidiLevel = bidiLevel;
137148
}
138149

139-
internal ShapedBuffer(ReadOnlyMemory<char> text, ArraySlice<GlyphInfo> glyphInfos, GlyphTypeface glyphTypeface, double fontRenderingEmSize, sbyte bidiLevel)
150+
internal ShapedBuffer(ReadOnlyMemory<char> text, ArraySlice<GlyphInfo> glyphInfos, ArraySlice<ushort> glyphIndices, GlyphTypeface glyphTypeface, double fontRenderingEmSize, sbyte bidiLevel)
140151
{
141152
Text = text;
142153
_glyphInfos = glyphInfos;
154+
_glyphIndices = glyphIndices;
143155
GlyphTypeface = glyphTypeface;
144156
FontRenderingEmSize = fontRenderingEmSize;
145157
BidiLevel = bidiLevel;
@@ -152,19 +164,23 @@ internal ShapedBuffer(ReadOnlyMemory<char> text, ArraySlice<GlyphInfo> glyphInfo
152164
/// until every sibling has been disposed. When <paramref name="sourcePrefixRef"/>
153165
/// is null the alias starts without a cluster cache and will build its own lazily.
154166
/// </summary>
155-
private ShapedBuffer(ReadOnlyMemory<char> text, ArraySlice<GlyphInfo> glyphInfos,
167+
private ShapedBuffer(ReadOnlyMemory<char> text,
168+
ArraySlice<GlyphInfo> glyphInfos, ArraySlice<ushort> glyphIndices,
156169
GlyphTypeface glyphTypeface, double fontRenderingEmSize, sbyte bidiLevel,
157170
IRef<PooledArray<GlyphInfo>>? sourceGlyphRef,
171+
IRef<PooledArray<ushort>>? sourceGlyphIndicesRef,
158172
IRef<PooledArray<double>>? sourcePrefixRef,
159173
IRef<PooledArray<int>>? sourceStartsRef,
160174
int clusterStartIdx, int clusterCount, int sourceCacheGeneration)
161175
{
162176
Text = text;
163177
_glyphInfos = glyphInfos;
178+
_glyphIndices = glyphIndices;
164179
GlyphTypeface = glyphTypeface;
165180
FontRenderingEmSize = fontRenderingEmSize;
166181
BidiLevel = bidiLevel;
167182
_glyphRef = sourceGlyphRef?.Clone();
183+
_glyphIndicesRef = sourceGlyphIndicesRef?.Clone();
168184

169185
if (sourcePrefixRef is not null)
170186
{
@@ -193,6 +209,16 @@ private ShapedBuffer(ReadOnlyMemory<char> text, ArraySlice<GlyphInfo> glyphInfos
193209
/// </summary>
194210
internal ArraySlice<GlyphInfo> GlyphInfos => _glyphInfos;
195211

212+
/// <summary>
213+
/// Contiguous view of the glyph indices for this buffer, kept in sync with
214+
/// <see cref="GlyphInfos"/> by the indexer setter. Consumers needing a
215+
/// <see cref="ReadOnlySpan{T}"/> of glyph IDs (e.g. for
216+
/// <c>GlyphTypeface.TryGetGlyphBounds</c> or
217+
/// <c>SKFont.GetGlyphWidths</c>) can use this directly without allocating
218+
/// a parallel array.
219+
/// </summary>
220+
public ReadOnlySpan<ushort> GlyphIndices => _glyphIndices.Span;
221+
196222
/// <summary>
197223
/// The buffer's glyph typeface.
198224
/// </summary>
@@ -230,6 +256,10 @@ public void Dispose()
230256
_glyphRef = null;
231257
_glyphInfos = ArraySlice<GlyphInfo>.Empty; // ensure we don't misuse a returned array
232258

259+
_glyphIndicesRef?.Dispose();
260+
_glyphIndicesRef = null;
261+
_glyphIndices = ArraySlice<ushort>.Empty;
262+
233263
ReleaseClusterCacheRefs();
234264
_clusterPrefix = null;
235265
_clusterStartChars = null;
@@ -259,6 +289,7 @@ public GlyphInfo this[int index]
259289
set
260290
{
261291
_glyphInfos[index] = value;
292+
_glyphIndices[index] = value.GlyphIndex;
262293
// Bump the shared glyph generation so any sibling that built a
263294
// cluster cache against the pre-mutation glyphs will detect the
264295
// mismatch on its next EnsureClusterCache call and rebuild.
@@ -558,8 +589,8 @@ internal ShapedBuffer WithBidiLevel(sbyte paragraphEmbeddingLevel)
558589
}
559590

560591
return new ShapedBuffer(
561-
Text, _glyphInfos, GlyphTypeface, FontRenderingEmSize, paragraphEmbeddingLevel,
562-
_glyphRef, prefixRef, startsRef, startIdx, count, _cacheGeneration);
592+
Text, _glyphInfos, _glyphIndices, GlyphTypeface, FontRenderingEmSize, paragraphEmbeddingLevel,
593+
_glyphRef, _glyphIndicesRef, prefixRef, startsRef, startIdx, count, _cacheGeneration);
563594
}
564595

565596
/// <summary>
@@ -576,7 +607,15 @@ internal ShapedBuffer CloneWritable()
576607

577608
span.CopyTo(glyphs);
578609

579-
return new ShapedBuffer(Text, new ArraySlice<GlyphInfo>(glyphs), GlyphTypeface, FontRenderingEmSize, BidiLevel);
610+
// The glyph indices ride in their own slice and must stay aligned with the
611+
// copied infos; justification mutates advances only, so a plain copy holds.
612+
var indicesSpan = _glyphIndices.Span;
613+
var indices = new ushort[indicesSpan.Length];
614+
615+
indicesSpan.CopyTo(indices);
616+
617+
return new ShapedBuffer(Text, new ArraySlice<GlyphInfo>(glyphs), new ArraySlice<ushort>(indices),
618+
GlyphTypeface, FontRenderingEmSize, BidiLevel);
580619
}
581620

582621
int IReadOnlyCollection<GlyphInfo>.Count => _glyphInfos.Length;
@@ -596,7 +635,9 @@ public SplitResult<ShapedBuffer> Split(int textLength)
596635
if (textLength <= 0)
597636
{
598637
var emptyBuffer = new ShapedBuffer(
599-
Text.Slice(0, 0), _glyphInfos.Slice(_glyphInfos.Start, 0),
638+
Text.Slice(0, 0),
639+
_glyphInfos.Slice(_glyphInfos.Start, 0),
640+
_glyphIndices.Slice(_glyphIndices.Start, 0),
600641
GlyphTypeface, FontRenderingEmSize, BidiLevel);
601642

602643
return new SplitResult<ShapedBuffer>(emptyBuffer, this);
@@ -662,6 +703,8 @@ private SplitResult<ShapedBuffer> SplitAscending(int textLength)
662703

663704
var firstGlyphs = _glyphInfos.Slice(sliceStart, splitGlyphIndex);
664705
var secondGlyphs = _glyphInfos.Slice(sliceStart + splitGlyphIndex, glyphInfosLength - splitGlyphIndex);
706+
var firstGlyphIndices = _glyphIndices.Slice(sliceStart, splitGlyphIndex);
707+
var secondGlyphIndices = _glyphIndices.Slice(sliceStart + splitGlyphIndex, glyphInfosLength - splitGlyphIndex);
665708

666709
var firstText = Text.Slice(0, splitCharCount);
667710
var secondText = Text.Slice(splitCharCount);
@@ -672,9 +715,9 @@ private SplitResult<ShapedBuffer> SplitAscending(int textLength)
672715
var leadingClusterCount = FindClusterOffsetForSplit(splitCharCount);
673716

674717
var leading = new ShapedBuffer(
675-
firstText, firstGlyphs,
718+
firstText, firstGlyphs, firstGlyphIndices,
676719
GlyphTypeface, FontRenderingEmSize, BidiLevel,
677-
_glyphRef, _prefixRef, _startsRef,
720+
_glyphRef, _glyphIndicesRef, _prefixRef, _startsRef,
678721
_clusterStartIdx, leadingClusterCount, _cacheGeneration);
679722

680723
if (secondText.Length == 0)
@@ -683,9 +726,9 @@ private SplitResult<ShapedBuffer> SplitAscending(int textLength)
683726
}
684727

685728
var trailing = new ShapedBuffer(
686-
secondText, secondGlyphs,
729+
secondText, secondGlyphs, secondGlyphIndices,
687730
GlyphTypeface, FontRenderingEmSize, BidiLevel,
688-
_glyphRef, _prefixRef, _startsRef,
731+
_glyphRef, _glyphIndicesRef, _prefixRef, _startsRef,
689732
_clusterStartIdx + leadingClusterCount, _clusterCount - leadingClusterCount, _cacheGeneration);
690733

691734
return new SplitResult<ShapedBuffer>(leading, trailing);
@@ -735,6 +778,8 @@ private SplitResult<ShapedBuffer> SplitDescending(int textLength)
735778
// Visual trailing = glyphs [splitGlyphIndex, end) → logically text[0..textLength] (our "first")
736779
var secondGlyphs = _glyphInfos.Slice(sliceStart, splitGlyphIndex);
737780
var firstGlyphs = _glyphInfos.Slice(sliceStart + splitGlyphIndex, glyphInfosLength - splitGlyphIndex);
781+
var secondGlyphIndices = _glyphIndices.Slice(sliceStart, splitGlyphIndex);
782+
var firstGlyphIndices = _glyphIndices.Slice(sliceStart + splitGlyphIndex, glyphInfosLength - splitGlyphIndex);
738783

739784
var firstText = Text.Slice(0, textLength);
740785
var secondText = Text.Slice(textLength);
@@ -747,9 +792,9 @@ private SplitResult<ShapedBuffer> SplitDescending(int textLength)
747792
var firstClusterCount = FindClusterOffsetForSplit(textLength);
748793

749794
var first = new ShapedBuffer(
750-
firstText, firstGlyphs,
795+
firstText, firstGlyphs, firstGlyphIndices,
751796
GlyphTypeface, FontRenderingEmSize, BidiLevel,
752-
_glyphRef, _prefixRef, _startsRef,
797+
_glyphRef, _glyphIndicesRef, _prefixRef, _startsRef,
753798
_clusterStartIdx, firstClusterCount, _cacheGeneration);
754799

755800
if (secondText.Length == 0 || secondGlyphs.Length == 0)
@@ -758,9 +803,9 @@ private SplitResult<ShapedBuffer> SplitDescending(int textLength)
758803
}
759804

760805
var second = new ShapedBuffer(
761-
secondText, secondGlyphs,
806+
secondText, secondGlyphs, secondGlyphIndices,
762807
GlyphTypeface, FontRenderingEmSize, BidiLevel,
763-
_glyphRef, _prefixRef, _startsRef,
808+
_glyphRef, _glyphIndicesRef, _prefixRef, _startsRef,
764809
_clusterStartIdx + firstClusterCount, _clusterCount - firstClusterCount, _cacheGeneration);
765810

766811
return new SplitResult<ShapedBuffer>(first, second);

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -843,10 +843,10 @@ public static TextLineImpl CreateEmptyTextLine(int firstTextSourceIndex, double
843843
var properties = paragraphProperties.DefaultTextRunProperties;
844844
var glyphTypeface = properties.CachedGlyphTypeface;
845845
var glyph = glyphTypeface.CharacterToGlyphMap[s_empty[0]];
846-
var glyphInfos = new[] { new GlyphInfo(glyph, firstTextSourceIndex, 0.0) };
847846

848-
var shapedBuffer = new ShapedBuffer(s_empty.AsMemory(), glyphInfos, glyphTypeface, properties.FontRenderingEmSize,
849-
(sbyte)flowDirection);
847+
var shapedBuffer = new ShapedBuffer(s_empty.AsMemory(), 1, glyphTypeface,
848+
properties.FontRenderingEmSize, (sbyte)flowDirection);
849+
shapedBuffer[0] = new GlyphInfo(glyph, firstTextSourceIndex, 0.0);
850850

851851
var textRuns = new TextRun[] { new ShapedTextRun(shapedBuffer, properties) };
852852

src/Skia/Avalonia.Skia/GlyphRunImpl.cs

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -38,14 +38,25 @@ public GlyphRunImpl(GlyphTypeface glyphTypeface, double fontRenderingEmSize,
3838
_glyphIndices = new ushort[count];
3939
_glyphPositions = new SKPoint[count];
4040

41-
// GetGlyphWidths needs _glyphIndices populated before the
42-
// per-glyph bounds can be fetched, so this walk has to come
43-
// first. It deliberately does no other work — positions and
44-
// runBounds are built together in the fused walk below, using
45-
// a single currentX accumulator.
46-
for (int i = 0; i < count; i++)
41+
// GetGlyphWidths needs _glyphIndices populated before the per-glyph
42+
// bounds can be fetched, so this walk has to come first. It does no
43+
// other work — positions and runBounds are built together in the
44+
// fused walk below, using a single currentX accumulator.
45+
//
46+
// ShapedBuffer maintains a contiguous ushort span over the run's
47+
// glyph IDs; copy it once instead of walking per glyph. Falls back
48+
// to per-glyph when the caller constructed a GlyphRun directly from
49+
// raw GlyphInfo records (custom rendering, glyph palettes).
50+
if (glyphInfos is Media.TextFormatting.ShapedBuffer shapedBuffer)
4751
{
48-
_glyphIndices[i] = glyphInfos[i].GlyphIndex;
52+
shapedBuffer.GlyphIndices.CopyTo(_glyphIndices);
53+
}
54+
else
55+
{
56+
for (int i = 0; i < count; i++)
57+
{
58+
_glyphIndices[i] = glyphInfos[i].GlyphIndex;
59+
}
4960
}
5061

5162
// Ideally the requested edging should be passed to the glyph run.
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
using System;
2+
using Avalonia.Base.UnitTests.Media.Fonts.Tables;
3+
using Avalonia.Media;
4+
using Avalonia.Media.TextFormatting;
5+
using Avalonia.Platform;
6+
using Xunit;
7+
8+
namespace Avalonia.Base.UnitTests.Media.TextFormatting;
9+
10+
public class ShapedBufferTests
11+
{
12+
private const string InterFontUri =
13+
"resm:Avalonia.Base.UnitTests.Assets.Inter-Regular.ttf?assembly=Avalonia.Base.UnitTests";
14+
15+
private static GlyphTypeface LoadInter()
16+
{
17+
var assetLoader = new StandardAssetLoader();
18+
using var stream = assetLoader.Open(new Uri(InterFontUri));
19+
return new GlyphTypeface(new CustomPlatformTypeface(stream));
20+
}
21+
22+
[Fact]
23+
public void GlyphIndices_Length_Matches_BufferLength()
24+
{
25+
var typeface = LoadInter();
26+
27+
using var buffer = new ShapedBuffer("hello".AsMemory(), 5, typeface, 16, 0);
28+
29+
Assert.Equal(5, buffer.GlyphIndices.Length);
30+
Assert.Equal(5, buffer.Length);
31+
}
32+
33+
[Fact]
34+
public void Indexer_Set_Syncs_GlyphIndices()
35+
{
36+
var typeface = LoadInter();
37+
38+
using var buffer = new ShapedBuffer("ABC".AsMemory(), 3, typeface, 16, 0);
39+
40+
buffer[0] = new GlyphInfo(42, 0, 10);
41+
buffer[1] = new GlyphInfo(99, 1, 11);
42+
buffer[2] = new GlyphInfo(7, 2, 12);
43+
44+
Assert.Equal(42, buffer.GlyphIndices[0]);
45+
Assert.Equal(99, buffer.GlyphIndices[1]);
46+
Assert.Equal(7, buffer.GlyphIndices[2]);
47+
48+
// And the GlyphInfo accessor still works.
49+
Assert.Equal(42, buffer[0].GlyphIndex);
50+
Assert.Equal(99, buffer[1].GlyphIndex);
51+
Assert.Equal(7, buffer[2].GlyphIndex);
52+
}
53+
54+
[Fact]
55+
public void Indexer_Set_Overwrite_Updates_GlyphIndices()
56+
{
57+
var typeface = LoadInter();
58+
59+
using var buffer = new ShapedBuffer("X".AsMemory(), 1, typeface, 16, 0);
60+
61+
buffer[0] = new GlyphInfo(10, 0, 1);
62+
Assert.Equal(10, buffer.GlyphIndices[0]);
63+
64+
// Mutating the entry (as InterWordJustification does) keeps the parallel array in sync.
65+
buffer[0] = new GlyphInfo(10, 0, 2);
66+
Assert.Equal(10, buffer.GlyphIndices[0]);
67+
68+
buffer[0] = new GlyphInfo(33, 0, 1);
69+
Assert.Equal(33, buffer.GlyphIndices[0]);
70+
}
71+
72+
[Fact]
73+
public void Split_Ascending_Preserves_GlyphIndices_Alignment()
74+
{
75+
var typeface = LoadInter();
76+
77+
// LTR (bidi 0): clusters ascending. Five 1-char clusters.
78+
using var buffer = new ShapedBuffer("ABCDE".AsMemory(), 5, typeface, 16, 0);
79+
buffer[0] = new GlyphInfo(10, 0, 5);
80+
buffer[1] = new GlyphInfo(20, 1, 5);
81+
buffer[2] = new GlyphInfo(30, 2, 5);
82+
buffer[3] = new GlyphInfo(40, 3, 5);
83+
buffer[4] = new GlyphInfo(50, 4, 5);
84+
85+
var split = buffer.Split(2);
86+
87+
Assert.NotNull(split.First);
88+
Assert.NotNull(split.Second);
89+
Assert.Equal(2, split.First!.GlyphIndices.Length);
90+
Assert.Equal(3, split.Second!.GlyphIndices.Length);
91+
92+
Assert.Equal(10, split.First.GlyphIndices[0]);
93+
Assert.Equal(20, split.First.GlyphIndices[1]);
94+
95+
Assert.Equal(30, split.Second.GlyphIndices[0]);
96+
Assert.Equal(40, split.Second.GlyphIndices[1]);
97+
Assert.Equal(50, split.Second.GlyphIndices[2]);
98+
99+
// Both halves stay in lockstep with the GlyphInfo indexer.
100+
Assert.Equal(split.First[0].GlyphIndex, split.First.GlyphIndices[0]);
101+
Assert.Equal(split.Second[1].GlyphIndex, split.Second.GlyphIndices[1]);
102+
}
103+
104+
[Fact]
105+
public void Split_At_Zero_Yields_Empty_Leading_With_Empty_Indices()
106+
{
107+
var typeface = LoadInter();
108+
109+
using var buffer = new ShapedBuffer("ABC".AsMemory(), 3, typeface, 16, 0);
110+
buffer[0] = new GlyphInfo(10, 0, 5);
111+
buffer[1] = new GlyphInfo(20, 1, 5);
112+
buffer[2] = new GlyphInfo(30, 2, 5);
113+
114+
var split = buffer.Split(0);
115+
116+
Assert.NotNull(split.First);
117+
Assert.Equal(0, split.First!.GlyphIndices.Length);
118+
Assert.Equal(0, split.First.Length);
119+
Assert.NotNull(split.Second);
120+
Assert.Equal(3, split.Second!.GlyphIndices.Length);
121+
}
122+
123+
[Fact]
124+
public void Dispose_Clears_GlyphIndices_View()
125+
{
126+
var typeface = LoadInter();
127+
128+
var buffer = new ShapedBuffer("AB".AsMemory(), 2, typeface, 16, 0);
129+
buffer[0] = new GlyphInfo(1, 0, 1);
130+
buffer[1] = new GlyphInfo(2, 1, 1);
131+
132+
Assert.Equal(2, buffer.GlyphIndices.Length);
133+
134+
buffer.Dispose();
135+
136+
// After dispose the views are reset so we don't reach into the returned pool buffer.
137+
Assert.Equal(0, buffer.GlyphIndices.Length);
138+
Assert.Equal(0, buffer.Length);
139+
}
140+
}

0 commit comments

Comments
 (0)