Browse Source

[Text] Unicode and text layout optimizations (#21400)

* perf(text): O(1) width queries on ShapedBuffer, cached LB-class lookup

- ShapedBuffer: lazy, pooled cluster-prefix cache shared across Split;
  adds TotalGlyphAdvance / MeasureCharactersThatFit.
- GlyphRun/TextFormatterImpl/Skia GlyphRunImpl: read the cache, drop
  duplicate scans; SplitTextRuns now returns firstLength via out.
- LineBreakEnumerator: cache Next/PreviousClass per advance; remove
  unused LineBreakPairTable.
- Tests + benchmarks for the new paths and a dotnet-trace harness.

~1.32x faster / -5% alloc on the emoji-wrap micro-benchmark; no
observable behaviour or public API change.

* perf(TextFormatting): inline LineBreakEnumerator rule dispatch

Replace the BreakUnitDelegate[] s_rules array dispatch in
LineBreakEnumerator.ExecuteRules with a sequence of direct static
calls and a `goto Done` early-exit. This removes 42 indirect calls
per codepoint and lets the JIT reason across rule boundaries, which
in turn makes [MethodImpl(AggressiveInlining)] meaningful — JIT
cannot inline through delegate.Invoke, so the attribute was a no-op
in the previous shape.

Selectively apply AggressiveInlining to the small single-condition
rules (LB03, LB04, LB06, LB07, LB08a, LB11–LB15d, LB18, LB20,
LB21b, LB22, LB29, LB31) and let the JIT decide on the larger ones
to avoid bloating the merged ExecuteRules.

The static BreakUnitDelegate[] s_rules array is removed.

Benchmarks (BDN default job, --inProcess, N=13–22, rel. StdDev <1.5%):

  UnicodeBreakEnumeratorBenchmark.LineBreakEnumerator_Sequence
    Ascii          154.6 µs -> 24.84 µs  (6.22x)
    Bmp            190.2 µs -> 32.81 µs  (5.80x)
    Supplementary  227.6 µs -> 37.56 µs  (6.06x)

  TextLayoutProfile.BuildEmojisWrapped
    Before (branch, pre-inline)  804.5 µs / 570.15 KB
    After  (branch, post-inline) 547.0 µs / 570.15 KB  (-32.0%)
    vs upstream/master (1061 µs) -> ~1.94x total speedup

No allocation change; CPU-only dispatch reshape. All
LineBreakEnumerator unit tests pass (5/5).

* Refactor ShapedBuffer to share pool storage via IRef + generation counter

Wrap the ArrayPool-rented glyph and cluster-cache arrays in a small
PooledArray<T> disposable and expose them through IRef<T>. Split children
and WithBidiLevel aliases now Clone() the refs instead of borrowing raw
pool arrays, so the backing storage survives until every sibling has
been disposed - eliminating the UAF risk that existed when a parent was
disposed before its children.

Add a per-glyph-holder generation counter (Volatile.Read / Interlocked
.Increment). The indexer setter bumps the counter on every write, and
EnsureClusterCache compares its recorded generation against the holder's
current value, rebuilding on mismatch. This lets us drop the previous
"no mutation after Split/WithBidiLevel" contract: mutations performed
through any sibling now propagate to the others' caches transparently.

Dispose is made idempotent via a _disposed guard so overlapping cache
eviction and TextLine teardown only release the IRefs once.

Adds ShapedBufferSharedStorageTests covering sibling lifetime,
Dispose idempotency, and generation-driven cache invalidation across
Split children and WithBidiLevel aliases.

* test(ShapedBuffer): cover cluster-cache sharing across Split/WithBidiLevel aliases

Add a regression test hook (`ClusterPrefix`) exposing the
backing cluster-prefix array reference, plus two tests that mutate a
parent buffer before aliasing it and assert the alias reuses the
parent's pooled prefix array instead of rebuilding. Guards against
forgetting to propagate `_cacheGeneration` to alias buffers, which
would silently defeat the cached-split fast path.

* Correctly use MathUtilities.LessThanOrClose

---------

Co-authored-by: Julien Lebosquain <julien@lebosquain.net>
pull/21441/head
Benedikt Stebner 5 months ago
committed by GitHub
parent
commit
42bba0b859
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 2
      .gitignore
  2. 19
      src/Avalonia.Base/Media/GlyphRun.cs
  3. 559
      src/Avalonia.Base/Media/TextFormatting/ShapedBuffer.cs
  4. 196
      src/Avalonia.Base/Media/TextFormatting/TextFormatterImpl.cs
  5. 352
      src/Avalonia.Base/Media/TextFormatting/Unicode/LineBreakEnumerator.cs
  6. 74
      src/Avalonia.Base/Media/TextFormatting/Unicode/LineBreakPairTable.cs
  7. 56
      src/Skia/Avalonia.Skia/GlyphRunImpl.cs
  8. 262
      tests/Avalonia.Base.UnitTests/Media/TextFormatting/CodepointTests.cs
  9. 94
      tests/Avalonia.Base.UnitTests/Media/TextFormatting/PropertyValueAliasHelperTests.cs
  10. 176
      tests/Avalonia.Base.UnitTests/Media/TextFormatting/UnicodeDataTests.cs
  11. 179
      tests/Avalonia.Base.UnitTests/Media/TextFormatting/UnicodeTrieTests.cs
  12. 78
      tests/Avalonia.Benchmarks/Program.cs
  13. 223
      tests/Avalonia.Benchmarks/Text/CodepointBenchmark.cs
  14. 4
      tests/Avalonia.Benchmarks/Text/HugeTextLayout.cs
  15. 149
      tests/Avalonia.Benchmarks/Text/ShapedBufferOps.cs
  16. 52
      tests/Avalonia.Benchmarks/Text/TextLayoutProfile.cs
  17. 132
      tests/Avalonia.Benchmarks/Text/UnicodeBreakEnumeratorBenchmark.cs
  18. 153
      tests/Avalonia.Benchmarks/Text/UnicodeTrieBenchmark.cs
  19. 297
      tests/Avalonia.Skia.UnitTests/Media/TextFormatting/ShapedBufferSharedStorageTests.cs
  20. 382
      tests/Avalonia.Skia.UnitTests/Media/TextFormatting/SplitTextRunsTests.cs
  21. 291
      tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterWrapCharacterizationTests.cs
  22. 7
      tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs
  23. 81
      tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextShaperTests.cs

2
.gitignore

@ -225,3 +225,5 @@ src/Browser/Avalonia.Browser/staticwebassets
# Claude agent worktrees
.claude/worktrees/
/.claude/settings.local.json
/planning
BenchmarkDotNet.Artifacts.*

19
src/Avalonia.Base/Media/GlyphRun.cs

@ -684,15 +684,26 @@ namespace Avalonia.Media
}
var height = GlyphTypeface.Metrics.LineSpacing * Scale;
var widthIncludingTrailingWhitespace = 0d;
var trailingWhitespaceLength = GetTrailingWhitespaceLength(isReversed, out var newLineLength, out var glyphCount);
for (var index = 0; index < _glyphInfos.Count; index++)
// when our glyph source is a ShapedBuffer (the common case every
// shape result flows through one), it already maintains a cluster-width
// prefix sum we can read in O(1) instead of summing all advances here.
double widthIncludingTrailingWhitespace;
if (_glyphInfos is TextFormatting.ShapedBuffer shapedBuffer)
{
widthIncludingTrailingWhitespace = shapedBuffer.TotalGlyphAdvance;
}
else
{
var advance = _glyphInfos[index].GlyphAdvance;
widthIncludingTrailingWhitespace = 0d;
widthIncludingTrailingWhitespace += advance;
for (var index = 0; index < _glyphInfos.Count; index++)
{
widthIncludingTrailingWhitespace += _glyphInfos[index].GlyphAdvance;
}
}
var width = widthIncludingTrailingWhitespace;

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

@ -2,22 +2,135 @@ using System;
using System.Buffers;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Threading;
using Avalonia.Utilities;
namespace Avalonia.Media.TextFormatting
{
public sealed class ShapedBuffer : IReadOnlyList<GlyphInfo>, IDisposable
{
private GlyphInfo[]? _rentedBuffer;
/// <summary>
/// Disposable wrapper around an <see cref="ArrayPool{T}"/>-rented array.
/// Combined with <see cref="IRef{T}"/> this gives <see cref="ShapedBuffer"/>
/// shared, ref-counted ownership of its glyph and cluster-cache storage so
/// that <see cref="Split"/> and <see cref="WithBidiLevel"/> can safely alias
/// the same backing arrays.
/// </summary>
internal sealed class PooledArray<T> : IDisposable
{
private T[]? _array;
private int _generation;
public PooledArray(int minLength)
{
_array = ArrayPool<T>.Shared.Rent(minLength);
}
public T[] Array => _array ?? throw new ObjectDisposedException(nameof(PooledArray<T>));
/// <summary>
/// Monotonically increasing version stamp. Bumped whenever the underlying
/// data is mutated so siblings sharing this holder can detect that any
/// cache derived from the data is stale and must be rebuilt.
/// </summary>
public int Generation => Volatile.Read(ref _generation);
public void BumpGeneration() => Interlocked.Increment(ref _generation);
public void Dispose()
{
var arr = Interlocked.Exchange(ref _array, null);
if (arr is not null)
{
ArrayPool<T>.Shared.Return(arr);
}
}
}
// Ref-counted handle to the pooled GlyphInfo[] that backs _glyphInfos.
// Null when the buffer was constructed over caller-owned storage
// (e.g. an externally allocated array for the empty-line synthetic run).
// Split children and WithBidiLevel aliases clone this ref so the array
// survives until every observer has been disposed.
private IRef<PooledArray<GlyphInfo>>? _glyphRef;
private ArraySlice<GlyphInfo> _glyphInfos;
// Lazily-computed cluster-width cache. MeasureLength and metrics
// queries both fold multi-glyph clusters and accumulate per-cluster
// advances — the result depends only on the shaped glyphs and the
// text, both immutable for a given ShapedBuffer instance.
//
// To make wrap-time splits cheap, the cache is shared by reference
// across split halves: when this buffer was produced by Split, the
// arrays point at the parent's cache and _clusterStartIdx is the offset
// at which this buffer's first cluster lives. That keeps post-split
// wrap iterations O(1) per query instead of re-walking glyphs.
//
// _clusterPrefix[i] = sum of cluster advances [0..i) in logical order
// over the full source buffer (parent's view); width of this sub-buffer
// is `_clusterPrefix[_clusterStartIdx + _clusterCount] - _clusterPrefix[_clusterStartIdx]`.
// _clusterStartChars[i] = char offset into the parent's Text where the
// i-th cluster begins; this sub-buffer's start char in parent space is
// `_clusterStartChars[_clusterStartIdx]`.
//
// Fast path: when every cluster is exactly one character wide (the
// common Latin / single-codepoint case where bufferLength == Text.Length
// == clusters), the start-chars array is omitted entirely because
// `startChars[k] == k` is implicit. In that mode `_clusterStartChars`
// is null but `_clusterPrefix` is still populated. This is detected
// during cache build and preserved across <see cref="Split"/>.
//
// Sums in this prefix are in logical (not visual) order, which can
// differ by ULPs from a visual-order sum for RTL buffers. Consumers
// comparing layout dimensions should use tolerant equality.
//
// Pooling: the cache arrays are rented from ArrayPool<T>.Shared in
// EnsureClusterCache. Ownership follows the same pattern as
// _rentedBuffer for the glyph-info array: only the buffer that rented
// the array tracks it in _rentedClusterPrefix / _rentedClusterStartChars
// and returns it on Dispose. Split children copy the data-array
// references via _clusterPrefix / _clusterStartChars but leave the
// _rented* handles null, so they don't return arrays they don't own.
// The pool may hand back larger arrays than requested — we always read
// through bounded indices (_clusterStartIdx + [0.._clusterCount]) so the
// unused tail is harmless.
// Pooling: the cluster-cache arrays are reached through ref-counted
// <see cref="IRef{PooledArray}"/> handles. The hot-path read fields
// <c>_clusterPrefix</c> and <c>_clusterStartChars</c> alias the rented
// arrays directly so measure/split loops stay indirection-free. Split
// children and <see cref="WithBidiLevel"/> aliases clone the refs, so the
// arrays survive until every sibling is disposed; the pool may hand back
// larger arrays than requested so we always read through bounded indices
// (_clusterStartIdx + [0.._clusterCount]).
private double[]? _clusterPrefix;
private int[]? _clusterStartChars;
private IRef<PooledArray<double>>? _prefixRef;
private IRef<PooledArray<int>>? _startsRef;
private int _clusterStartIdx;
private int _clusterCount;
// Generation observed on the shared glyph holder when this buffer's
// cluster cache was built. If the holder's current generation differs,
// a sibling has mutated the glyph array since and we must rebuild.
// Zero is the initial value for both fields, so a freshly populated
// cache against an unmutated holder matches without extra bookkeeping.
private int _cacheGeneration;
// Guard so Dispose is idempotent. Multiple Dispose calls (e.g. via
// cache eviction overlapping with TextLine teardown) only release the
// ref-counted handles once.
private bool _disposed;
private static readonly int[] s_emptyStartChars = new[] { 0 };
private static readonly double[] s_emptyPrefix = new[] { 0d };
public ShapedBuffer(ReadOnlyMemory<char> text, int bufferLength, GlyphTypeface glyphTypeface, double fontRenderingEmSize, sbyte bidiLevel)
{
Text = text;
_rentedBuffer = ArrayPool<GlyphInfo>.Shared.Rent(bufferLength);
_glyphInfos = new ArraySlice<GlyphInfo>(_rentedBuffer, 0, bufferLength);
_glyphRef = RefCountable.Create(new PooledArray<GlyphInfo>(bufferLength));
_glyphInfos = new ArraySlice<GlyphInfo>(_glyphRef.Item.Array, 0, bufferLength);
GlyphTypeface = glyphTypeface;
FontRenderingEmSize = fontRenderingEmSize;
BidiLevel = bidiLevel;
@ -32,6 +145,44 @@ namespace Avalonia.Media.TextFormatting
BidiLevel = bidiLevel;
}
/// <summary>
/// Internal constructor used by <see cref="Split"/> and <see cref="WithBidiLevel"/> to
/// alias the source buffer's pooled glyph storage and (optionally) cluster cache.
/// Each non-null ref is <see cref="IRef{T}.Clone"/>d so the underlying arrays survive
/// until every sibling has been disposed. When <paramref name="sourcePrefixRef"/>
/// is null the alias starts without a cluster cache and will build its own lazily.
/// </summary>
private ShapedBuffer(ReadOnlyMemory<char> text, ArraySlice<GlyphInfo> glyphInfos,
GlyphTypeface glyphTypeface, double fontRenderingEmSize, sbyte bidiLevel,
IRef<PooledArray<GlyphInfo>>? sourceGlyphRef,
IRef<PooledArray<double>>? sourcePrefixRef,
IRef<PooledArray<int>>? sourceStartsRef,
int clusterStartIdx, int clusterCount, int sourceCacheGeneration)
{
Text = text;
_glyphInfos = glyphInfos;
GlyphTypeface = glyphTypeface;
FontRenderingEmSize = fontRenderingEmSize;
BidiLevel = bidiLevel;
_glyphRef = sourceGlyphRef?.Clone();
if (sourcePrefixRef is not null)
{
_prefixRef = sourcePrefixRef.Clone();
_clusterPrefix = _prefixRef.Item.Array;
if (sourceStartsRef is not null)
{
_startsRef = sourceStartsRef.Clone();
_clusterStartChars = _startsRef.Item.Array;
}
_clusterStartIdx = clusterStartIdx;
_clusterCount = clusterCount;
_cacheGeneration = sourceCacheGeneration;
}
}
/// <summary>
/// The buffer's length.
/// </summary>
@ -69,12 +220,35 @@ namespace Avalonia.Media.TextFormatting
public void Dispose()
{
if (_rentedBuffer is not null)
if (_disposed)
{
ArrayPool<GlyphInfo>.Shared.Return(_rentedBuffer);
_rentedBuffer = null;
_glyphInfos = ArraySlice<GlyphInfo>.Empty; // ensure we don't misuse the returned array
return;
}
_disposed = true;
_glyphRef?.Dispose();
_glyphRef = null;
_glyphInfos = ArraySlice<GlyphInfo>.Empty; // ensure we don't misuse a returned array
ReleaseClusterCacheRefs();
_clusterPrefix = null;
_clusterStartChars = null;
_clusterStartIdx = 0;
_clusterCount = 0;
}
/// <summary>
/// Releases this buffer's ref-counted handles to the cluster-cache arrays
/// (if any). The underlying arrays are only returned to the pool when the
/// last sibling releases its handle, so this is safe to call from both
/// <see cref="Dispose"/> and <see cref="InvalidateClusterCache"/>.
/// </summary>
private void ReleaseClusterCacheRefs()
{
_prefixRef?.Dispose();
_prefixRef = null;
_startsRef?.Dispose();
_startsRef = null;
}
public GlyphInfo this[int index]
@ -82,7 +256,325 @@ namespace Avalonia.Media.TextFormatting
[MethodImpl(MethodImplOptions.AggressiveInlining)]
get => _glyphInfos[index];
[MethodImpl(MethodImplOptions.AggressiveInlining)]
set => _glyphInfos[index] = value;
set
{
_glyphInfos[index] = value;
// Bump the shared glyph generation so any sibling that built a
// cluster cache against the pre-mutation glyphs will detect the
// mismatch on its next EnsureClusterCache call and rebuild.
_glyphRef?.Item.BumpGeneration();
InvalidateClusterCache();
}
}
/// <summary>
/// Test hook: indicates that the cluster cache is using the
/// one-char-per-cluster fast path (no <c>_clusterStartChars</c> allocation).
/// Materialises the cache as a side effect, so call after the buffer is
/// fully populated.
/// </summary>
internal bool IsClusterCacheSimple
{
get
{
EnsureClusterCache();
return _clusterStartChars is null;
}
}
/// <summary>
/// Test hook: exposes the backing cluster-prefix array reference so unit
/// tests can assert that <see cref="Split"/> / <see cref="WithBidiLevel"/>
/// aliases share the parent's cache rather than rebuilding their own.
/// Returns null when no cache has been built yet.
/// </summary>
internal double[]? ClusterPrefix => _clusterPrefix;
/// <summary>
/// Returns the total advance of all glyphs in the buffer (i.e. the buffer's
/// rendered width). Cached after first access; the value is summed in
/// logical cluster order, which can differ by ULPs from a visual-order sum
/// on RTL buffers — fine for layout but tests should use FP-tolerant equality.
/// </summary>
internal double TotalGlyphAdvance
{
[MethodImpl(MethodImplOptions.AggressiveInlining)]
get
{
var prefix = EnsureClusterCache();
var start = _clusterStartIdx;
return prefix[start + _clusterCount] - prefix[start];
}
}
/// <summary>
/// Finds how many text characters from the start of this buffer fit within
/// <paramref name="availableWidth"/>, walking in logical cluster order.
/// Returns the character count and the width consumed (always &lt;= availableWidth
/// unless the very first cluster overflows, in which case the caller is expected
/// to honour the overflow contract documented in <c>TextFormatterImpl.MeasureLength</c>).
/// </summary>
internal int MeasureCharactersThatFit(double availableWidth, out double widthConsumed)
{
var prefix = EnsureClusterCache();
var startIdx = _clusterStartIdx;
var count = _clusterCount;
if (count == 0)
{
widthConsumed = 0d;
return 0;
}
// Find the largest k such that prefix[startIdx + k] - prefix[startIdx] <=
// availableWidth. Standard binary search on the prefix-sum array, with the
// sub-buffer's base width subtracted out.
var basePrefix = prefix[startIdx];
var lo = 0;
var hi = count;
while (lo < hi)
{
var mid = (lo + hi + 1) >> 1;
if (MathUtilities.LessThanOrClose(prefix[startIdx + mid] - basePrefix, availableWidth))
{
lo = mid;
}
else
{
hi = mid - 1;
}
}
widthConsumed = prefix[startIdx + lo] - basePrefix;
// Simple-mode fast path: 1 char per cluster, so the consumed char count
// equals the cluster offset we just resolved (no start-chars table needed).
var starts = _clusterStartChars;
if (starts is null)
{
return lo;
}
return starts[startIdx + lo] - starts[startIdx];
}
/// <summary>
/// Returns the character length of the first logical cluster in this buffer.
/// Used by <c>MeasureLength</c> to satisfy the "include at least one cluster"
/// rule when even the first cluster does not fit the paragraph width.
/// </summary>
internal int FirstClusterCharLength
{
[MethodImpl(MethodImplOptions.AggressiveInlining)]
get
{
EnsureClusterCache();
if (_clusterCount == 0)
{
return 0;
}
var starts = _clusterStartChars;
if (starts is null)
{
// Simple mode: every cluster is one character wide.
return 1;
}
var startIdx = _clusterStartIdx;
return starts[startIdx + 1] - starts[startIdx];
}
}
/// <summary>
/// Ensures the cluster cache is built in <em>logical</em> order. LTR buffers store glyphs
/// in ascending cluster order so logical order is the same as visual order
/// (walk forward from index 0). RTL buffers store glyphs in descending cluster
/// order — visual order is reverse-logical — so logical order means walking
/// the underlying array backwards. The output `prefix` and `startChars` are
/// always in logical order: `startChars[0] == 0`, `startChars[count] == Text.Length`,
/// and `prefix[count]` equals the total advance.
/// </summary>
private double[] EnsureClusterCache()
{
var currentGeneration = _glyphRef?.Item.Generation ?? 0;
if (_clusterPrefix is not null)
{
if (_cacheGeneration == currentGeneration)
{
return _clusterPrefix;
}
// A sibling mutated the shared glyph array since this cache was
// built — drop the stale view (releasing our refs; other
// siblings keep their clones alive) and rebuild below against
// the current glyph data.
InvalidateClusterCache();
}
var glyphInfos = _glyphInfos.Span;
var bufferLength = _glyphInfos.Length;
if (bufferLength == 0)
{
_clusterCount = 0;
_clusterStartChars = s_emptyStartChars;
_cacheGeneration = currentGeneration;
return _clusterPrefix = s_emptyPrefix;
}
var isLtr = IsLeftToRight;
var step = isLtr ? 1 : -1;
var start = isLtr ? 0 : bufferLength - 1;
var end = isLtr ? bufferLength : -1;
var baseCluster = glyphInfos[start].GlyphCluster;
var textLength = Text.Length;
// First pass: count clusters by counting cluster-id transitions in
// logical order. Also track whether this is the "simple" case where
// bufferLength == textLength == clusters and cluster ids are exactly
// baseCluster + logicalIndex — i.e. one glyph per cluster, one char
// per cluster. The check piggybacks the existing iteration so it adds
// a few cheap comparisons but no allocations.
var clusters = 1;
var canBeSimple = bufferLength == textLength;
// Logical index 0 trivially matches: glyphInfos[start].GlyphCluster - baseCluster == 0.
{
var prevId = glyphInfos[start].GlyphCluster;
var logicalIndex = 1;
for (var j = start + step; j != end; j += step, logicalIndex++)
{
var id = glyphInfos[j].GlyphCluster;
if (id != prevId)
{
clusters++;
prevId = id;
}
if (canBeSimple && id - baseCluster != logicalIndex)
{
canBeSimple = false;
}
}
}
var simple = canBeSimple && clusters == bufferLength;
// Rent (possibly oversized) ref-counted holders from the shared pools.
// Split children / WithBidiLevel aliases will Clone these refs so the
// arrays survive until every sibling has released them.
var prefixHolder = new PooledArray<double>(clusters + 1);
_prefixRef = RefCountable.Create(prefixHolder);
var prefix = prefixHolder.Array;
int[]? startChars = null;
if (!simple)
{
var startsHolder = new PooledArray<int>(clusters + 1);
_startsRef = RefCountable.Create(startsHolder);
startChars = startsHolder.Array;
}
// Pool-provided arrays come pre-populated with old data; the first
// prefix slot must be zero (the loop below overwrites every other
// slot we read, and startChars[0] is set explicitly when needed).
prefix[0] = 0d;
var clusterIndex = 0;
var currentClusterId = baseCluster;
var currentWidth = 0d;
if (!simple)
{
startChars![0] = 0;
}
for (var j = start; j != end; j += step)
{
var info = glyphInfos[j];
if (info.GlyphCluster != currentClusterId)
{
prefix[clusterIndex + 1] = prefix[clusterIndex] + currentWidth;
if (!simple)
{
// Cluster IDs increase in logical order in both directions: for LTR
// we walk forward over ascending IDs; for RTL we walk backward over
// (visually descending = logically ascending) IDs.
startChars![clusterIndex + 1] = info.GlyphCluster - baseCluster;
}
clusterIndex++;
currentClusterId = info.GlyphCluster;
currentWidth = info.GlyphAdvance;
}
else
{
currentWidth += info.GlyphAdvance;
}
}
// Close the final cluster.
prefix[clusterIndex + 1] = prefix[clusterIndex] + currentWidth;
if (!simple)
{
startChars![clusterIndex + 1] = textLength;
}
_clusterCount = clusters;
_clusterStartChars = startChars;
_clusterPrefix = prefix;
_cacheGeneration = currentGeneration;
return prefix;
}
/// <summary>
/// Returns the cluster index (relative to this sub-buffer's cache view) at
/// which a logical text-character offset lands. Split methods snap their
/// boundaries to whole clusters, so this is an exact match in the cluster
/// starts table.
/// </summary>
private int FindClusterOffsetForSplit(int splitCharCount)
{
var starts = _clusterStartChars;
if (starts is null)
{
// Simple mode: cluster index == char offset within this sub-buffer.
return splitCharCount;
}
var startIdx = _clusterStartIdx;
var count = _clusterCount;
var targetChar = starts[startIdx] + splitCharCount;
// Binary search for the first cluster whose start char >= targetChar.
var lo = 0;
var hi = count;
while (lo < hi)
{
var mid = (lo + hi) >> 1;
if (starts[startIdx + mid] < targetChar)
{
lo = mid + 1;
}
else
{
hi = mid;
}
}
return lo;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void InvalidateClusterCache()
{
// Releasing our refs is safe even when siblings hold clones — the
// underlying pool arrays survive until the last ref is disposed.
// After invalidation a fresh cache will be rented on the next
// EnsureClusterCache call (independent from any sibling's view).
ReleaseClusterCacheRefs();
_clusterPrefix = null;
_clusterStartChars = null;
_clusterStartIdx = 0;
_clusterCount = 0;
}
public IEnumerator<GlyphInfo> GetEnumerator() => _glyphInfos.GetEnumerator();
@ -99,7 +591,26 @@ namespace Avalonia.Media.TextFormatting
return this;
}
return new ShapedBuffer(Text, _glyphInfos, GlyphTypeface, FontRenderingEmSize, paragraphEmbeddingLevel);
// If we already have a populated cluster cache, hand the alias its
// own clones of the ref-counted holders so it can read the prefix
// sums without rebuilding. BidiLevel only affects how callers
// interpret the buffer, not the shaper's glyph order, so the
// logical-order prefix sums are identical.
IRef<PooledArray<double>>? prefixRef = null;
IRef<PooledArray<int>>? startsRef = null;
var startIdx = 0;
var count = 0;
if (_prefixRef is not null)
{
prefixRef = _prefixRef;
startsRef = _startsRef;
startIdx = _clusterStartIdx;
count = _clusterCount;
}
return new ShapedBuffer(
Text, _glyphInfos, GlyphTypeface, FontRenderingEmSize, paragraphEmbeddingLevel,
_glyphRef, prefixRef, startsRef, startIdx, count, _cacheGeneration);
}
int IReadOnlyCollection<GlyphInfo>.Count => _glyphInfos.Length;
@ -189,9 +700,16 @@ namespace Avalonia.Media.TextFormatting
var firstText = Text.Slice(0, splitCharCount);
var secondText = Text.Slice(splitCharCount);
// Ensure parent's cluster cache exists, then hand sub-views of
// it to the children. Splitting is O(1) per child instead of O(glyphs).
EnsureClusterCache();
var leadingClusterCount = FindClusterOffsetForSplit(splitCharCount);
var leading = new ShapedBuffer(
firstText, firstGlyphs,
GlyphTypeface, FontRenderingEmSize, BidiLevel);
GlyphTypeface, FontRenderingEmSize, BidiLevel,
_glyphRef, _prefixRef, _startsRef,
_clusterStartIdx, leadingClusterCount, _cacheGeneration);
if (secondText.Length == 0)
{
@ -200,7 +718,9 @@ namespace Avalonia.Media.TextFormatting
var trailing = new ShapedBuffer(
secondText, secondGlyphs,
GlyphTypeface, FontRenderingEmSize, BidiLevel);
GlyphTypeface, FontRenderingEmSize, BidiLevel,
_glyphRef, _prefixRef, _startsRef,
_clusterStartIdx + leadingClusterCount, _clusterCount - leadingClusterCount, _cacheGeneration);
return new SplitResult<ShapedBuffer>(leading, trailing);
}
@ -253,9 +773,18 @@ namespace Avalonia.Media.TextFormatting
var firstText = Text.Slice(0, textLength);
var secondText = Text.Slice(textLength);
// share the parent's cluster cache. The cache stores
// clusters in logical order, so "first" (text[0..textLength]) gets the
// leading slice and "second" gets the trailing slice — same indexing
// as the LTR case.
EnsureClusterCache();
var firstClusterCount = FindClusterOffsetForSplit(textLength);
var first = new ShapedBuffer(
firstText, firstGlyphs,
GlyphTypeface, FontRenderingEmSize, BidiLevel);
GlyphTypeface, FontRenderingEmSize, BidiLevel,
_glyphRef, _prefixRef, _startsRef,
_clusterStartIdx, firstClusterCount, _cacheGeneration);
if (secondText.Length == 0 || secondGlyphs.Length == 0)
{
@ -264,7 +793,9 @@ namespace Avalonia.Media.TextFormatting
var second = new ShapedBuffer(
secondText, secondGlyphs,
GlyphTypeface, FontRenderingEmSize, BidiLevel);
GlyphTypeface, FontRenderingEmSize, BidiLevel,
_glyphRef, _prefixRef, _startsRef,
_clusterStartIdx + firstClusterCount, _clusterCount - firstClusterCount, _cacheGeneration);
return new SplitResult<ShapedBuffer>(first, second);
}

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

@ -186,6 +186,17 @@ namespace Avalonia.Media.TextFormatting
/// <returns>The split text runs.</returns>
internal static SplitResult<RentedList<TextRun>> SplitTextRuns(IReadOnlyList<TextRun> textRuns, int length,
FormattingObjectPool objectPool)
=> SplitTextRuns(textRuns, length, objectPool, out _);
/// <summary>
/// Split a sequence of runs into two segments at specified length. The actual
/// length of the first segment (which may differ from <paramref name="length"/>
/// when the split lands on a cluster boundary) is returned via
/// <paramref name="firstLength"/>. This lets the wrap caller avoid a separate
/// second pass to sum run lengths.
/// </summary>
internal static SplitResult<RentedList<TextRun>> SplitTextRuns(IReadOnlyList<TextRun> textRuns, int length,
FormattingObjectPool objectPool, out int firstLength)
{
if(length == 0)
{
@ -196,6 +207,7 @@ namespace Avalonia.Media.TextFormatting
second.Add(textRuns[i]);
}
firstLength = 0;
return new SplitResult<RentedList<TextRun>>(null, second);
}
@ -242,6 +254,7 @@ namespace Avalonia.Media.TextFormatting
first.Add(currentRun);
firstLength = currentLength + currentRunLength;
return new SplitResult<RentedList<TextRun>>(first, second);
}
else
@ -249,6 +262,8 @@ namespace Avalonia.Media.TextFormatting
secondCount++;
var second = objectPool.TextRunLists.Rent();
var addedFirstLength = 0;
int trailingLoopStart;
if (currentRun is ShapedTextRun shapedTextCharacters)
{
@ -257,6 +272,7 @@ namespace Avalonia.Media.TextFormatting
if(split.First is not null)
{
first.Add(split.First);
addedFirstLength = split.First.Length;
}
if (split.Second != null)
@ -267,13 +283,38 @@ namespace Avalonia.Media.TextFormatting
// The split produced fresh ShapedTextRuns for each half, so the
// caller's reference to the original is no longer needed — release it.
shapedTextCharacters.Dispose();
// currentRun is consumed by the split; the trailing loop adds the
// runs *after* it.
trailingLoopStart = 1;
}
else if (currentLength == 0)
{
// Non-splittable run at the very start of the list, asked to split
// strictly inside it. Snapping before would leave first empty and
// the wrap caller would loop forever — same situation as the wrap
// algorithm's "include at least one cluster" overflow rule. Place
// currentRun in first and let the line overflow.
first.Add(currentRun);
addedFirstLength = currentRunLength;
trailingLoopStart = 1;
}
else
{
// Non-splittable run at the split point. Snap the boundary BEFORE
// it: currentRun goes into second along with the remaining runs,
// first ends at currentLength (shorter than requested but
// content-preserving). Without this branch the run would be
// dropped from both halves.
trailingLoopStart = 0;
}
for (var j = 1; j < secondCount; j++)
for (var j = trailingLoopStart; j < secondCount; j++)
{
second.Add(textRuns[i + j]);
}
firstLength = currentLength + addedFirstLength;
return new SplitResult<RentedList<TextRun>>(first, second);
}
}
@ -283,6 +324,7 @@ namespace Avalonia.Media.TextFormatting
first.Add(textRuns[i]);
}
firstLength = currentLength;
return new SplitResult<RentedList<TextRun>>(first, null);
}
@ -720,81 +762,50 @@ namespace Avalonia.Media.TextFormatting
{
case ShapedTextRun shapedTextCharacters:
{
if (shapedTextCharacters.ShapedBuffer.Length > 0)
// cluster-width prefix sum lets us answer "how much fits"
// in O(log clusters) instead of walking every glyph. The total
// advance and the per-cluster start char are cached on the
// ShapedBuffer (which lives in the run cache), so the first
// layout pays the O(glyphs) cost and every subsequent layout
// is constant-time.
var buffer = shapedTextCharacters.ShapedBuffer;
if (buffer.Length == 0)
{
var bufferLength = shapedTextCharacters.ShapedBuffer.Length;
var runLength = 0;
var runTextLength = shapedTextCharacters.Length;
var isLeftToRight = shapedTextCharacters.ShapedBuffer.IsLeftToRight;
// Cluster values stay anchored to the original text even after
// splits, so anchor the run's logical end from FirstCluster.
var logicalEnd = shapedTextCharacters.GlyphRun.Metrics.FirstCluster + runTextLength;
// Walk in LOGICAL order: LTR is already logical (ascending
// clusters from j=0 onwards), RTL visual is reverse-logical so
// we iterate from the tail. The algorithm below reads
// `currentInfo` / `nextInfo` in logical progression — next
// always means "the next glyph logically after the current".
int step = isLeftToRight ? 1 : -1;
int start = isLeftToRight ? 0 : bufferLength - 1;
int end = isLeftToRight ? bufferLength : -1;
for (var j = start; j != end; j += step)
{
var currentInfo = shapedTextCharacters.ShapedBuffer[j];
var clusterWidth = currentInfo.GlyphAdvance;
GlyphInfo nextInfo = default;
var hasNext = false;
// Collect additional glyphs belonging to the same cluster.
while ((isLeftToRight ? j + 1 < bufferLength : j - 1 >= 0))
{
nextInfo = shapedTextCharacters.ShapedBuffer[j + step];
if (currentInfo.GlyphCluster == nextInfo.GlyphCluster)
{
clusterWidth += nextInfo.GlyphAdvance;
j += step;
continue;
}
hasNext = true;
break;
}
var nextLogicalCluster = hasNext ? nextInfo.GlyphCluster : logicalEnd;
var clusterLength = nextLogicalCluster - currentInfo.GlyphCluster;
break;
}
if (MathUtilities.GreaterThan(currentWidth + clusterWidth, paragraphWidth))
{
if (runLength == 0 && measuredLength == 0)
{
runLength = clusterLength;
}
var remaining = paragraphWidth - currentWidth;
var bufferWidth = buffer.TotalGlyphAdvance;
measuredLength += runLength;
if (!MathUtilities.GreaterThan(bufferWidth, remaining))
{
// Whole buffer fits consume it and continue to the next run.
currentWidth += bufferWidth;
measuredLength += currentRun.Length;
break;
}
if (runIndex < textRuns.Count - 1 && runLength == currentRun.Length && textRuns[runIndex + 1] is TextEndOfLine endOfLine)
{
measuredLength += endOfLine.Length;
}
// Some part of the buffer overflows: find the cluster boundary.
var runLength = buffer.MeasureCharactersThatFit(remaining, out _);
return measuredLength;
}
// "Include at least one cluster" rule preserves the existing
// contract that the caller always advances by at least one
// grapheme even when the first cluster overflows the line.
if (runLength == 0 && measuredLength == 0)
{
runLength = buffer.FirstClusterCharLength;
}
currentWidth += clusterWidth;
runLength += clusterLength;
}
measuredLength += runLength;
measuredLength += runLength;
if (runIndex < textRuns.Count - 1 && runLength == currentRun.Length &&
textRuns[runIndex + 1] is TextEndOfLine endOfLine)
{
measuredLength += endOfLine.Length;
}
break;
return measuredLength;
}
case DrawableTextRun drawableTextRun:
@ -908,11 +919,15 @@ namespace Avalonia.Media.TextFormatting
var currentPosition = 0;
for (var index = 0; index < textRuns.Count; index++)
var wrappingMode = paragraphProperties.TextWrapping;
var runCount = textRuns.Count;
for (var index = 0; index < runCount; index++)
{
var breakFound = false;
var currentRun = textRuns[index];
var currentRunLength = currentRun.Length;
switch (currentRun)
{
@ -935,7 +950,7 @@ namespace Avalonia.Media.TextFormatting
if (currentLength + lineBreak.PositionMeasure > measuredLength)
{
if (paragraphProperties.TextWrapping == TextWrapping.WrapWithOverflow)
if (wrappingMode == TextWrapping.WrapWithOverflow)
{
if (lastWrapPosition > 0)
{
@ -947,9 +962,9 @@ namespace Avalonia.Media.TextFormatting
}
//Find next possible wrap position (overflow)
if (index < textRuns.Count - 1)
if (index < runCount - 1)
{
if (lineBreak.PositionWrap != currentRun.Length)
if (lineBreak.PositionWrap != currentRunLength)
{
//We already found the next possible wrap position.
breakFound = true;
@ -963,19 +978,20 @@ namespace Avalonia.Media.TextFormatting
{
currentPosition += lineBreak.PositionWrap;
if (lineBreak.PositionWrap != currentRun.Length)
if (lineBreak.PositionWrap != currentRunLength)
{
break;
}
index++;
if (index >= textRuns.Count)
if (index >= runCount)
{
break;
}
currentRun = textRuns[index];
currentRunLength = currentRun.Length;
lineBreaker = new LineBreakEnumerator(currentRun.Text.Span);
}
@ -1003,7 +1019,7 @@ namespace Avalonia.Media.TextFormatting
break;
}
if (lineBreak.PositionMeasure != lineBreak.PositionWrap || lineBreak.PositionWrap != currentRun.Length)
if (lineBreak.PositionMeasure != lineBreak.PositionWrap || lineBreak.PositionWrap != currentRunLength)
{
lastWrapPosition = currentLength + lineBreak.PositionWrap;
}
@ -1015,7 +1031,7 @@ namespace Avalonia.Media.TextFormatting
if (!breakFound)
{
currentLength += currentRun.Length;
currentLength += currentRunLength;
continue;
}
@ -1025,7 +1041,7 @@ namespace Avalonia.Media.TextFormatting
break;
}
var (preSplitRuns, postSplitRuns) = SplitTextRuns(textRuns, measuredLength, objectPool);
var (preSplitRuns, postSplitRuns) = SplitTextRuns(textRuns, measuredLength, objectPool, out var splitLength);
try
{
@ -1033,6 +1049,7 @@ namespace Avalonia.Media.TextFormatting
if (postSplitRuns?.Count > 0)
{
List<TextRun> remainingRuns;
var postSplitCount = postSplitRuns.Count;
// reuse the list as much as possible:
// if canReuseTextRunList == true it's coming from previous remaining runs
@ -1040,13 +1057,19 @@ namespace Avalonia.Media.TextFormatting
{
remainingRuns = textRuns;
remainingRuns.Clear();
// ensure capacity up front so List<T>.Add does not resize
// mid-loop (each resize is an Array.Copy of the backing array).
if (remainingRuns.Capacity < postSplitCount)
{
remainingRuns.Capacity = postSplitCount;
}
}
else
{
remainingRuns = new List<TextRun>();
remainingRuns = new List<TextRun>(postSplitCount);
}
for (var i = 0; i < postSplitRuns.Count; ++i)
for (var i = 0; i < postSplitCount; ++i)
{
remainingRuns.Add(postSplitRuns[i]);
}
@ -1072,17 +1095,16 @@ namespace Avalonia.Media.TextFormatting
ResetTrailingWhitespaceBidiLevels(preSplitRuns, paragraphProperties.FlowDirection, objectPool);
}
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;
// SplitTextRuns has already computed the actual length of the first
// segment (the cluster boundary may land slightly off the requested
// length), so we just need to materialise the run array for TextLineImpl
// no second-pass length sum required.
var preSplitCount = preSplitRuns.Count;
var remainingTextRuns = new TextRun[preSplitCount];
for(var i = 0; i < preSplitRuns.Count; i++)
for (var i = 0; i < preSplitCount; i++)
{
var currentRun = preSplitRuns[i];
remainingTextRuns[i] = currentRun;
splitLength += currentRun.Length;
remainingTextRuns[i] = preSplitRuns[i];
}
var textLine = new TextLineImpl(remainingTextRuns, firstTextSourceIndex, splitLength,

352
src/Avalonia.Base/Media/TextFormatting/Unicode/LineBreakEnumerator.cs

@ -71,7 +71,7 @@ namespace Avalonia.Media.TextFormatting.Unicode
case LineBreakClass.CarriageReturn:
case LineBreakClass.LineFeed:
{
if (state.Previous.LineBreakClass == LineBreakClass.CarriageReturn)
if (state.PreviousClass == LineBreakClass.CarriageReturn)
{
positionMeasure = FindPriorNonWhitespace(text, state.Previous.Start);
}
@ -122,25 +122,65 @@ namespace Avalonia.Media.TextFormatting.Unicode
private static LineBreak? ExecuteRules(ReadOnlySpan<char> text, ref LineBreakState state)
{
foreach (var rule in s_rules)
{
var res = rule.Invoke(text, ref state);
switch (res)
{
case RuleResult.Pass:
continue;
case RuleResult.NoBreak:
return null;
case RuleResult.MayBreak:
case RuleResult.MustBreak:
return GetLineBreak(text, state, IsBreakClass(state.Current.LineBreakClass));
default:
throw new InvalidOperationException("Invalid state.");
}
// Rules are invoked directly (not through a delegate array) so the JIT can
// inline trivial early-exit rules and eliminate per-rule indirect-call
// overhead. Order is significant: LB21a must run before LB21.
RuleResult res;
if ((res = RegionalIndicator(text, ref state)) != RuleResult.Pass) goto Done;
if ((res = LB03(text, ref state)) != RuleResult.Pass) goto Done;
if ((res = LB04(text, ref state)) != RuleResult.Pass) goto Done;
if ((res = LB05(text, ref state)) != RuleResult.Pass) goto Done;
if ((res = LB06(text, ref state)) != RuleResult.Pass) goto Done;
if ((res = LB07(text, ref state)) != RuleResult.Pass) goto Done;
if ((res = LB08(text, ref state)) != RuleResult.Pass) goto Done;
if ((res = LB08a(text, ref state)) != RuleResult.Pass) goto Done;
if ((res = LB09(text, ref state)) != RuleResult.Pass) goto Done;
if ((res = LB10(text, ref state)) != RuleResult.Pass) goto Done;
if ((res = LB11(text, ref state)) != RuleResult.Pass) goto Done;
if ((res = LB12(text, ref state)) != RuleResult.Pass) goto Done;
if ((res = LB12a(text, ref state)) != RuleResult.Pass) goto Done;
if ((res = LB13(text, ref state)) != RuleResult.Pass) goto Done;
if ((res = LB14(text, ref state)) != RuleResult.Pass) goto Done;
if ((res = LB15a(text, ref state)) != RuleResult.Pass) goto Done;
if ((res = LB15b(text, ref state)) != RuleResult.Pass) goto Done;
if ((res = LB15c(text, ref state)) != RuleResult.Pass) goto Done;
if ((res = LB15d(text, ref state)) != RuleResult.Pass) goto Done;
if ((res = LB16(text, ref state)) != RuleResult.Pass) goto Done;
if ((res = LB17(text, ref state)) != RuleResult.Pass) goto Done;
if ((res = LB18(text, ref state)) != RuleResult.Pass) goto Done;
if ((res = LB19(text, ref state)) != RuleResult.Pass) goto Done;
if ((res = LB20(text, ref state)) != RuleResult.Pass) goto Done;
if ((res = LB20a(text, ref state)) != RuleResult.Pass) goto Done;
if ((res = LB21a(text, ref state)) != RuleResult.Pass) goto Done;
if ((res = LB21(text, ref state)) != RuleResult.Pass) goto Done;
if ((res = LB21b(text, ref state)) != RuleResult.Pass) goto Done;
if ((res = LB22(text, ref state)) != RuleResult.Pass) goto Done;
if ((res = LB23(text, ref state)) != RuleResult.Pass) goto Done;
if ((res = LB23a(text, ref state)) != RuleResult.Pass) goto Done;
if ((res = LB24(text, ref state)) != RuleResult.Pass) goto Done;
if ((res = LB25(text, ref state)) != RuleResult.Pass) goto Done;
if ((res = LB26(text, ref state)) != RuleResult.Pass) goto Done;
if ((res = LB27(text, ref state)) != RuleResult.Pass) goto Done;
if ((res = LB28(text, ref state)) != RuleResult.Pass) goto Done;
if ((res = LB28a(text, ref state)) != RuleResult.Pass) goto Done;
if ((res = LB29(text, ref state)) != RuleResult.Pass) goto Done;
if ((res = LB30(text, ref state)) != RuleResult.Pass) goto Done;
if ((res = LB30a(text, ref state)) != RuleResult.Pass) goto Done;
if ((res = LB30b(text, ref state)) != RuleResult.Pass) goto Done;
res = LB31(text, ref state); // always returns MayBreak
Done:
switch (res)
{
case RuleResult.NoBreak:
return null;
case RuleResult.MayBreak:
case RuleResult.MustBreak:
return GetLineBreak(text, state, IsBreakClass(state.Current.LineBreakClass));
default:
return null;
}
return null;
}
private static RuleResult RegionalIndicator(ReadOnlySpan<char> text, ref LineBreakState state)
@ -164,6 +204,7 @@ namespace Avalonia.Media.TextFormatting.Unicode
/// <summary>
/// LB3: Always break at the end of text.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static RuleResult LB03(ReadOnlySpan<char> text, ref LineBreakState state)
{
if (state.Current.EndOfText)
@ -177,6 +218,7 @@ namespace Avalonia.Media.TextFormatting.Unicode
/// <summary>
/// LB4: Always break after hard line breaks.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static RuleResult LB04(ReadOnlySpan<char> text, ref LineBreakState state)
{
// BK !
@ -196,7 +238,7 @@ namespace Avalonia.Media.TextFormatting.Unicode
switch (state.Current.LineBreakClass)
{
case LineBreakClass.CarriageReturn:
if (state.Next(text).LineBreakClass == LineBreakClass.LineFeed)
if (state.NextClass == LineBreakClass.LineFeed)
{
return RuleResult.NoBreak; // CR × LF
}
@ -214,11 +256,11 @@ namespace Avalonia.Media.TextFormatting.Unicode
/// <summary>
/// LB6: Do not break before hard line breaks.
/// </summary>
/// <returns></returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static RuleResult LB06(ReadOnlySpan<char> text, ref LineBreakState state)
{
// × ( BK | CR | LF | NL )
if (IsBreakClass(state.Next(text).LineBreakClass))
if (IsBreakClass(state.NextClass))
{
return RuleResult.NoBreak;
}
@ -229,11 +271,12 @@ namespace Avalonia.Media.TextFormatting.Unicode
/// <summary>
/// LB7: Do not break before spaces or zero width space.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static RuleResult LB07(ReadOnlySpan<char> text, ref LineBreakState state)
{
// × SP
// × ZW
switch (state.Next(text).LineBreakClass)
switch (state.NextClass)
{
case LineBreakClass.Space:
case LineBreakClass.ZWSpace:
@ -251,7 +294,7 @@ namespace Avalonia.Media.TextFormatting.Unicode
/// </summary>
private static RuleResult LB08(ReadOnlySpan<char> text, ref LineBreakState state)
{
if (state.LastBeforeSpace.LineBreakClass == LineBreakClass.ZWSpace && state.Next(text).LineBreakClass != LineBreakClass.Space)
if (state.LastBeforeSpace.LineBreakClass == LineBreakClass.ZWSpace && state.NextClass != LineBreakClass.Space)
{
return RuleResult.MayBreak;
}
@ -262,6 +305,7 @@ namespace Avalonia.Media.TextFormatting.Unicode
/// <summary>
/// LB8a: Do not break after a zero width joiner.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static RuleResult LB08a(ReadOnlySpan<char> text, ref LineBreakState state)
{
// ZWJ ×
@ -290,7 +334,7 @@ namespace Avalonia.Media.TextFormatting.Unicode
return RuleResult.Pass;
}
switch (state.Next(text).LineBreakClass)
switch (state.NextClass)
{
case LineBreakClass.CombiningMark:
case LineBreakClass.ZWJ:
@ -326,9 +370,10 @@ namespace Avalonia.Media.TextFormatting.Unicode
/// <summary>
/// LB11: Do not break before or after Word joiner and related characters.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static RuleResult LB11(ReadOnlySpan<char> text, ref LineBreakState state)
{
if (state.Next(text).LineBreakClass == LineBreakClass.WordJoiner /* × WJ */
if (state.NextClass == LineBreakClass.WordJoiner /* × WJ */
|| state.Current.LineBreakClass == LineBreakClass.WordJoiner /* WJ × */)
{
return RuleResult.NoBreak;
@ -340,6 +385,7 @@ namespace Avalonia.Media.TextFormatting.Unicode
/// <summary>
/// LB12: Do not break after NBSP and related characters.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static RuleResult LB12(ReadOnlySpan<char> text, ref LineBreakState state)
{
// GL ×
@ -357,7 +403,7 @@ namespace Avalonia.Media.TextFormatting.Unicode
private static RuleResult LB12a(ReadOnlySpan<char> text, ref LineBreakState state)
{
// [^SP BA HY] × GL
if (state.Next(text).LineBreakClass == LineBreakClass.Glue)
if (state.NextClass == LineBreakClass.Glue)
{
switch (state.Current.LineBreakClass)
{
@ -377,13 +423,14 @@ namespace Avalonia.Media.TextFormatting.Unicode
/// <summary>
/// LB13: Do not break before ‘]’ or ‘!’ or ‘;’ or ‘/’, even after spaces.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static RuleResult LB13(ReadOnlySpan<char> text, ref LineBreakState state)
{
// × CL
// × CP
// × EX
// × SY
switch (state.Next(text).LineBreakClass)
switch (state.NextClass)
{
case LineBreakClass.ClosePunctuation:
case LineBreakClass.CloseParenthesis:
@ -398,6 +445,7 @@ namespace Avalonia.Media.TextFormatting.Unicode
/// <summary>
/// LB14: Do not break after ‘[’, even after spaces.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static RuleResult LB14(ReadOnlySpan<char> text, ref LineBreakState state)
{
// OP SP* ×
@ -455,7 +503,7 @@ namespace Avalonia.Media.TextFormatting.Unicode
private static RuleResult LB15b(ReadOnlySpan<char> text, ref LineBreakState state)
{
// × [\p{Pf}&QU] ( SP | GL | WJ | CL | QU | CP | EX | IS | SY | BK | CR | LF | NL | ZW | eot)
if (state.Next(text).Codepoint.GeneralCategory == GeneralCategory.FinalPunctuation && (state.Next(text).LineBreakClass == LineBreakClass.Quotation))
if (state.Next(text).Codepoint.GeneralCategory == GeneralCategory.FinalPunctuation && (state.NextClass == LineBreakClass.Quotation))
{
var after = LineBreakState.After(text, state.Next(text));
@ -496,7 +544,7 @@ namespace Avalonia.Media.TextFormatting.Unicode
// SP ÷ IS NU
if (state.Current.LineBreakClass == LineBreakClass.Space)
{
switch (state.Next(text).LineBreakClass)
switch (state.NextClass)
{
case LineBreakClass.InfixNumeric when LineBreakState.After(text, state.Next(text)).LineBreakClass == LineBreakClass.Numeric:
{
@ -511,10 +559,11 @@ namespace Avalonia.Media.TextFormatting.Unicode
/// <summary>
/// LB15d: Otherwise, do not break before ‘;’, ‘,’, or ‘.’, even after spaces.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static RuleResult LB15d(ReadOnlySpan<char> text, ref LineBreakState state)
{
// × IS
if (state.Next(text).LineBreakClass == LineBreakClass.InfixNumeric)
if (state.NextClass == LineBreakClass.InfixNumeric)
{
return RuleResult.NoBreak;
}
@ -572,6 +621,7 @@ namespace Avalonia.Media.TextFormatting.Unicode
/// <summary>
/// LB18: Break after spaces.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static RuleResult LB18(ReadOnlySpan<char> text, ref LineBreakState state)
{
// SP ÷
@ -634,11 +684,12 @@ namespace Avalonia.Media.TextFormatting.Unicode
/// <summary>
/// LB20: Break before and after unresolved CB.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static RuleResult LB20(ReadOnlySpan<char> text, ref LineBreakState state)
{
// ÷ CB
// CB ÷
if ((state.Current.LineBreakClass == LineBreakClass.ContingentBreak) || (state.Next(text).LineBreakClass == LineBreakClass.ContingentBreak))
if ((state.Current.LineBreakClass == LineBreakClass.ContingentBreak) || (state.NextClass == LineBreakClass.ContingentBreak))
{
return RuleResult.MayBreak;
}
@ -655,7 +706,7 @@ namespace Avalonia.Media.TextFormatting.Unicode
var previous = state.Current.Inherited ? LineBreakState.Before(text, current) : state.Previous;
// (sot | BK | CR | LF | NL | SP | ZW | CB | GL)(HY | HH) × (AL | HL)
if (IsMatch(previous) && state.Next(text).LineBreakClass is LineBreakClass.Alphabetic or LineBreakClass.HebrewLetter)
if (IsMatch(previous) && state.NextClass is LineBreakClass.Alphabetic or LineBreakClass.HebrewLetter)
{
if (current.LineBreakClass is LineBreakClass.Hyphen or LineBreakClass.UnambiguousHyphen)
{
@ -696,7 +747,7 @@ namespace Avalonia.Media.TextFormatting.Unicode
private static RuleResult LB21(ReadOnlySpan<char> text, ref LineBreakState state)
{
// × (BA | HY | NS)
switch (state.Next(text).LineBreakClass)
switch (state.NextClass)
{
// [21.01]
case LineBreakClass.BreakAfter:
@ -718,16 +769,15 @@ namespace Avalonia.Media.TextFormatting.Unicode
return RuleResult.Pass;
}
/// <summary>
/// LB21a: Don't break after Hebrew + Hyphen.
/// </summary>
private static RuleResult LB21a(ReadOnlySpan<char> text, ref LineBreakState state)
{
if(state.Next(text).LineBreakClass != LineBreakClass.HebrewLetter)
if(state.NextClass != LineBreakClass.HebrewLetter)
{
// [21.1] HL(HY|HH) × [^HL]
if (state.Previous.LineBreakClass == LineBreakClass.HebrewLetter
if (state.PreviousClass == LineBreakClass.HebrewLetter
&& state.Current.LineBreakClass is LineBreakClass.Hyphen or LineBreakClass.UnambiguousHyphen)
{
return RuleResult.NoBreak;
@ -740,10 +790,11 @@ namespace Avalonia.Media.TextFormatting.Unicode
/// <summary>
/// LB21b: Don’t break between Solidus and Hebrew letters.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static RuleResult LB21b(ReadOnlySpan<char> text, ref LineBreakState state)
{
// [21.2] SY × HL
if ((state.Current.LineBreakClass == LineBreakClass.BreakSymbols) && (state.Next(text).LineBreakClass == LineBreakClass.HebrewLetter))
if ((state.Current.LineBreakClass == LineBreakClass.BreakSymbols) && (state.NextClass == LineBreakClass.HebrewLetter))
{
return RuleResult.NoBreak;
}
@ -754,10 +805,11 @@ namespace Avalonia.Media.TextFormatting.Unicode
/// <summary>
/// LB22: Do not break before ellipses.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static RuleResult LB22(ReadOnlySpan<char> text, ref LineBreakState state)
{
// × IN
if (state.Next(text).LineBreakClass == LineBreakClass.Inseparable)
if (state.NextClass == LineBreakClass.Inseparable)
{
return RuleResult.NoBreak;
}
@ -776,7 +828,7 @@ namespace Avalonia.Media.TextFormatting.Unicode
case LineBreakClass.HebrewLetter:
{
// (AL | HL) × NU
if (state.Next(text).LineBreakClass == LineBreakClass.Numeric)
if (state.NextClass == LineBreakClass.Numeric)
{
return RuleResult.NoBreak;
}
@ -787,7 +839,7 @@ namespace Avalonia.Media.TextFormatting.Unicode
case LineBreakClass.Numeric:
{
// NU × (AL | HL)
if (state.Next(text).LineBreakClass is LineBreakClass.Alphabetic or LineBreakClass.HebrewLetter)
if (state.NextClass is LineBreakClass.Alphabetic or LineBreakClass.HebrewLetter)
{
return RuleResult.NoBreak;
}
@ -800,40 +852,35 @@ namespace Avalonia.Media.TextFormatting.Unicode
return RuleResult.Pass;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static bool IsIdEbEm(LineBreakClass cls)
=> cls == LineBreakClass.Ideographic || cls == LineBreakClass.EBase || cls == LineBreakClass.EModifier;
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static bool IsPrPo(LineBreakClass cls)
=> cls == LineBreakClass.PrefixNumeric || cls == LineBreakClass.PostfixNumeric;
/// <summary>
/// LB23a: Do not break between numeric prefixes and ideographs, or between
/// ideographs and numeric postfixes.
/// </summary>
private static RuleResult LB23a(ReadOnlySpan<char> text, ref LineBreakState state)
{
// PR × (ID | EB | EM)
if ((state.Current.LineBreakClass == LineBreakClass.PrefixNumeric)
&& IsMatch(state.Next(text).LineBreakClass))
var currentClass = state.Current.LineBreakClass;
var nextClass = state.NextClass;
// LB23a: PR × (ID | EB | EM)
if (currentClass == LineBreakClass.PrefixNumeric && IsIdEbEm(nextClass))
{
return RuleResult.NoBreak;
}
// (ID | EB | EM) × PO
if ((state.Next(text).LineBreakClass == LineBreakClass.PostfixNumeric)
&& IsMatch(state.Current.LineBreakClass))
// (ID | EB | EM) × PO
if (nextClass == LineBreakClass.PostfixNumeric && IsIdEbEm(currentClass))
{
return RuleResult.NoBreak;
}
return RuleResult.Pass;
static bool IsMatch(LineBreakClass cls)
{
switch (cls)
{
case LineBreakClass.Ideographic:
case LineBreakClass.EBase:
case LineBreakClass.EModifier:
return true;
}
return false;
}
}
/// <summary>
@ -842,15 +889,16 @@ namespace Avalonia.Media.TextFormatting.Unicode
/// </summary>
private static RuleResult LB24(ReadOnlySpan<char> text, ref LineBreakState state)
{
// (PR | PO) × (AL | HL)
if (state.Current.LineBreakClass is LineBreakClass.PrefixNumeric or LineBreakClass.PostfixNumeric
&& state.Next(text).LineBreakClass is LineBreakClass.Alphabetic or LineBreakClass.HebrewLetter)
var currentClass = state.Current.LineBreakClass;
var nextClass = state.NextClass;
// LB24: (PR | PO) × (AL | HL);
if (IsPrPo(currentClass) && (nextClass == LineBreakClass.Alphabetic || nextClass == LineBreakClass.HebrewLetter))
{
return RuleResult.NoBreak;
}
// (AL | HL) × (PR | PO)
if (state.Current.LineBreakClass is LineBreakClass.Alphabetic or LineBreakClass.HebrewLetter
&& state.Next(text).LineBreakClass is LineBreakClass.PrefixNumeric or LineBreakClass.PostfixNumeric)
if ((currentClass == LineBreakClass.Alphabetic || currentClass == LineBreakClass.HebrewLetter) && IsPrPo(nextClass))
{
return RuleResult.NoBreak;
}
@ -858,13 +906,12 @@ namespace Avalonia.Media.TextFormatting.Unicode
return RuleResult.Pass;
}
/// <summary>
/// LB25: Do not break between the following pairs of classes relevant to numbers
/// </summary>
private static RuleResult LB25(ReadOnlySpan<char> text, ref LineBreakState state)
{
switch (state.Next(text).LineBreakClass)
switch (state.NextClass)
{
// [25.06] NU(SY|IS)* x PR
case LineBreakClass.PrefixNumeric:
@ -874,7 +921,7 @@ namespace Avalonia.Media.TextFormatting.Unicode
// [25.04] NU(SY|IS)* CP × PR
case LineBreakClass.CloseParenthesis:
{
switch (state.Previous.LineBreakClass)
switch (state.PreviousClass)
{
case LineBreakClass.Numeric:
{
@ -903,7 +950,7 @@ namespace Avalonia.Media.TextFormatting.Unicode
case LineBreakClass.BreakSymbols:
case LineBreakClass.InfixNumeric:
{
if (state.Previous.LineBreakClass == LineBreakClass.Numeric)
if (state.PreviousClass == LineBreakClass.Numeric)
{
return RuleResult.NoBreak;
}
@ -913,7 +960,7 @@ namespace Avalonia.Media.TextFormatting.Unicode
// [25.03] NU(SY|IS)* CL × PR
case LineBreakClass.ClosePunctuation:
{
switch (state.Previous.LineBreakClass)
switch (state.PreviousClass)
{
case LineBreakClass.Numeric:
{
@ -922,7 +969,7 @@ namespace Avalonia.Media.TextFormatting.Unicode
case LineBreakClass.BreakSymbols:
case LineBreakClass.InfixNumeric:
{
if (state.Previous.LineBreakClass == LineBreakClass.Numeric)
if (state.PreviousClass == LineBreakClass.Numeric)
{
return RuleResult.NoBreak;
}
@ -949,7 +996,7 @@ namespace Avalonia.Media.TextFormatting.Unicode
case LineBreakClass.BreakSymbols:
case LineBreakClass.InfixNumeric:
{
if (state.Previous.LineBreakClass == LineBreakClass.Numeric)
if (state.PreviousClass == LineBreakClass.Numeric)
{
return RuleResult.NoBreak;
}
@ -968,7 +1015,7 @@ namespace Avalonia.Media.TextFormatting.Unicode
// [25.01] NU(SY|IS)* CL × PO
case LineBreakClass.ClosePunctuation:
{
switch (state.Previous.LineBreakClass)
switch (state.PreviousClass)
{
case LineBreakClass.Numeric:
{
@ -977,7 +1024,7 @@ namespace Avalonia.Media.TextFormatting.Unicode
case LineBreakClass.BreakSymbols:
case LineBreakClass.InfixNumeric:
{
if (state.Previous.LineBreakClass == LineBreakClass.Numeric)
if (state.PreviousClass == LineBreakClass.Numeric)
{
return RuleResult.NoBreak;
}
@ -996,7 +1043,7 @@ namespace Avalonia.Media.TextFormatting.Unicode
case LineBreakClass.BreakSymbols:
case LineBreakClass.InfixNumeric:
{
if (state.Previous.LineBreakClass == LineBreakClass.Numeric)
if (state.PreviousClass == LineBreakClass.Numeric)
{
return RuleResult.NoBreak;
}
@ -1011,7 +1058,7 @@ namespace Avalonia.Media.TextFormatting.Unicode
if (state.Current.LineBreakClass == LineBreakClass.PrefixNumeric)
{
switch (state.Next(text).LineBreakClass)
switch (state.NextClass)
{
case LineBreakClass.OpenPunctuation:
{
@ -1041,7 +1088,7 @@ namespace Avalonia.Media.TextFormatting.Unicode
if (state.Current.LineBreakClass == LineBreakClass.PostfixNumeric)
{
switch (state.Next(text).LineBreakClass)
switch (state.NextClass)
{
case LineBreakClass.OpenPunctuation:
{
@ -1076,7 +1123,7 @@ namespace Avalonia.Media.TextFormatting.Unicode
case LineBreakClass.Hyphen:
case LineBreakClass.InfixNumeric:
{
if (state.Next(text).LineBreakClass == LineBreakClass.Numeric)
if (state.NextClass == LineBreakClass.Numeric)
{
return RuleResult.NoBreak;
}
@ -1097,7 +1144,7 @@ namespace Avalonia.Media.TextFormatting.Unicode
case LineBreakClass.JL:
{
// JL × (JL | JV | H2 | H3)
switch (state.Next(text).LineBreakClass)
switch (state.NextClass)
{
case LineBreakClass.JL:
case LineBreakClass.JV:
@ -1112,7 +1159,7 @@ namespace Avalonia.Media.TextFormatting.Unicode
case LineBreakClass.H2:
{
// (JV | H2) × (JV | JT)
switch (state.Next(text).LineBreakClass)
switch (state.NextClass)
{
case LineBreakClass.JV:
case LineBreakClass.JT:
@ -1124,7 +1171,7 @@ namespace Avalonia.Media.TextFormatting.Unicode
case LineBreakClass.H3:
{
// (JT | H3) × JT
if (state.Next(text).LineBreakClass == LineBreakClass.JT)
if (state.NextClass == LineBreakClass.JT)
{
return RuleResult.NoBreak;
}
@ -1149,7 +1196,7 @@ namespace Avalonia.Media.TextFormatting.Unicode
case LineBreakClass.H3:
{
// (JL | JV | JT | H2 | H3) × PO
if (state.Next(text).LineBreakClass == LineBreakClass.PostfixNumeric)
if (state.NextClass == LineBreakClass.PostfixNumeric)
{
return RuleResult.NoBreak;
}
@ -1158,7 +1205,7 @@ namespace Avalonia.Media.TextFormatting.Unicode
case LineBreakClass.PrefixNumeric:
{
// PR × (JL | JV | JT | H2 | H3)
switch (state.Next(text).LineBreakClass)
switch (state.NextClass)
{
case LineBreakClass.JL:
case LineBreakClass.JV:
@ -1186,7 +1233,7 @@ namespace Avalonia.Media.TextFormatting.Unicode
case LineBreakClass.Alphabetic:
case LineBreakClass.HebrewLetter:
{
switch (state.Next(text).LineBreakClass)
switch (state.NextClass)
{
case LineBreakClass.Alphabetic:
case LineBreakClass.HebrewLetter:
@ -1218,7 +1265,7 @@ namespace Avalonia.Media.TextFormatting.Unicode
// [28.12] (AK | DottedCircle | AS) × (VF | VI)
if (isMatch(current)
&& ((state.Next(text).LineBreakClass == LineBreakClass.ViramaFinal) || (state.Next(text).LineBreakClass == LineBreakClass.Virama)))
&& ((state.NextClass == LineBreakClass.ViramaFinal) || (state.NextClass == LineBreakClass.Virama)))
{
return RuleResult.NoBreak;
}
@ -1226,7 +1273,7 @@ namespace Avalonia.Media.TextFormatting.Unicode
// [28.13] (AK | DottedCircle| AS) VI × (AK | DottedCircle)
if (isMatch(previous)
&& current.LineBreakClass == LineBreakClass.Virama
&& ((state.Next(text).LineBreakClass == LineBreakClass.Aksara) || (state.Next(text).Codepoint == DotCircle)))
&& ((state.NextClass == LineBreakClass.Aksara) || (state.Next(text).Codepoint == DotCircle)))
{
return RuleResult.NoBreak;
}
@ -1251,11 +1298,12 @@ namespace Avalonia.Media.TextFormatting.Unicode
/// <summary>
/// LB29: Do not break between numeric punctuation and alphabetics (“e.g.”).
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static RuleResult LB29(ReadOnlySpan<char> text, ref LineBreakState state)
{
// IS × (AL | HL)
if ((state.Current.LineBreakClass == LineBreakClass.InfixNumeric)
&& ((state.Next(text).LineBreakClass == LineBreakClass.Alphabetic) || (state.Next(text).LineBreakClass == LineBreakClass.HebrewLetter)))
&& ((state.NextClass == LineBreakClass.Alphabetic) || (state.NextClass == LineBreakClass.HebrewLetter)))
{
return RuleResult.NoBreak;
}
@ -1288,7 +1336,7 @@ namespace Avalonia.Media.TextFormatting.Unicode
// [CP-[\p{ea=F}\p{ea=W}\p{ea=H}]] × (AL | HL | NU)
if (!state.Current.Codepoint.IsEastAsian)
{
switch (state.Next(text).LineBreakClass)
switch (state.NextClass)
{
case LineBreakClass.Alphabetic:
case LineBreakClass.HebrewLetter:
@ -1313,7 +1361,7 @@ namespace Avalonia.Media.TextFormatting.Unicode
/// <returns></returns>
private static RuleResult LB30a(ReadOnlySpan<char> text, ref LineBreakState state)
{
if (state.RegionalIndicator > 0 && state.Next(text).LineBreakClass == LineBreakClass.RegionalIndicator)
if (state.RegionalIndicator > 0 && state.NextClass == LineBreakClass.RegionalIndicator)
{
if (state.RegionalIndicator + 1 == 2)
{
@ -1333,7 +1381,7 @@ namespace Avalonia.Media.TextFormatting.Unicode
var current = state.Current.Inherited ? state.Previous : state.Current;
// EB × EM
if ((current.LineBreakClass == LineBreakClass.EBase) && (state.Next(text).LineBreakClass == LineBreakClass.EModifier))
if ((current.LineBreakClass == LineBreakClass.EBase) && (state.NextClass == LineBreakClass.EModifier))
{
return RuleResult.NoBreak;
}
@ -1346,7 +1394,7 @@ namespace Avalonia.Media.TextFormatting.Unicode
// codepoints with Line_Break=ID in some blocks are also assigned the
// Extended_Pictographic property. Those blocks are intended for future
// allocation of emoji characters.
if (state.Next(text).LineBreakClass == LineBreakClass.EModifier &&
if (state.NextClass == LineBreakClass.EModifier &&
current.Codepoint.GraphemeBreakClass == GraphemeBreakClass.ExtendedPictographic &&
current.Codepoint.GeneralCategory == GeneralCategory.Unassigned)
{
@ -1359,6 +1407,7 @@ namespace Avalonia.Media.TextFormatting.Unicode
/// <summary>
/// LB31: Break everywhere else.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static RuleResult LB31(ReadOnlySpan<char> text, ref LineBreakState state)
{
return RuleResult.MayBreak;
@ -1413,11 +1462,6 @@ namespace Avalonia.Media.TextFormatting.Unicode
return LineBreakClass.Alphabetic;
}
if (cp.Value == 327685)
{
return LineBreakClass.Alphabetic;
}
// LB 1
// ==========================================
// Resolved Original General_Category
@ -1460,14 +1504,25 @@ namespace Avalonia.Media.TextFormatting.Unicode
private ref struct LineBreakState
{
private BreakUnit? _next;
// _next is resolved lazily on first Read; subsequent Reads eagerly
// pre-peek so that NextClass is a plain field load inside every rule
// (avoiding the 28-byte BreakUnit copy that state.NextClass
// would otherwise incur per call).
private BreakUnit _next;
private bool _hasNext;
private LineBreakClass _nextClass;
private BreakUnit _previous;
private LineBreakClass _previousClass;
public LineBreakState()
{
_next = null;
_hasNext = false;
_next = default;
_nextClass = LineBreakClass.Unknown;
_previous = s_sot;
_previousClass = s_sot.LineBreakClass;
Current = s_sot;
LastBeforeSpace = s_sot;
LastBeforeWhitespace = s_sot;
@ -1475,6 +1530,24 @@ namespace Avalonia.Media.TextFormatting.Unicode
public BreakUnit Current { get; set; }
/// <summary>
/// Cached LineBreakClass of the previous BreakUnit. Updated whenever
/// <see cref="Previous"/> would be reassigned (in <see cref="Read"/>
/// or via the Ignored/Inherited fall-through in the getter).
/// </summary>
public LineBreakClass PreviousClass
{
get
{
if (_previous.Ignored || _previous.Inherited)
{
_previous = LastBeforeWhitespace;
_previousClass = LastBeforeWhitespace.LineBreakClass;
}
return _previousClass;
}
}
public BreakUnit Previous
{
get
@ -1482,6 +1555,7 @@ namespace Avalonia.Media.TextFormatting.Unicode
if (_previous.Ignored || _previous.Inherited)
{
_previous = LastBeforeWhitespace;
_previousClass = LastBeforeWhitespace.LineBreakClass;
}
return _previous;
@ -1490,9 +1564,23 @@ namespace Avalonia.Media.TextFormatting.Unicode
public BreakUnit Next(ReadOnlySpan<char> text)
{
return _next ??= Peek(text);
if (!_hasNext)
{
_next = Peek(text);
_nextClass = _next.LineBreakClass;
_hasNext = true;
}
return _next;
}
/// <summary>
/// Cached LineBreakClass of the next BreakUnit. Faster than
/// <c>state.NextClass</c> because it avoids the
/// BreakUnit struct copy. Read is responsible for ensuring _hasNext
/// is set before any rule sees this property.
/// </summary>
public LineBreakClass NextClass => _nextClass;
public static BreakUnit After(ReadOnlySpan<char> text, BreakUnit current)
{
if (current.EndOfText)
@ -1519,12 +1607,17 @@ namespace Avalonia.Media.TextFormatting.Unicode
public void IgnoreNext(ReadOnlySpan<char> text)
{
_next = Next(text) with { Ignored = true };
var n = Next(text) with { Ignored = true };
_next = n;
_nextClass = n.LineBreakClass;
_hasNext = true;
}
public void ReplaceNext(BreakUnit next)
{
_next = next;
_next = next;
_nextClass = next.LineBreakClass;
_hasNext = true;
}
public int Position { get; private set; }
@ -1568,6 +1661,7 @@ namespace Avalonia.Media.TextFormatting.Unicode
public BreakUnit Read(ReadOnlySpan<char> text)
{
_previous = Current;
_previousClass = Current.LineBreakClass;
var next = Next(text);
@ -1575,12 +1669,17 @@ namespace Avalonia.Media.TextFormatting.Unicode
Position += next.Length;
_next = null;
// Eagerly peek the new "next" so the cached _nextClass is valid
// for every rule in the upcoming ExecuteRules pass.
_next = Peek(text);
_nextClass = _next.LineBreakClass;
_hasNext = true;
// LB9 ignored marks do not become the prior item for the next real boundary.
if (_previous.Ignored || _previous.Inherited)
{
_previous = LastBeforeWhitespace;
_previousClass = LastBeforeWhitespace.LineBreakClass;
}
if (Current.Ignored)
@ -1590,8 +1689,6 @@ namespace Avalonia.Media.TextFormatting.Unicode
var current = Current.Inherited ? Previous : Current;
// LB9 folds ignored marks into the prior base, so later rules
// that inspect general category still need the base codepoint.
if (!Current.Codepoint.IsWhiteSpace)
{
LastBeforeWhitespace = current;
@ -1627,50 +1724,5 @@ namespace Avalonia.Media.TextFormatting.Unicode
}
private delegate RuleResult BreakUnitDelegate(ReadOnlySpan<char> text, ref LineBreakState state);
private static readonly BreakUnitDelegate[] s_rules = [
RegionalIndicator,
LB03,
LB04,
LB05,
LB06,
LB07,
LB08,
LB08a,
LB09,
LB10,
LB11,
LB12,
LB12a,
LB13,
LB14,
LB15a,
LB15b,
LB15c,
LB15d,
LB16,
LB17,
LB18,
LB19,
LB20,
LB20a,
LB21a, // Must be before LB21
LB21,
LB21b,
LB22,
LB23,
LB23a,
LB24,
LB25,
LB26,
LB27,
LB28,
LB28a,
LB29,
LB30,
LB30a,
LB30b,
LB31,
];
}
}

74
src/Avalonia.Base/Media/TextFormatting/Unicode/LineBreakPairTable.cs

@ -1,74 +0,0 @@
// Copyright (c) Six Labors.
// Licensed under the Apache License, Version 2.0.
// Ported from: https://github.com/SixLabors/Fonts/
namespace Avalonia.Media.TextFormatting.Unicode
{
internal static class LineBreakPairTable
{
/// <summary>
/// Direct break opportunity
/// </summary>
public const byte DIBRK = 0;
/// <summary>
/// Indirect break opportunity
/// </summary>
public const byte INBRK = 1;
/// <summary>
/// Indirect break opportunity for combining marks
/// </summary>
public const byte CIBRK = 2;
/// <summary>
/// Prohibited break for combining marks
/// </summary>
public const byte CPBRK = 3;
/// <summary>
/// Prohibited break
/// </summary>
public const byte PRBRK = 4;
// Based on example pair table from https://www.unicode.org/reports/tr14/tr14-37.html#Table2
// - ZWJ special processing for LB8a
// - CB manually added as per Rule LB20
public static byte[][] Table { get; } = {
// . OP CL CP QU GL NS EX SY IS PR PO NU AL HL ID IN HY BA BB B2 ZW CM WJ H2 H3 JL JV JT RI EB EM ZWJ CB
new[] { PRBRK, PRBRK, PRBRK, PRBRK, PRBRK, PRBRK, PRBRK, PRBRK, PRBRK, PRBRK, PRBRK, PRBRK, PRBRK, PRBRK, PRBRK, PRBRK, PRBRK, PRBRK, PRBRK, PRBRK, PRBRK, CPBRK, PRBRK, PRBRK, PRBRK, PRBRK, PRBRK, PRBRK, PRBRK, PRBRK, PRBRK, PRBRK, PRBRK }, // OP
new[] { DIBRK, PRBRK, PRBRK, INBRK, INBRK, PRBRK, PRBRK, PRBRK, PRBRK, INBRK, INBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, INBRK, INBRK, DIBRK, DIBRK, PRBRK, CIBRK, PRBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, INBRK, DIBRK }, // CL
new[] { DIBRK, PRBRK, PRBRK, INBRK, INBRK, PRBRK, PRBRK, PRBRK, PRBRK, INBRK, INBRK, INBRK, INBRK, INBRK, DIBRK, DIBRK, INBRK, INBRK, DIBRK, DIBRK, PRBRK, CIBRK, PRBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, INBRK, DIBRK }, // CP
new[] { PRBRK, PRBRK, PRBRK, INBRK, INBRK, INBRK, PRBRK, PRBRK, PRBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK, PRBRK, CIBRK, PRBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK }, // QU
new[] { INBRK, PRBRK, PRBRK, INBRK, INBRK, INBRK, PRBRK, PRBRK, PRBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK, PRBRK, CIBRK, PRBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK }, // GL
new[] { DIBRK, PRBRK, PRBRK, INBRK, INBRK, INBRK, PRBRK, PRBRK, PRBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, INBRK, INBRK, DIBRK, DIBRK, PRBRK, CIBRK, PRBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, INBRK, DIBRK }, // NS
new[] { DIBRK, PRBRK, PRBRK, INBRK, INBRK, INBRK, PRBRK, PRBRK, PRBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, INBRK, INBRK, INBRK, DIBRK, DIBRK, PRBRK, CIBRK, PRBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, INBRK, DIBRK }, // EX
new[] { DIBRK, PRBRK, PRBRK, INBRK, INBRK, INBRK, PRBRK, PRBRK, PRBRK, DIBRK, DIBRK, INBRK, DIBRK, INBRK, DIBRK, DIBRK, INBRK, INBRK, DIBRK, DIBRK, PRBRK, CIBRK, PRBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, INBRK, DIBRK }, // SY
new[] { DIBRK, PRBRK, PRBRK, INBRK, INBRK, INBRK, PRBRK, PRBRK, PRBRK, DIBRK, DIBRK, INBRK, INBRK, INBRK, DIBRK, DIBRK, INBRK, INBRK, DIBRK, DIBRK, PRBRK, CIBRK, PRBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, INBRK, DIBRK }, // IS
new[] { INBRK, PRBRK, PRBRK, INBRK, INBRK, INBRK, PRBRK, PRBRK, PRBRK, DIBRK, DIBRK, INBRK, INBRK, INBRK, INBRK, DIBRK, INBRK, INBRK, DIBRK, DIBRK, PRBRK, CIBRK, PRBRK, INBRK, INBRK, INBRK, INBRK, INBRK, DIBRK, INBRK, INBRK, INBRK, DIBRK }, // PR
new[] { INBRK, PRBRK, PRBRK, INBRK, INBRK, INBRK, PRBRK, PRBRK, PRBRK, DIBRK, DIBRK, INBRK, INBRK, INBRK, DIBRK, DIBRK, INBRK, INBRK, DIBRK, DIBRK, PRBRK, CIBRK, PRBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, INBRK, DIBRK }, // PO
new[] { INBRK, PRBRK, PRBRK, INBRK, INBRK, INBRK, PRBRK, PRBRK, PRBRK, INBRK, INBRK, INBRK, INBRK, INBRK, DIBRK, INBRK, INBRK, INBRK, DIBRK, DIBRK, PRBRK, CIBRK, PRBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, INBRK, DIBRK }, // NU
new[] { INBRK, PRBRK, PRBRK, INBRK, INBRK, INBRK, PRBRK, PRBRK, PRBRK, INBRK, INBRK, INBRK, INBRK, INBRK, DIBRK, INBRK, INBRK, INBRK, DIBRK, DIBRK, PRBRK, CIBRK, PRBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, INBRK, DIBRK }, // AL
new[] { INBRK, PRBRK, PRBRK, INBRK, INBRK, INBRK, PRBRK, PRBRK, PRBRK, INBRK, INBRK, INBRK, INBRK, INBRK, DIBRK, INBRK, INBRK, INBRK, DIBRK, DIBRK, PRBRK, CIBRK, PRBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, INBRK, DIBRK }, // HL
new[] { DIBRK, PRBRK, PRBRK, INBRK, INBRK, INBRK, PRBRK, PRBRK, PRBRK, DIBRK, INBRK, DIBRK, DIBRK, DIBRK, DIBRK, INBRK, INBRK, INBRK, DIBRK, DIBRK, PRBRK, CIBRK, PRBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, INBRK, DIBRK }, // ID
new[] { DIBRK, PRBRK, PRBRK, INBRK, INBRK, INBRK, PRBRK, PRBRK, PRBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, INBRK, INBRK, INBRK, DIBRK, DIBRK, PRBRK, CIBRK, PRBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, INBRK, DIBRK }, // IN
new[] { DIBRK, PRBRK, PRBRK, INBRK, DIBRK, INBRK, PRBRK, PRBRK, PRBRK, DIBRK, DIBRK, INBRK, DIBRK, DIBRK, DIBRK, DIBRK, INBRK, INBRK, DIBRK, DIBRK, PRBRK, CIBRK, PRBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, INBRK, DIBRK }, // HY
new[] { DIBRK, PRBRK, PRBRK, INBRK, DIBRK, INBRK, PRBRK, PRBRK, PRBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, INBRK, INBRK, DIBRK, DIBRK, PRBRK, CIBRK, PRBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, INBRK, DIBRK }, // BA
new[] { INBRK, PRBRK, PRBRK, INBRK, INBRK, INBRK, PRBRK, PRBRK, PRBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK, PRBRK, CIBRK, PRBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK, DIBRK }, // BB
new[] { DIBRK, PRBRK, PRBRK, INBRK, INBRK, INBRK, PRBRK, PRBRK, PRBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, INBRK, INBRK, DIBRK, PRBRK, PRBRK, CIBRK, PRBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, INBRK, DIBRK }, // B2
new[] { DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, PRBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK }, // ZW
new[] { INBRK, PRBRK, PRBRK, INBRK, INBRK, INBRK, PRBRK, PRBRK, PRBRK, INBRK, INBRK, INBRK, INBRK, INBRK, DIBRK, INBRK, INBRK, INBRK, DIBRK, DIBRK, PRBRK, CIBRK, PRBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, INBRK, DIBRK }, // CM
new[] { INBRK, PRBRK, PRBRK, INBRK, INBRK, INBRK, PRBRK, PRBRK, PRBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK, PRBRK, CIBRK, PRBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK }, // WJ
new[] { DIBRK, PRBRK, PRBRK, INBRK, INBRK, INBRK, PRBRK, PRBRK, PRBRK, DIBRK, INBRK, DIBRK, DIBRK, DIBRK, DIBRK, INBRK, INBRK, INBRK, DIBRK, DIBRK, PRBRK, CIBRK, PRBRK, DIBRK, DIBRK, DIBRK, INBRK, INBRK, DIBRK, DIBRK, DIBRK, INBRK, DIBRK }, // H2
new[] { DIBRK, PRBRK, PRBRK, INBRK, INBRK, INBRK, PRBRK, PRBRK, PRBRK, DIBRK, INBRK, DIBRK, DIBRK, DIBRK, DIBRK, INBRK, INBRK, INBRK, DIBRK, DIBRK, PRBRK, CIBRK, PRBRK, DIBRK, DIBRK, DIBRK, DIBRK, INBRK, DIBRK, DIBRK, DIBRK, INBRK, DIBRK }, // H3
new[] { DIBRK, PRBRK, PRBRK, INBRK, INBRK, INBRK, PRBRK, PRBRK, PRBRK, DIBRK, INBRK, DIBRK, DIBRK, DIBRK, DIBRK, INBRK, INBRK, INBRK, DIBRK, DIBRK, PRBRK, CIBRK, PRBRK, INBRK, INBRK, INBRK, INBRK, DIBRK, DIBRK, DIBRK, DIBRK, INBRK, DIBRK }, // JL
new[] { DIBRK, PRBRK, PRBRK, INBRK, INBRK, INBRK, PRBRK, PRBRK, PRBRK, DIBRK, INBRK, DIBRK, DIBRK, DIBRK, DIBRK, INBRK, INBRK, INBRK, DIBRK, DIBRK, PRBRK, CIBRK, PRBRK, DIBRK, DIBRK, DIBRK, INBRK, INBRK, DIBRK, DIBRK, DIBRK, INBRK, DIBRK }, // JV
new[] { DIBRK, PRBRK, PRBRK, INBRK, INBRK, INBRK, PRBRK, PRBRK, PRBRK, DIBRK, INBRK, DIBRK, DIBRK, DIBRK, DIBRK, INBRK, INBRK, INBRK, DIBRK, DIBRK, PRBRK, CIBRK, PRBRK, DIBRK, DIBRK, DIBRK, DIBRK, INBRK, DIBRK, DIBRK, DIBRK, INBRK, DIBRK }, // JT
new[] { DIBRK, PRBRK, PRBRK, INBRK, INBRK, INBRK, PRBRK, PRBRK, PRBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, INBRK, INBRK, DIBRK, DIBRK, PRBRK, CIBRK, PRBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, INBRK, DIBRK, DIBRK, INBRK, DIBRK }, // RI
new[] { DIBRK, PRBRK, PRBRK, INBRK, INBRK, INBRK, PRBRK, PRBRK, PRBRK, DIBRK, INBRK, DIBRK, DIBRK, DIBRK, DIBRK, INBRK, INBRK, INBRK, DIBRK, DIBRK, PRBRK, CIBRK, PRBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, INBRK, INBRK, DIBRK }, // EB
new[] { DIBRK, PRBRK, PRBRK, INBRK, INBRK, INBRK, PRBRK, PRBRK, PRBRK, DIBRK, INBRK, DIBRK, DIBRK, DIBRK, DIBRK, INBRK, INBRK, INBRK, DIBRK, DIBRK, PRBRK, CIBRK, PRBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, INBRK, DIBRK }, // EM
new[] { INBRK, PRBRK, PRBRK, INBRK, INBRK, INBRK, PRBRK, PRBRK, PRBRK, INBRK, INBRK, INBRK, INBRK, INBRK, DIBRK, INBRK, INBRK, INBRK, DIBRK, DIBRK, PRBRK, CIBRK, PRBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, INBRK, DIBRK }, // ZWJ
new[] { DIBRK, PRBRK, PRBRK, INBRK, INBRK, DIBRK, PRBRK, PRBRK, PRBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, PRBRK, CIBRK, PRBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, INBRK, DIBRK } // CB
};
}
}

56
src/Skia/Avalonia.Skia/GlyphRunImpl.cs

@ -1,7 +1,6 @@
using System;
using System.Buffers;
using System.Collections.Generic;
using System.Threading;
using Avalonia.Media;
using Avalonia.Media.TextFormatting;
using Avalonia.Platform;
@ -39,18 +38,14 @@ namespace Avalonia.Skia
_glyphIndices = new ushort[count];
_glyphPositions = new SKPoint[count];
var currentX = 0.0;
// GetGlyphWidths needs _glyphIndices populated before the
// per-glyph bounds can be fetched, so this walk has to come
// first. It deliberately does no other work — positions and
// runBounds are built together in the fused walk below, using
// a single currentX accumulator.
for (int i = 0; i < count; i++)
{
var glyphInfo = glyphInfos[i];
var offset = glyphInfo.GlyphOffset;
_glyphIndices[i] = glyphInfo.GlyphIndex;
_glyphPositions[i] = new SKPoint((float)(currentX + offset.X), (float)offset.Y);
currentX += glyphInfos[i].GlyphAdvance;
_glyphIndices[i] = glyphInfos[i].GlyphIndex;
}
// Ideally the requested edging should be passed to the glyph run.
@ -67,26 +62,39 @@ namespace Avalonia.Skia
using var font = CreateFont(defaultTextOptions);
var runBounds = new Rect();
var glyphBounds = ArrayPool<SKRect>.Shared.Rent(count);
font.GetGlyphWidths(_glyphIndices, null, glyphBounds.AsSpan(0, count));
try
{
font.GetGlyphWidths(_glyphIndices, null, glyphBounds.AsSpan(0, count));
currentX = 0;
// build _glyphPositions and union runBounds in a
// single pass. Replaces the previous two separate walks (each
// maintaining its own currentX) each glyphInfo is read once,
// and one accumulator covers both outputs.
var currentX = 0.0;
var runBounds = new Rect();
for (var i = 0; i < count; i++)
{
var gBounds = glyphBounds[i];
var advance = glyphInfos[i].GlyphAdvance;
for (int i = 0; i < count; i++)
{
var glyphInfo = glyphInfos[i];
var offset = glyphInfo.GlyphOffset;
var gBounds = glyphBounds[i];
runBounds = runBounds.Union(new Rect(currentX + gBounds.Left, gBounds.Top, gBounds.Width, gBounds.Height));
_glyphPositions[i] = new SKPoint((float)(currentX + offset.X), (float)offset.Y);
currentX += advance;
}
ArrayPool<SKRect>.Shared.Return(glyphBounds);
runBounds = runBounds.Union(new Rect(currentX + gBounds.Left, gBounds.Top, gBounds.Width, gBounds.Height));
currentX += glyphInfo.GlyphAdvance;
}
BaselineOrigin = baselineOrigin;
Bounds = runBounds.Translate(new Vector(baselineOrigin.X, baselineOrigin.Y));
BaselineOrigin = baselineOrigin;
Bounds = runBounds.Translate(new Vector(baselineOrigin.X, baselineOrigin.Y));
}
finally
{
ArrayPool<SKRect>.Shared.Return(glyphBounds);
}
}
public double FontRenderingEmSize { get; }

262
tests/Avalonia.Base.UnitTests/Media/TextFormatting/CodepointTests.cs

@ -0,0 +1,262 @@
using System;
using System.Collections.Generic;
using Avalonia.Media.TextFormatting.Unicode;
using Xunit;
namespace Avalonia.Base.UnitTests.Media.TextFormatting;
/// <summary>
/// Direct coverage for <see cref="Codepoint"/> — surrogate-pair decoding via
/// <see cref="Codepoint.ReadAt(ReadOnlySpan{char}, int, out int)"/>, the small
/// helper properties / methods, and the bitmask-based <c>IsWhiteSpace</c> path
/// that depends on every used <see cref="GeneralCategory"/> value fitting in 64
/// bits.
/// </summary>
public class CodepointTests
{
[Theory]
[InlineData("a", 0, (uint)'a', 1)]
[InlineData("abc", 1, (uint)'b', 1)]
[InlineData("abc", 2, (uint)'c', 1)]
public void ReadAt_BmpScalar_ReturnsCharAndAdvancesByOne(string text, int index, uint expectedValue, int expectedCount)
{
var cp = Codepoint.ReadAt(text.AsSpan(), index, out var count);
Assert.Equal(expectedValue, cp.Value);
Assert.Equal(expectedCount, count);
}
[Fact]
public void ReadAt_HighSurrogate_AtStart_DecodesPair()
{
// U+1F600 GRINNING FACE — high surrogate at index 0.
const string text = "😀";
var cp = Codepoint.ReadAt(text.AsSpan(), 0, out var count);
Assert.Equal(0x1F600u, cp.Value);
Assert.Equal(2, count);
}
[Fact]
public void ReadAt_LowSurrogate_ScansBackToHighSurrogate()
{
// Reading at the low surrogate position should still return the full
// supplementary codepoint by looking one index back.
const string text = "😀";
var cp = Codepoint.ReadAt(text.AsSpan(), 1, out var count);
Assert.Equal(0x1F600u, cp.Value);
Assert.Equal(2, count);
}
[Fact]
public void ReadAt_HighSurrogate_WithoutFollowingLow_ReturnsReplacement()
{
// Lone high surrogate at end of string.
const string text = "a\uD83D";
var cp = Codepoint.ReadAt(text.AsSpan(), 1, out var count);
Assert.Equal(Codepoint.ReplacementCodepoint.Value, cp.Value);
Assert.Equal(1, count);
}
[Fact]
public void ReadAt_HighSurrogate_FollowedByNonLow_ReturnsReplacement()
{
// High surrogate followed by a regular BMP character (invalid pair).
const string text = "\uD83Da";
var cp = Codepoint.ReadAt(text.AsSpan(), 0, out var count);
Assert.Equal(Codepoint.ReplacementCodepoint.Value, cp.Value);
Assert.Equal(1, count);
}
[Fact]
public void ReadAt_LoneLowSurrogate_AtStart_ReturnsReplacement()
{
// Lone low surrogate with nothing before it.
const string text = "\uDE00b";
var cp = Codepoint.ReadAt(text.AsSpan(), 0, out var count);
Assert.Equal(Codepoint.ReplacementCodepoint.Value, cp.Value);
Assert.Equal(1, count);
}
[Fact]
public void ReadAt_LowSurrogate_NotPrecededByHigh_ReturnsReplacement()
{
// Low surrogate at index 1, but index 0 is a regular char (not a high surrogate).
const string text = "a\uDE00";
var cp = Codepoint.ReadAt(text.AsSpan(), 1, out var count);
Assert.Equal(Codepoint.ReplacementCodepoint.Value, cp.Value);
Assert.Equal(1, count);
}
[Fact]
public void ReadAt_IndexPastLength_ReturnsReplacement()
{
const string text = "abc";
var cp = Codepoint.ReadAt(text.AsSpan(), 5, out var count);
Assert.Equal(Codepoint.ReplacementCodepoint.Value, cp.Value);
Assert.Equal(1, count);
}
[Fact]
public void ReadAt_IndexAtLength_ReturnsReplacement()
{
const string text = "abc";
var cp = Codepoint.ReadAt(text.AsSpan(), 3, out var count);
Assert.Equal(Codepoint.ReplacementCodepoint.Value, cp.Value);
Assert.Equal(1, count);
}
[Fact]
public void ReadAt_EmptySpan_ReturnsReplacement()
{
var cp = Codepoint.ReadAt(ReadOnlySpan<char>.Empty, 0, out var count);
Assert.Equal(Codepoint.ReplacementCodepoint.Value, cp.Value);
Assert.Equal(1, count);
}
[Fact]
public void CodepointEnumerator_DecodesMixedBmpAndSupplementaryText()
{
// 'a' + 😀 (U+1F600) + 'b' + ✓ (U+2713) + 🚀 (U+1F680).
const string text = "a😀b✓🚀";
var expected = new uint[] { 'a', 0x1F600, 'b', 0x2713, 0x1F680 };
var enumerator = new CodepointEnumerator(text.AsSpan());
var actual = new List<uint>();
while (enumerator.MoveNext(out var cp))
{
actual.Add(cp.Value);
}
Assert.Equal(expected, actual);
}
/// <summary>
/// <see cref="Codepoint.IsWhiteSpace"/> uses a bitmask trick that assumes every
/// <see cref="GeneralCategory"/> value used in the mask fits in 64 bits.
/// If <c>Control</c>, <c>Format</c>, or <c>SpaceSeparator</c> ever moves past
/// position 63 in the enum, the mask silently produces wrong results. This
/// guards against that.
/// </summary>
[Fact]
public void IsWhiteSpace_AllMaskedGeneralCategoriesFitInBitmask()
{
Assert.True((int)GeneralCategory.Control < 64);
Assert.True((int)GeneralCategory.Format < 64);
Assert.True((int)GeneralCategory.SpaceSeparator < 64);
}
[Theory]
[InlineData(0x0020u, true)] // SPACE
[InlineData(0x0009u, true)] // TAB (Control)
[InlineData(0x000Au, true)] // LF (Control)
[InlineData(0x000Du, true)] // CR (Control)
[InlineData(0x00A0u, true)] // NBSP (SpaceSeparator)
[InlineData(0x200Bu, true)] // ZWSP (Format)
[InlineData(0x0061u, false)] // 'a'
[InlineData(0x0030u, false)] // '0'
[InlineData(0x002Eu, false)] // '.'
public void IsWhiteSpace_KnownCodepoints(uint value, bool expected)
{
Assert.Equal(expected, new Codepoint(value).IsWhiteSpace);
}
[Theory]
[InlineData(0x000Au, true)] // LF
[InlineData(0x000Bu, true)] // VT
[InlineData(0x000Cu, true)] // FF
[InlineData(0x000Du, true)] // CR
[InlineData(0x0085u, true)] // NEL
[InlineData(0x2028u, true)] // LINE SEPARATOR
[InlineData(0x2029u, true)] // PARAGRAPH SEPARATOR
[InlineData(0x0020u, false)]
[InlineData(0x0061u, false)]
[InlineData(0x0009u, false)] // TAB is not a "break" char in Avalonia's sense
public void IsBreakChar_KnownCodepoints(uint value, bool expected)
{
Assert.Equal(expected, new Codepoint(value).IsBreakChar);
}
[Theory]
[InlineData(0x0061u, false)] // 'a'
[InlineData(0x4E2Du, true)] // '中' Wide
[InlineData(0xFF21u, true)] // 'A' Fullwidth
[InlineData(0xFF71u, true)] // 'ア' Halfwidth
[InlineData(0x03B1u, false)] // 'α' Ambiguous (not east asian per IsEastAsian)
[InlineData(0x0020u, false)] // ' ' Narrow
public void IsEastAsian_KnownCodepoints(uint value, bool expected)
{
Assert.Equal(expected, new Codepoint(value).IsEastAsian);
}
[Theory]
[InlineData(0x3008u, 0x2329u)] // 〈 → ⟨
[InlineData(0x3009u, 0x232Au)] // 〉 → ⟩
[InlineData(0x0061u, 0x0061u)] // 'a' → 'a' (unchanged)
[InlineData(0x0028u, 0x0028u)] // '(' → '(' (unchanged)
public void GetCanonicalType_MapsKnownCodepoints(uint input, uint expected)
{
// GetCanonicalType is internal; reachable here via InternalsVisibleTo.
var actual = Codepoint.GetCanonicalType(new Codepoint(input));
Assert.Equal(expected, actual.Value);
}
[Theory]
[InlineData(0x0028u, true, 0x0029u)] // '(' → ')'
[InlineData(0x0029u, true, 0x0028u)] // ')' → '('
[InlineData(0x005Bu, true, 0x005Du)] // '[' → ']'
[InlineData(0x005Du, true, 0x005Bu)] // ']' → '['
[InlineData(0x0061u, false, 0u)] // 'a' has no pair
[InlineData(0x0020u, false, 0u)] // ' ' has no pair
public void TryGetPairedBracket_KnownCodepoints(uint codepoint, bool expectedSuccess, uint expectedPair)
{
var result = new Codepoint(codepoint).TryGetPairedBracket(out var pair);
Assert.Equal(expectedSuccess, result);
if (expectedSuccess)
{
Assert.Equal(expectedPair, pair.Value);
}
}
[Fact]
public void ImplicitConversions_RoundTripValue()
{
var cp = new Codepoint(0x1F600u);
int asInt = cp;
uint asUint = cp;
Assert.Equal(0x1F600, asInt);
Assert.Equal(0x1F600u, asUint);
}
[Fact]
public void IsInRangeInclusive_BoundsAreInclusive()
{
Assert.True(Codepoint.IsInRangeInclusive(new Codepoint(0x10u), 0x10u, 0x20u));
Assert.True(Codepoint.IsInRangeInclusive(new Codepoint(0x20u), 0x10u, 0x20u));
Assert.True(Codepoint.IsInRangeInclusive(new Codepoint(0x15u), 0x10u, 0x20u));
Assert.False(Codepoint.IsInRangeInclusive(new Codepoint(0x0Fu), 0x10u, 0x20u));
Assert.False(Codepoint.IsInRangeInclusive(new Codepoint(0x21u), 0x10u, 0x20u));
}
}

94
tests/Avalonia.Base.UnitTests/Media/TextFormatting/PropertyValueAliasHelperTests.cs

@ -0,0 +1,94 @@
using Avalonia.Media.TextFormatting.Unicode;
using Xunit;
namespace Avalonia.Base.UnitTests.Media.TextFormatting;
/// <summary>
/// Lightweight round-trip checks for the generated <see cref="PropertyValueAliasHelper"/>.
/// Catches regressions in the alias-helper writer (e.g. wrong typeName, missing
/// entries, casing mismatches) without re-asserting the UCD aliases themselves.
/// </summary>
public class PropertyValueAliasHelperTests
{
[Theory]
[InlineData("L", BidiClass.LeftToRight)]
[InlineData("R", BidiClass.RightToLeft)]
[InlineData("AL", BidiClass.ArabicLetter)]
[InlineData("EN", BidiClass.EuropeanNumber)]
public void GetBidiClass_KnownTags(string tag, BidiClass expected)
{
Assert.Equal(expected, PropertyValueAliasHelper.GetBidiClass(tag));
}
[Fact]
public void GetBidiClass_UnknownTag_FallsBackToLeftToRight()
{
// The generator emits LeftToRight as the fallback for unknown tags.
Assert.Equal(BidiClass.LeftToRight, PropertyValueAliasHelper.GetBidiClass("not-a-real-tag"));
}
[Theory]
[InlineData("Latn", Script.Latin)]
[InlineData("Cyrl", Script.Cyrillic)]
[InlineData("Hani", Script.Han)]
[InlineData("Hebr", Script.Hebrew)]
[InlineData("Arab", Script.Arabic)]
[InlineData("Zyyy", Script.Common)]
public void GetScript_KnownTags(string tag, Script expected)
{
Assert.Equal(expected, PropertyValueAliasHelper.GetScript(tag));
}
[Fact]
public void GetTag_RoundTripsScriptThroughGetScript()
{
// Tag -> enum -> tag should round-trip for every script the helper knows.
// Pick a handful so we catch obvious typoes in the writer without needing
// a full mirror of UCD here.
foreach (var script in new[] { Script.Latin, Script.Cyrillic, Script.Han, Script.Hebrew, Script.Arabic })
{
var tag = PropertyValueAliasHelper.GetTag(script);
Assert.Equal(script, PropertyValueAliasHelper.GetScript(tag));
}
}
[Theory]
[InlineData("Lu", GeneralCategory.UppercaseLetter)]
[InlineData("Ll", GeneralCategory.LowercaseLetter)]
[InlineData("Nd", GeneralCategory.DecimalNumber)]
[InlineData("Zs", GeneralCategory.SpaceSeparator)]
[InlineData("Cc", GeneralCategory.Control)]
public void GetGeneralCategory_KnownTags(string tag, GeneralCategory expected)
{
Assert.Equal(expected, PropertyValueAliasHelper.GetGeneralCategory(tag));
}
[Theory]
[InlineData("AL", LineBreakClass.Alphabetic)]
[InlineData("LF", LineBreakClass.LineFeed)]
[InlineData("CR", LineBreakClass.CarriageReturn)]
[InlineData("XX", LineBreakClass.Unknown)]
public void GetLineBreakClass_KnownTags(string tag, LineBreakClass expected)
{
Assert.Equal(expected, PropertyValueAliasHelper.GetLineBreakClass(tag));
}
[Theory]
[InlineData("LE", WordBreakClass.ALetter)]
[InlineData("CR", WordBreakClass.CarriageReturn)]
[InlineData("LF", WordBreakClass.LineFeed)]
[InlineData("XX", WordBreakClass.Other)]
public void GetWordBreakClass_KnownTags(string tag, WordBreakClass expected)
{
Assert.Equal(expected, PropertyValueAliasHelper.GetWordBreakClass(tag));
}
[Theory]
[InlineData("o", BidiPairedBracketType.Open)]
[InlineData("c", BidiPairedBracketType.Close)]
[InlineData("n", BidiPairedBracketType.None)]
public void GetBidiPairedBracketType_KnownTags(string tag, BidiPairedBracketType expected)
{
Assert.Equal(expected, PropertyValueAliasHelper.GetBidiPairedBracketType(tag));
}
}

176
tests/Avalonia.Base.UnitTests/Media/TextFormatting/UnicodeDataTests.cs

@ -0,0 +1,176 @@
using Avalonia.Media.TextFormatting.Unicode;
using Xunit;
namespace Avalonia.Base.UnitTests.Media.TextFormatting;
/// <summary>
/// Spot-checks for <see cref="UnicodeData"/>. The trie generator already round-trips
/// every assigned codepoint against its source dictionary, so these tests focus on
/// pinning the public surface against drift: shift/mask layout, default values for
/// unassigned codepoints, and a few well-known codepoints across the four tries.
/// </summary>
public class UnicodeDataTests
{
[Theory]
[InlineData(0x0061u, GeneralCategory.LowercaseLetter)] // 'a'
[InlineData(0x0041u, GeneralCategory.UppercaseLetter)] // 'A'
[InlineData(0x0030u, GeneralCategory.DecimalNumber)] // '0'
[InlineData(0x0020u, GeneralCategory.SpaceSeparator)] // ' '
[InlineData(0x000Au, GeneralCategory.Control)] // '\n'
[InlineData(0x0009u, GeneralCategory.Control)] // '\t'
[InlineData(0x002Eu, GeneralCategory.OtherPunctuation)] // '.'
[InlineData(0x0028u, GeneralCategory.OpenPunctuation)] // '('
[InlineData(0x0029u, GeneralCategory.ClosePunctuation)] // ')'
[InlineData(0x0024u, GeneralCategory.CurrencySymbol)] // '$'
[InlineData(0x002Bu, GeneralCategory.MathSymbol)] // '+'
[InlineData(0x200Bu, GeneralCategory.Format)] // ZWSP
[InlineData(0xE000u, GeneralCategory.PrivateUse)] // BMP PUA start
[InlineData(0xD800u, GeneralCategory.Surrogate)] // high surrogate start (LSCP path)
[InlineData(0xDFFFu, GeneralCategory.Surrogate)] // low surrogate end (LSCP path)
// Note: codepoints >= HighStart (currently 0x100000) all collapse to a single
// fallback value because the trie compresses Plane 16 to save space. The
// resulting GeneralCategory is not the per-codepoint UCD value. See
// UnicodeTrieTests.Get_AtAndAboveHighStart_AllCodepointsShareFallback.
public void GetGeneralCategory_KnownCodepoints(uint codepoint, GeneralCategory expected)
{
Assert.Equal(expected, UnicodeData.GetGeneralCategory(codepoint));
}
[Theory]
[InlineData(0x0061u, Script.Latin)] // 'a'
[InlineData(0x0041u, Script.Latin)] // 'A'
[InlineData(0x044Fu, Script.Cyrillic)] // 'я'
[InlineData(0x4E2Du, Script.Han)] // '中'
[InlineData(0x05D0u, Script.Hebrew)] // 'א'
[InlineData(0x0627u, Script.Arabic)] // 'ا'
[InlineData(0x0020u, Script.Common)] // ' '
[InlineData(0x0030u, Script.Common)] // '0'
[InlineData(0x1F600u, Script.Common)] // 😀 (supplementary, common)
[InlineData(0x0300u, Script.Inherited)] // combining grave (inherited)
public void GetScript_KnownCodepoints(uint codepoint, Script expected)
{
Assert.Equal(expected, UnicodeData.GetScript(codepoint));
}
[Theory]
[InlineData(0x0061u, BidiClass.LeftToRight)] // 'a'
[InlineData(0x0041u, BidiClass.LeftToRight)] // 'A'
[InlineData(0x0039u, BidiClass.EuropeanNumber)] // '9'
[InlineData(0x0024u, BidiClass.EuropeanTerminator)] // '$'
[InlineData(0x002Cu, BidiClass.CommonSeparator)] // ','
[InlineData(0x0020u, BidiClass.WhiteSpace)] // ' '
[InlineData(0x0009u, BidiClass.SegmentSeparator)] // '\t'
[InlineData(0x000Au, BidiClass.ParagraphSeparator)] // '\n'
[InlineData(0x05D0u, BidiClass.RightToLeft)] // 'א'
[InlineData(0x0627u, BidiClass.ArabicLetter)] // 'ا'
public void GetBiDiClass_KnownCodepoints(uint codepoint, BidiClass expected)
{
Assert.Equal(expected, UnicodeData.GetBiDiClass(codepoint));
}
[Theory]
[InlineData(0x0028u, BidiPairedBracketType.Open, 0x0029u)] // '(' → ')'
[InlineData(0x0029u, BidiPairedBracketType.Close, 0x0028u)] // ')' → '('
[InlineData(0x005Bu, BidiPairedBracketType.Open, 0x005Du)] // '[' → ']'
[InlineData(0x005Du, BidiPairedBracketType.Close, 0x005Bu)] // ']' → '['
[InlineData(0x007Bu, BidiPairedBracketType.Open, 0x007Du)] // '{' → '}'
public void GetBiDiPairedBracket_RoundTripsKnownPairs(uint codepoint, BidiPairedBracketType expectedType, uint expectedPair)
{
Assert.Equal(expectedType, UnicodeData.GetBiDiPairedBracketType(codepoint));
Assert.Equal(expectedPair, UnicodeData.GetBiDiPairedBracket(codepoint).Value);
}
[Fact]
public void GetBiDiPairedBracketType_NonBracket_IsNone()
{
Assert.Equal(BidiPairedBracketType.None, UnicodeData.GetBiDiPairedBracketType(0x0061u));
Assert.Equal(BidiPairedBracketType.None, UnicodeData.GetBiDiPairedBracketType(0x0020u));
}
[Theory]
[InlineData(0x0061u, LineBreakClass.Alphabetic)] // 'a'
[InlineData(0x000Au, LineBreakClass.LineFeed)] // '\n'
[InlineData(0x000Du, LineBreakClass.CarriageReturn)] // '\r'
[InlineData(0x0020u, LineBreakClass.Space)] // ' '
[InlineData(0x0009u, LineBreakClass.BreakAfter)] // '\t'
[InlineData(0x002Du, LineBreakClass.Hyphen)] // '-'
[InlineData(0x0028u, LineBreakClass.OpenPunctuation)] // '('
[InlineData(0x0029u, LineBreakClass.CloseParenthesis)]// ')'
[InlineData(0x0030u, LineBreakClass.Numeric)] // '0'
[InlineData(0x4E2Du, LineBreakClass.Ideographic)] // '中'
[InlineData(0x2028u, LineBreakClass.MandatoryBreak)] // LINE SEPARATOR
[InlineData(0x2029u, LineBreakClass.MandatoryBreak)] // PARAGRAPH SEPARATOR
public void GetLineBreakClass_KnownCodepoints(uint codepoint, LineBreakClass expected)
{
Assert.Equal(expected, UnicodeData.GetLineBreakClass(codepoint));
}
[Theory]
[InlineData(0x0061u, WordBreakClass.ALetter)] // 'a'
[InlineData(0x000Du, WordBreakClass.CarriageReturn)] // '\r'
[InlineData(0x000Au, WordBreakClass.LineFeed)] // '\n'
[InlineData(0x0020u, WordBreakClass.WSegSpace)] // ' '
[InlineData(0x0030u, WordBreakClass.Numeric)] // '0'
[InlineData(0x200Du, WordBreakClass.ZWJ)] // ZWJ
[InlineData(0x05D0u, WordBreakClass.HebrewLetter)] // 'א'
[InlineData(0x4E2Du, WordBreakClass.Other)] // '中' (CJK is WB=Other)
public void GetWordBreakClass_KnownCodepoints(uint codepoint, WordBreakClass expected)
{
Assert.Equal(expected, UnicodeData.GetWordBreakClass(codepoint));
}
[Theory]
[InlineData(0x000Du, GraphemeBreakClass.CR)] // '\r'
[InlineData(0x000Au, GraphemeBreakClass.LF)] // '\n'
[InlineData(0x200Du, GraphemeBreakClass.ZWJ)] // ZWJ
[InlineData(0x1F600u, GraphemeBreakClass.ExtendedPictographic)] // 😀 (overridden by emoji-data.txt)
[InlineData(0x1100u, GraphemeBreakClass.L)] // HANGUL CHOSEONG KIYEOK
[InlineData(0x1161u, GraphemeBreakClass.V)] // HANGUL JUNGSEONG A
[InlineData(0x11A8u, GraphemeBreakClass.T)] // HANGUL JONGSEONG KIYEOK
[InlineData(0x0061u, GraphemeBreakClass.Other)] // 'a'
[InlineData(0x0030u, GraphemeBreakClass.Other)] // '0'
public void GetGraphemeClusterBreak_KnownCodepoints(uint codepoint, GraphemeBreakClass expected)
{
Assert.Equal(expected, UnicodeData.GetGraphemeClusterBreak(codepoint));
}
[Theory]
[InlineData(0x0061u, EastAsianWidthClass.Narrow)] // 'a'
[InlineData(0x0020u, EastAsianWidthClass.Narrow)] // ' '
[InlineData(0x4E2Du, EastAsianWidthClass.Wide)] // '中'
[InlineData(0xFF21u, EastAsianWidthClass.Fullwidth)] // 'A' FULLWIDTH LATIN CAPITAL A
[InlineData(0xFF71u, EastAsianWidthClass.Halfwidth)] // 'ア' HALFWIDTH KATAKANA A
[InlineData(0x03B1u, EastAsianWidthClass.Ambiguous)] // 'α'
[InlineData(0x200Bu, EastAsianWidthClass.Neutral)] // ZWSP
public void GetEastAsianWidthClass_KnownCodepoints(uint codepoint, EastAsianWidthClass expected)
{
Assert.Equal(expected, UnicodeData.GetEastAsianWidthClass(codepoint));
}
/// <summary>
/// Regression test for the BiDi / GraphemeBreak / UnicodeData trie builders'
/// reliance on the seeded default class sitting at int position 0 (caught at
/// generation time by the ABI validator, but only this asserts the runtime
/// behavior of an unassigned codepoint).
/// </summary>
[Fact]
public void UnassignedCodepoint_FallsBackToSeededDefaults()
{
// U+0378 is an unassigned BMP code point (and has been for decades — stable choice).
const uint unassigned = 0x0378u;
// Default Bidi class for unassigned codepoints is LeftToRight (seeded at position 0).
Assert.Equal(BidiClass.LeftToRight, UnicodeData.GetBiDiClass(unassigned));
// Default grapheme break class is Other (seeded at position 0).
Assert.Equal(GraphemeBreakClass.Other, UnicodeData.GetGraphemeClusterBreak(unassigned));
// Default line break class is Unknown — set explicitly via initialValue in the
// UnicodeData trie builder, not via seed position 0.
Assert.Equal(LineBreakClass.Unknown, UnicodeData.GetLineBreakClass(unassigned));
// Default word break class is Other — also set explicitly in the generator's
// post-pass that maps unset WordBreakClass to Other.
Assert.Equal(WordBreakClass.Other, UnicodeData.GetWordBreakClass(unassigned));
}
}

179
tests/Avalonia.Base.UnitTests/Media/TextFormatting/UnicodeTrieTests.cs

@ -0,0 +1,179 @@
using Avalonia.Media.TextFormatting.Unicode;
using Xunit;
namespace Avalonia.Base.UnitTests.Media.TextFormatting;
/// <summary>
/// Direct coverage for <see cref="UnicodeTrie"/> and <see cref="UnicodeTrieBuilder"/>.
/// The production tries (UnicodeData, BiDi, GraphemeBreak, EastAsianWidth) are
/// used to exercise the four branches of <see cref="UnicodeTrie.Get"/>; a small
/// synthetic trie covers the error-value path (which is otherwise unreachable
/// because the committed tries are generated with <c>errorValue == 0</c>) and the
/// builder round-trip.
/// </summary>
public class UnicodeTrieTests
{
[Theory]
[InlineData(0x0020u)] // ASCII space
[InlineData(0x0061u)] // 'a'
[InlineData(0x4E2Du)] // '中' (BMP CJK)
[InlineData(0xFFFFu)] // last BMP non-surrogate
public void Get_BmpNonSurrogate_ReturnsValueMatchingUnicodeDataWrapper(uint codepoint)
{
// Walking the trie directly and re-applying the published shift/mask must
// produce the same answer as the public UnicodeData wrapper. This catches
// packing-layout drift between generator (writes packed bits) and
// UnicodeData.Get* (reads packed bits).
var packed = UnicodeDataTrie.Trie.Get(codepoint);
var categoryFromTrie = (GeneralCategory)(packed & UnicodeData.CATEGORY_MASK);
var scriptFromTrie = (Script)((packed >> UnicodeData.SCRIPT_SHIFT) & UnicodeData.SCRIPT_MASK);
Assert.Equal(UnicodeData.GetGeneralCategory(codepoint), categoryFromTrie);
Assert.Equal(UnicodeData.GetScript(codepoint), scriptFromTrie);
}
[Theory]
[InlineData(0xD800u)] // first high surrogate
[InlineData(0xDB00u)] // mid high surrogate
[InlineData(0xDBFFu)] // last high surrogate
[InlineData(0xDC00u)] // first low surrogate
[InlineData(0xDFFFu)] // last low surrogate
public void Get_SurrogateRange_ResolvesViaLscpIndex(uint codepoint)
{
// Surrogates have a dedicated index region (LSCP_INDEX_2_OFFSET) in the
// trie. The general category for every codepoint in this range is
// Surrogate; this asserts the LSCP branch returns the correct row.
Assert.Equal(GeneralCategory.Surrogate, UnicodeData.GetGeneralCategory(codepoint));
}
[Theory]
[InlineData(0x10000u)] // first supplementary
[InlineData(0x1F600u)] // 😀
[InlineData(0x2F800u)] // CJK compatibility supplement
public void Get_Supplementary_BelowHighStart_ResolvesViaTwoLevelLookup(uint codepoint)
{
// Just walking the trie and reapplying the published mask must match the
// wrapper — same guarantee as the BMP test, but exercises the two-level
// supplementary lookup branch.
var packed = BiDiTrie.Trie.Get(codepoint);
var bidiFromTrie = (BidiClass)((packed >> UnicodeData.BIDICLASS_SHIFT) & UnicodeData.BIDICLASS_MASK);
Assert.Equal(UnicodeData.GetBiDiClass(codepoint), bidiFromTrie);
}
[Fact]
public void Get_AtAndAboveHighStart_AllCodepointsShareFallback()
{
// Every codepoint >= HighStart short-circuits to the trie's last data
// block. The committed tries set HighStart at 0x100000, so all of Plane
// 16 collapses to one fallback value — this is a compression artifact
// of the trie format. The test verifies the SHAPE of that contract (one
// value for the whole high range) rather than asserting any specific
// per-codepoint property: callers querying Plane 16 should not rely on
// PUA / Unassigned distinctions surviving the trie.
var v100000 = UnicodeDataTrie.Trie.Get(0x100000u);
Assert.Equal(v100000, UnicodeDataTrie.Trie.Get(0x100001u));
Assert.Equal(v100000, UnicodeDataTrie.Trie.Get(0x10FFFDu));
Assert.Equal(v100000, UnicodeDataTrie.Trie.Get(0x10FFFFu));
var bidi100000 = BiDiTrie.Trie.Get(0x100000u);
Assert.Equal(bidi100000, BiDiTrie.Trie.Get(0x10FFFFu));
}
[Fact]
public void Get_BeyondMaxCodepoint_IsHandledGracefullyByProductionTries()
{
// The committed tries are generated with errorValue == 0, so codepoints
// past 0x10FFFF return 0 (Other category, LeftToRight bidi, etc.). This
// documents that contract; the synthetic-trie test below covers the
// case where errorValue is non-zero.
const uint beyondRange = 0x110000u;
Assert.Equal(0u, UnicodeDataTrie.Trie.Get(beyondRange));
Assert.Equal(0u, BiDiTrie.Trie.Get(beyondRange));
Assert.Equal(0u, GraphemeBreakTrie.Trie.Get(beyondRange));
Assert.Equal(0u, EastAsianWidthTrie.Trie.Get(beyondRange));
}
[Fact]
public void Builder_RoundTripsSetValues()
{
var builder = new UnicodeTrieBuilder(initialValue: 7u);
builder.Set(0x0061, 0xAA);
builder.Set(0x4E2D, 0xBB);
builder.Set(0x1F600, 0xCC);
var trie = builder.Freeze();
Assert.Equal(0xAAu, trie.Get(0x0061));
Assert.Equal(0xBBu, trie.Get(0x4E2D));
Assert.Equal(0xCCu, trie.Get(0x1F600));
}
[Fact]
public void Builder_SetRange_AppliesValueToEveryCodepointInRange()
{
var builder = new UnicodeTrieBuilder();
builder.SetRange(0x2000, 0x2010, 0x42);
var trie = builder.Freeze();
for (uint cp = 0x2000; cp <= 0x2010; cp++)
{
Assert.Equal(0x42u, trie.Get(cp));
}
// Just outside the range stays at the initial value (0 by default).
Assert.Equal(0u, trie.Get(0x1FFF));
Assert.Equal(0u, trie.Get(0x2011));
}
[Fact]
public void Builder_UnassignedCodepoints_GetInitialValue()
{
var builder = new UnicodeTrieBuilder(initialValue: 0xDEAD);
builder.Set(0x0061, 0xBEEF);
var trie = builder.Freeze();
Assert.Equal(0xBEEFu, trie.Get(0x0061));
Assert.Equal(0xDEADu, trie.Get(0x0062));
Assert.Equal(0xDEADu, trie.Get(0x4E2D));
Assert.Equal(0xDEADu, trie.Get(0x1F600));
}
[Fact]
public void Get_OutOfRange_ReturnsConfiguredErrorValue()
{
// Build with a non-zero errorValue so the "> 0x10FFFF" branch produces a
// distinguishable result. This is the only feasible test of that branch —
// the committed tries all use errorValue == 0 which collides with the
// happy-path zero value.
var builder = new UnicodeTrieBuilder(initialValue: 0u, errorValue: 0xFFFFu);
builder.Set(0x0061, 0x11);
var trie = builder.Freeze();
Assert.Equal(0xFFFFu, trie.Get(0x110000u));
Assert.Equal(0xFFFFu, trie.Get(0xFFFFFFFFu));
}
[Fact]
public void Get_AtAndAboveHighStart_OnSyntheticTrie_UsesHighFallback()
{
// SetRange across a huge supplementary span forces the builder to allocate
// a high block. Codepoints at and above the resulting HighStart should
// return the value that covers the high range.
var builder = new UnicodeTrieBuilder(initialValue: 0u);
builder.SetRange(0x80000, 0x10FFFF, 0x55);
var trie = builder.Freeze();
Assert.Equal(0x55u, trie.Get(0x100000u));
Assert.Equal(0x55u, trie.Get(0x10FFFFu));
// Below the high range — still the initial value.
Assert.Equal(0u, trie.Get(0x1000u));
}
}

78
tests/Avalonia.Benchmarks/Program.cs

@ -1,6 +1,13 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Reflection;
using Avalonia.Harfbuzz;
using Avalonia.Media;
using Avalonia.Media.TextFormatting;
using Avalonia.Skia;
using Avalonia.UnitTests;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Configs;
using BenchmarkDotNet.Running;
@ -11,6 +18,17 @@ namespace Avalonia.Benchmarks
{
static void Main(string[] args)
{
// Profiling harness: bypasses BDN entirely so dotnet-trace's rundown
// doesn't deadlock against BDN's measurement loop. Run as:
// dotnet-trace collect --providers Microsoft-DotNETCore-SampleProfiler
// --duration 00:00:25 -o trace.nettrace --
// dotnet Avalonia.Benchmarks.dll --profile-textlayout
if (args.Contains("--profile-textlayout"))
{
ProfileTextLayout();
return;
}
// Use reflection for a more maintainable way of creating the benchmark switcher,
// Benchmarks are listed in namespace order first (e.g. BenchmarkDotNet.Samples.CPU,
// BenchmarkDotNet.Samples.IL, etc) then by name, so the output is easy to understand
@ -33,5 +51,65 @@ namespace Avalonia.Benchmarks
benchmarkSwitcher.Run(args, config);
}
private static void ProfileTextLayout()
{
// Bootstrap with the real Skia render interface (instead of the mock used
// by TestServices.StyledWindow). That makes GlyphRunImpl construction —
// which builds per-glyph absolute positions and walks the ink bounds —
// appear on the hot path, so we can see whether the render-side prefix
// sum is a real production cost. The mock platform returns trivial
// values and hides this work.
var services = TestServices.StyledWindow
.With(
renderInterface: new PlatformRenderInterface(),
textShaperImpl: new HarfBuzzTextShaper(),
fontManagerImpl: new FontManagerImpl());
using var app = UnitTestApplication.Start(services);
// Real Avalonia consumers (e.g. TextBlock) share a TextRunCache across
// re-layouts of the same string, so shaping cost amortises after the
// first build. Profiling without a cache makes HarfBuzz dominate the
// trace and hides the steady-state hot paths that matter.
var cache = new TextRunCache();
// Warm up the JIT, font loading, trie data, tiered compilation — and
// populate the cache so the measurement loop runs cache-warm like a
// real Avalonia paint pass would.
for (var i = 0; i < 500; i++)
{
BuildOne(cache).Dispose();
}
// Steady-state measurement loop. ~18 s is well within dotnet-trace's
// typical --duration 00:00:25 window, so the harness exits before the
// collector — that gives the EventPipe rundown an idle target to flush
// against, avoiding the back-pressure deadlock that happens when the
// target is mid-loop at rundown time.
var sw = Stopwatch.StartNew();
var count = 0;
while (sw.Elapsed < TimeSpan.FromSeconds(18))
{
BuildOne(cache).Dispose();
count++;
}
sw.Stop();
Console.WriteLine($"Built {count} layouts in {sw.Elapsed.TotalSeconds:F1}s " +
$"({sw.Elapsed.TotalMilliseconds / count:F3} ms/op).");
}
private static TextLayout BuildOne(TextRunCache cache)
{
return new TextLayout(
Text.HugeTextLayout.EmojisText,
Typeface.Default,
12d,
Brushes.Black,
maxWidth: 120,
textTrimming: TextTrimming.None,
textWrapping: TextWrapping.WrapWithOverflow,
textRunCache: cache);
}
}
}

223
tests/Avalonia.Benchmarks/Text/CodepointBenchmark.cs

@ -0,0 +1,223 @@
using System;
using System.Text;
using Avalonia.Media.TextFormatting.Unicode;
using BenchmarkDotNet.Attributes;
namespace Avalonia.Benchmarks.Text;
/// <summary>
/// End-to-end benchmarks for the <see cref="Codepoint"/> hot path: surrogate
/// decoding via <see cref="Codepoint.ReadAt"/>, property accessors that
/// perform Unicode data lookups via generated static <see cref="UnicodeTrie"/>
/// instances, and a representative "all properties" pass that approximates
/// what a layout pipeline traversal pays per codepoint.
/// </summary>
[MemoryDiagnoser]
[MinIterationTime(150)]
[MaxWarmupCount(15)]
public class CodepointBenchmark
{
public enum TextDistribution
{
/// <summary>Pure ASCII (no surrogate decoding cost).</summary>
Ascii,
/// <summary>BMP mix of Latin, Cyrillic, Greek, CJK (one UTF-16 unit per scalar).</summary>
Bmp,
/// <summary>Mix of supplementary plane scalars (math, emoji, CJK Ext B) — every other
/// scalar requires surrogate-pair decoding.</summary>
Supplementary,
}
private const int ScalarCount = 1024;
private string _text = string.Empty;
[Params(TextDistribution.Ascii, TextDistribution.Bmp, TextDistribution.Supplementary)]
public TextDistribution Distribution { get; set; }
[GlobalSetup]
public void Setup()
{
var rng = new Random(42);
var sb = new StringBuilder(ScalarCount * 2);
for (var i = 0; i < ScalarCount; i++)
{
var scalar = Distribution switch
{
TextDistribution.Ascii => (uint)rng.Next(0x20, 0x7F),
TextDistribution.Bmp => SampleBmpScalar(rng),
TextDistribution.Supplementary => SampleSupplementaryScalar(rng),
_ => (uint)'a',
};
sb.Append(char.ConvertFromUtf32((int)scalar));
}
_text = sb.ToString();
}
private static uint SampleBmpScalar(Random rng)
{
// Pick from a spread of common BMP ranges; skip the surrogate region.
var bucket = rng.Next(4);
return bucket switch
{
0 => (uint)rng.Next(0x0020, 0x007F), // Latin
1 => (uint)rng.Next(0x0400, 0x0500), // Cyrillic
2 => (uint)rng.Next(0x0370, 0x0400), // Greek
_ => (uint)rng.Next(0x4E00, 0x9FFF), // CJK Unified Ideographs
};
}
private static uint SampleSupplementaryScalar(Random rng)
{
// Alternate BMP and supplementary so the benchmark exercises both
// branches of Codepoint.ReadAt — pure-supplementary text isn't
// representative of any real layout workload.
if ((rng.Next() & 1) == 0)
{
return SampleBmpScalar(rng);
}
var bucket = rng.Next(3);
return bucket switch
{
0 => (uint)rng.Next(0x1F300, 0x1F600), // emoji
1 => (uint)rng.Next(0x20000, 0x2A6DF), // CJK Ext B
_ => (uint)rng.Next(0x1D400, 0x1D800), // math alphanumerics
};
}
[Benchmark]
public uint ReadAt_Sequence()
{
var span = _text.AsSpan();
var sum = 0u;
var i = 0;
while (i < span.Length)
{
sum += Codepoint.ReadAt(span, i, out var count).Value;
i += count;
}
return sum;
}
[Benchmark]
public uint CodepointEnumerator_Sequence()
{
var enumerator = new CodepointEnumerator(_text.AsSpan());
var sum = 0u;
while (enumerator.MoveNext(out var cp))
{
sum += cp.Value;
}
return sum;
}
[Benchmark]
public int Sequence_GeneralCategory()
{
var span = _text.AsSpan();
var sum = 0;
var i = 0;
while (i < span.Length)
{
var cp = Codepoint.ReadAt(span, i, out var count);
sum += (int)cp.GeneralCategory;
i += count;
}
return sum;
}
[Benchmark]
public int Sequence_Script()
{
var span = _text.AsSpan();
var sum = 0;
var i = 0;
while (i < span.Length)
{
var cp = Codepoint.ReadAt(span, i, out var count);
sum += (int)cp.Script;
i += count;
}
return sum;
}
[Benchmark]
public int Sequence_BiDiClass()
{
var span = _text.AsSpan();
var sum = 0;
var i = 0;
while (i < span.Length)
{
var cp = Codepoint.ReadAt(span, i, out var count);
sum += (int)cp.BiDiClass;
i += count;
}
return sum;
}
/// <summary>
/// Worst-case representative of a layout pipeline pass that needs every
/// property per codepoint. Seven trie lookups per scalar (Category +
/// Script + BiDi + LineBreak + WordBreak + GraphemeBreak + EastAsianWidth).
/// This is the headline number for the "should we merge tries?" question.
/// </summary>
[Benchmark]
public int Sequence_AllProperties()
{
var span = _text.AsSpan();
var sum = 0;
var i = 0;
while (i < span.Length)
{
var cp = Codepoint.ReadAt(span, i, out var count);
sum += (int)cp.GeneralCategory;
sum += (int)cp.Script;
sum += (int)cp.BiDiClass;
sum += (int)cp.LineBreakClass;
sum += (int)cp.WordBreakClass;
sum += (int)cp.GraphemeBreakClass;
sum += (int)cp.EastAsianWidthClass;
i += count;
}
return sum;
}
[Benchmark]
public int TryGetPairedBracket_Sequence()
{
var span = _text.AsSpan();
var paired = 0;
var i = 0;
while (i < span.Length)
{
var cp = Codepoint.ReadAt(span, i, out var count);
if (cp.TryGetPairedBracket(out _))
{
paired++;
}
i += count;
}
return paired;
}
}

4
tests/Avalonia.Benchmarks/Text/HugeTextLayout.cs

@ -66,7 +66,7 @@ In respect that the structure of the sufficient amount poses problems and challe
[Benchmark]
public TextLayout BuildTextLayout() => MakeLayout(Text);
private const string Emojis = @"😀 😁 😂 🤣 😃 😄 😅 😆 😉 😊 😋 😎 😍 😘 🥰 😗 😙 😚 ☺️ 🙂 🤗 🤩 🤔 🤨 😐 😑 😶 🙄 😏 😣 😥 😮 🤐 😯 😪 😫 😴 😌 😛 😜 😝 🤤 😒 😓 😔 😕 🙃 🤑 😲 ☹️ 🙁 😖 😞 😟 😤 😢 😭 😦 😧 😨 😩 🤯 😬 😰 😱 🥵 🥶 😳 🤪 😵 😡 😠 🤬 😷 🤒 🤕 🤢 🤮 🤧 😇 🤠 🤡 🥳 🥴 🥺 🤥 🤫 🤭 🧐 🤓 😈 👿 👹 👺 💀 👻 👽 🤖 💩 😺 😸 😹 😻 😼 😽 🙀 😿 😾
internal const string EmojisText = @"😀 😁 😂 🤣 😃 😄 😅 😆 😉 😊 😋 😎 😍 😘 🥰 😗 😙 😚 ☺️ 🙂 🤗 🤩 🤔 🤨 😐 😑 😶 🙄 😏 😣 😥 😮 🤐 😯 😪 😫 😴 😌 😛 😜 😝 🤤 😒 😓 😔 😕 🙃 🤑 😲 ☹️ 🙁 😖 😞 😟 😤 😢 😭 😦 😧 😨 😩 🤯 😬 😰 😱 🥵 🥶 😳 🤪 😵 😡 😠 🤬 😷 🤒 🤕 🤢 🤮 🤧 😇 🤠 🤡 🥳 🥴 🥺 🤥 🤫 🤭 🧐 🤓 😈 👿 👹 👺 💀 👻 👽 🤖 💩 😺 😸 😹 😻 😼 😽 🙀 😿 😾
👶 👧 🧒 👦 👩 🧑 👨 👵 🧓 👴 👲 👳‍♀️ 👳‍♂️ 🧕 🧔 👱‍♂️ 👱‍♀️ 👨‍🦰 👩‍🦰 👨‍🦱 👩‍🦱 👨‍🦲 👩‍🦲 👨‍🦳 👩‍🦳 🦸‍♀️ 🦸‍♂️ 🦹‍♀️ 🦹‍♂️ 👮‍♀️ 👮‍♂️ 👷‍♀️ 👷‍♂️ 💂‍♀️ 💂‍♂️ 🕵️‍♀️ 🕵️‍♂️ 👩‍⚕️ 👨‍⚕️ 👩‍🌾 👨‍🌾 👩‍🍳 👨‍🍳 👩‍🎓 👨‍🎓 👩‍🎤 👨‍🎤 👩‍🏫 👨‍🏫 👩‍🏭 👨‍🏭 👩‍💻 👨‍💻 👩‍💼 👨‍💼 👩‍🔧 👨‍🔧 👩‍🔬 👨‍🔬 👩‍🎨 👨‍🎨 👩‍🚒 👨‍🚒 👩‍✈️ 👨‍✈️ 👩‍🚀 👨‍🚀 👩‍⚖️ 👨‍⚖️ 👰 🤵 👸 🤴 🤶 🎅 🧙‍♀️ 🧙‍♂️ 🧝‍♀️ 🧝‍♂️ 🧛‍♀️ 🧛‍♂️ 🧟‍♀️ 🧟‍♂️ 🧞‍♀️ 🧞‍♂️ 🧜‍♀️ 🧜‍♂️ 🧚‍♀️ 🧚‍♂️ 👼 🤰 🤱 🙇‍♀️ 🙇‍♂️ 💁‍♀️ 💁‍♂️ 🙅‍♀️ 🙅‍♂️ 🙆‍♀️ 🙆‍♂️ 🙋‍♀️ 🙋‍♂️ 🤦‍♀️ 🤦‍♂️ 🤷‍♀️ 🤷‍♂️ 🙎‍♀️ 🙎‍♂️ 🙍‍♀️ 🙍‍♂️ 💇‍♀️ 💇‍♂️ 💆‍♀️ 💆‍♂️ 🧖‍♀️ 🧖‍♂️ 💅 🤳 💃 🕺 👯‍♀️ 👯‍♂️ 🕴 🚶‍♀️ 🚶‍♂️ 🏃‍♀️ 🏃‍♂️ 👫 👭 👬 💑 👩‍❤️‍👩 👨‍❤️‍👨 💏 👩‍❤️‍💋‍👩 👨‍❤️‍💋‍👨 👪 👨‍👩‍👧 👨‍👩‍👧‍👦 👨‍👩‍👦‍👦 👨‍👩‍👧‍👧 👩‍👩‍👦 👩‍👩‍👧 👩‍👩‍👧‍👦 👩‍👩‍👦‍👦 👩‍👩‍👧‍👧 👨‍👨‍👦 👨‍👨‍👧 👨‍👨‍👧‍👦 👨‍👨‍👦‍👦 👨‍👨‍👧‍👧 👩‍👦 👩‍👧 👩‍👧‍👦 👩‍👦‍👦 👩‍👧‍👧 👨‍👦 👨‍👧 👨‍👧‍👦 👨‍👦‍👦 👨‍👧‍👧 🤲 👐 🙌 👏 🤝 👍 👎 👊 ✊ 🤛 🤜 🤞 ✌️ 🤟 🤘 👌 👈 👉 👆 👇 ☝️ ✋ 🤚 🖐 🖖 👋 🤙 💪 🦵 🦶 🖕 ✍️ 🙏 💍 💄 💋 👄 👅 👂 👃 👣 👁 👀 🧠 🦴 🦷 🗣 👤 👥
🧥 👚 👕 👖 👔 👗 👙 👘 👠 👡 👢 👞 👟 🥾 🥿 🧦 🧤 🧣 🎩 🧢 👒 🎓 ⛑ 👑 👝 👛 👜 💼 🎒 👓 🕶 🥽 🥼 🌂 🧵 🧶
👶🏻 👦🏻 👧🏻 👨🏻 👩🏻 👱🏻‍♀️ 👱🏻 👴🏻 👵🏻 👲🏻 👳🏻‍♀️ 👳🏻 👮🏻‍♀️ 👮🏻 👷🏻‍♀️ 👷🏻 💂🏻‍♀️ 💂🏻 🕵🏻‍♀️ 🕵🏻 👩🏻‍⚕️ 👨🏻‍⚕️ 👩🏻‍🌾 👨🏻‍🌾 👩🏻‍🍳 👨🏻‍🍳 👩🏻‍🎓 👨🏻‍🎓 👩🏻‍🎤 👨🏻‍🎤 👩🏻‍🏫 👨🏻‍🏫 👩🏻‍🏭 👨🏻‍🏭 👩🏻‍💻 👨🏻‍💻 👩🏻‍💼 👨🏻‍💼 👩🏻‍🔧 👨🏻‍🔧 👩🏻‍🔬 👨🏻‍🔬 👩🏻‍🎨 👨🏻‍🎨 👩🏻‍🚒 👨🏻‍🚒 👩🏻‍✈️ 👨🏻‍✈️ 👩🏻‍🚀 👨🏻‍🚀 👩🏻‍⚖️ 👨🏻‍⚖️ 🤶🏻 🎅🏻 👸🏻 🤴🏻 👰🏻 🤵🏻 👼🏻 🤰🏻 🙇🏻‍♀️ 🙇🏻 💁🏻 💁🏻‍♂️ 🙅🏻 🙅🏻‍♂️ 🙆🏻 🙆🏻‍♂️ 🙋🏻 🙋🏻‍♂️ 🤦🏻‍♀️ 🤦🏻‍♂️ 🤷🏻‍♀️ 🤷🏻‍♂️ 🙎🏻 🙎🏻‍♂️ 🙍🏻 🙍🏻‍♂️ 💇🏻 💇🏻‍♂️ 💆🏻 💆🏻‍♂️ 🕴🏻 💃🏻 🕺🏻 🚶🏻‍♀️ 🚶🏻 🏃🏻‍♀️ 🏃🏻 🤲🏻 👐🏻 🙌🏻 👏🏻 🙏🏻 👍🏻 👎🏻 👊🏻 ✊🏻 🤛🏻 🤜🏻 🤞🏻 ✌🏻 🤟🏻 🤘🏻 👌🏻 👈🏻 👉🏻 👆🏻 👇🏻 ☝🏻 ✋🏻 🤚🏻 🖐🏻 🖖🏻 👋🏻 🤙🏻 💪🏻 🖕🏻 ✍🏻 🤳🏻 💅🏻 👂🏻 👃🏻
@ -84,7 +84,7 @@ In respect that the structure of the sufficient amount poses problems and challe
🥱 🤏 🦾 🦿 🦻 🧏 🧏‍♂️ 🧏‍♀️ 🧍 🧍‍♂️ 🧍‍♀️ 🧎 🧎‍♂️ 🧎‍♀️ 👨‍🦯 👩‍🦯 👨‍🦼 👩‍🦼 👨‍🦽 👩‍🦽 🦧 🦮 🐕‍🦺 🦥 🦦 🦨 🦩 🧄 🧅 🧇 🧆 🧈 🦪 🧃 🧉 🧊 🛕 🦽 🦼 🛺 🪂 🪐 🤿 🪀 🪁 🦺 🥻 🩱 🩲 🩳 🩰 🪕 🪔 🪓 🦯 🩸 🩹 🩺 🪑 🪒 🤎 🤍 🟠 🟡 🟢 🟣 🟤 🟥 🟧 🟨 🟩 🟦 🟪 🟫";
[Benchmark]
public TextLayout BuildEmojisTextLayout() => MakeLayout(Emojis);
public TextLayout BuildEmojisTextLayout() => MakeLayout(EmojisText);
[Benchmark]
public TextLayout[] BuildManySmallTexts()

149
tests/Avalonia.Benchmarks/Text/ShapedBufferOps.cs

@ -0,0 +1,149 @@
#nullable enable
using System;
using System.Linq;
using Avalonia.Media;
using Avalonia.Media.TextFormatting;
using Avalonia.UnitTests;
using BenchmarkDotNet.Attributes;
namespace Avalonia.Benchmarks.Text;
/// <summary>
/// Micro-benchmark for the <see cref="ShapedBuffer"/> cluster-cache hot paths
/// (<see cref="ShapedBuffer.TotalGlyphAdvance"/>, <see cref="ShapedBuffer.MeasureCharactersThatFit"/>,
/// and the cached <see cref="ShapedBuffer.Split"/> chain). Compares the
/// simple-mode fast path (1 char per cluster) against complex clusters by
/// shaping random ASCII vs. random-from-extended-Latin so the buffers exercise
/// different code paths. Pair with <c>--memory</c> to see allocation impact of
/// the <see cref="System.Buffers.ArrayPool{T}"/>-backed cluster cache.
/// </summary>
[MemoryDiagnoser]
[MinIterationTime(150)]
[MaxWarmupCount(10)]
public class ShapedBufferOps : IDisposable
{
private readonly IDisposable _app;
private readonly TextShaperOptions _options;
private string _text = string.Empty;
private ShapedBuffer? _primed;
public ShapedBufferOps()
{
_app = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface);
_options = new TextShaperOptions(Typeface.Default.GlyphTypeface);
}
[Params(8, 32, 128, 512, 2048)]
public int GlyphCount { get; set; }
[GlobalSetup]
public void Setup()
{
// ASCII text → simple-mode fast path (one glyph == one cluster == one char).
var rng = new Random(GlyphCount);
const string alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 ";
_text = new string(Enumerable.Range(0, GlyphCount).Select(_ => alphabet[rng.Next(alphabet.Length)]).ToArray());
_primed?.Dispose();
_primed = TextShaper.Current.ShapeText(_text, _options);
// Prime the cluster cache once so the per-call benchmarks measure only the lookup cost.
_ = _primed.TotalGlyphAdvance;
}
/// <summary>
/// Cost of shaping + building the cluster cache from scratch. Captures both
/// the HarfBuzz call and the prefix-sum allocation/initialisation.
/// </summary>
[Benchmark]
public double ShapeAndPrime()
{
using var buffer = TextShaper.Current.ShapeText(_text, _options);
return buffer.TotalGlyphAdvance;
}
/// <summary>
/// Repeated <see cref="ShapedBuffer.TotalGlyphAdvance"/> on a primed buffer.
/// Should be ~O(1) per call regardless of glyph count.
/// </summary>
[Benchmark]
public double TotalAdvance_Cached()
{
var sum = 0d;
for (var i = 0; i < 64; i++)
{
sum += _primed!.TotalGlyphAdvance;
}
return sum;
}
/// <summary>
/// Repeated <see cref="ShapedBuffer.MeasureCharactersThatFit"/> targeting
/// half the buffer's total width. Exercises the binary search across the
/// prefix table.
/// </summary>
[Benchmark]
public int MeasureFit_Cached()
{
var halfWidth = _primed!.TotalGlyphAdvance * 0.5;
var sum = 0;
for (var i = 0; i < 64; i++)
{
sum += _primed.MeasureCharactersThatFit(halfWidth, out _);
}
return sum;
}
/// <summary>
/// Splits the primed buffer at three positions and queries the resulting
/// children — the workload the shared cluster cache is designed for. Each
/// child should reuse the parent cache in O(1)/O(log) instead of rebuilding.
/// </summary>
[Benchmark]
public double SplitChain()
{
var quarter = _primed!.Text.Length / 4;
ShapedBuffer? first = null;
ShapedBuffer? second = null;
ShapedBuffer? firstFirst = null;
ShapedBuffer? firstSecond = null;
ShapedBuffer? secondFirst = null;
ShapedBuffer? secondSecond = null;
try
{
var halves = _primed.Split(quarter * 2);
first = halves.First!;
second = halves.Second!;
var firstSplit = first.Split(quarter);
var secondSplit = second.Split(quarter);
firstFirst = firstSplit.First;
firstSecond = firstSplit.Second;
secondFirst = secondSplit.First;
secondSecond = secondSplit.Second;
var total = (firstFirst?.TotalGlyphAdvance ?? 0)
+ (firstSecond?.TotalGlyphAdvance ?? 0)
+ (secondFirst?.TotalGlyphAdvance ?? 0)
+ (secondSecond?.TotalGlyphAdvance ?? 0);
return total;
}
finally
{
firstFirst?.Dispose();
firstSecond?.Dispose();
secondFirst?.Dispose();
secondSecond?.Dispose();
first?.Dispose();
second?.Dispose();
}
}
public void Dispose()
{
_primed?.Dispose();
_app.Dispose();
}
}

52
tests/Avalonia.Benchmarks/Text/TextLayoutProfile.cs

@ -0,0 +1,52 @@
using System;
using Avalonia.Media;
using Avalonia.Media.TextFormatting;
using Avalonia.UnitTests;
using BenchmarkDotNet.Attributes;
namespace Avalonia.Benchmarks.Text;
/// <summary>
/// Single-variant benchmark used as a profiling target. Pins the most expensive
/// realistic case from <see cref="HugeTextLayout"/> (Wrap=true, Trim=false on
/// the emoji block) so BDN's EventPipe profiler captures only this one run.
/// Not part of the regular perf coverage — leave excluded from normal sweeps
/// or run with an explicit filter.
/// </summary>
[MemoryDiagnoser]
[MinIterationTime(150)]
[MaxWarmupCount(15)]
public class TextLayoutProfile : IDisposable
{
private readonly IDisposable _app;
// Real Avalonia consumers (e.g. TextBlock) share a TextRunCache across
// re-layouts of the same string, so shaping cost amortises. The wrap-path
// perf doc relies on this benchmark to mirror that scenario; without the
// cache shaping dominates and obscures wrap-loop changes.
private readonly TextRunCache _runCache = new();
public TextLayoutProfile()
{
_app = UnitTestApplication.Start(TestServices.StyledWindow);
}
private const string Emojis = HugeTextLayout.EmojisText;
[Benchmark]
public TextLayout BuildEmojisWrapped()
{
var layout = new TextLayout(
Emojis,
Typeface.Default,
12d,
Brushes.Black,
maxWidth: 120,
textTrimming: TextTrimming.None,
textWrapping: TextWrapping.WrapWithOverflow,
textRunCache: _runCache);
layout.Dispose();
return layout;
}
public void Dispose() => _app?.Dispose();
}

132
tests/Avalonia.Benchmarks/Text/UnicodeBreakEnumeratorBenchmark.cs

@ -0,0 +1,132 @@
using System;
using System.Text;
using Avalonia.Media.TextFormatting.Unicode;
using BenchmarkDotNet.Attributes;
namespace Avalonia.Benchmarks.Text;
/// <summary>
/// End-to-end iteration cost for the three Unicode break enumerators. Their
/// inner loops call into the trie-backed property getters, so this benchmark
/// captures both the trie lookup cost and the per-segment algorithmic overhead
/// in a shape that mirrors what text layout pays per string.
/// </summary>
[MemoryDiagnoser]
[MinIterationTime(150)]
[MaxWarmupCount(15)]
public class UnicodeBreakEnumeratorBenchmark
{
public enum TextDistribution
{
/// <summary>Pure ASCII — no surrogate decoding cost.</summary>
Ascii,
/// <summary>BMP mix of Latin, Cyrillic, Greek, CJK.</summary>
Bmp,
/// <summary>Mix of BMP and supplementary plane scalars.</summary>
Supplementary,
}
private const int ScalarCount = 1024;
private string _text = string.Empty;
[Params(TextDistribution.Ascii, TextDistribution.Bmp, TextDistribution.Supplementary)]
public TextDistribution Distribution { get; set; }
[GlobalSetup]
public void Setup()
{
// The fixture intentionally mirrors CodepointBenchmark's distributions so
// the two benchmark suites are comparable: any added cost above the raw
// codepoint sequence is the break-algorithm overhead.
var rng = new Random(42);
var sb = new StringBuilder(ScalarCount * 2);
for (var i = 0; i < ScalarCount; i++)
{
var scalar = Distribution switch
{
TextDistribution.Ascii => (uint)rng.Next(0x20, 0x7F),
TextDistribution.Bmp => SampleBmpScalar(rng),
TextDistribution.Supplementary => SampleSupplementaryScalar(rng),
_ => (uint)'a',
};
sb.Append(char.ConvertFromUtf32((int)scalar));
}
_text = sb.ToString();
}
private static uint SampleBmpScalar(Random rng)
{
var bucket = rng.Next(4);
return bucket switch
{
0 => (uint)rng.Next(0x0020, 0x007F), // Latin
1 => (uint)rng.Next(0x0400, 0x0500), // Cyrillic
2 => (uint)rng.Next(0x0370, 0x0400), // Greek
_ => (uint)rng.Next(0x4E00, 0x9FFF), // CJK Unified Ideographs
};
}
private static uint SampleSupplementaryScalar(Random rng)
{
if ((rng.Next() & 1) == 0)
{
return SampleBmpScalar(rng);
}
var bucket = rng.Next(3);
return bucket switch
{
0 => (uint)rng.Next(0x1F300, 0x1F600), // emoji
1 => (uint)rng.Next(0x20000, 0x2A6DF), // CJK Ext B
_ => (uint)rng.Next(0x1D400, 0x1D800), // math alphanumerics
};
}
[Benchmark]
public int LineBreakEnumerator_Sequence()
{
var enumerator = new LineBreakEnumerator(_text.AsSpan());
var count = 0;
while (enumerator.MoveNext(out _))
{
count++;
}
return count;
}
[Benchmark]
public int WordBreakEnumerator_Sequence()
{
var enumerator = new WordBreakEnumerator(_text.AsSpan());
var count = 0;
while (enumerator.MoveNext(out _))
{
count++;
}
return count;
}
[Benchmark]
public int GraphemeEnumerator_Sequence()
{
var enumerator = new GraphemeEnumerator(_text.AsSpan());
var count = 0;
while (enumerator.MoveNext(out _))
{
count++;
}
return count;
}
}

153
tests/Avalonia.Benchmarks/Text/UnicodeTrieBenchmark.cs

@ -0,0 +1,153 @@
using System;
using Avalonia.Media.TextFormatting.Unicode;
using BenchmarkDotNet.Attributes;
namespace Avalonia.Benchmarks.Text;
/// <summary>
/// Raw <see cref="UnicodeTrie.Get"/> throughput across the four committed tries
/// (UnicodeData, BiDi, GraphemeBreak, EastAsianWidth) and three codepoint
/// distributions (ASCII, BMP, supplementary). Bypasses surrogate decoding so
/// the numbers reflect only the trie walk cost — useful as a regression target
/// when changing trie packing, branch ordering, or builder layout.
/// </summary>
[MemoryDiagnoser]
[MinIterationTime(150)]
[MaxWarmupCount(15)]
public class UnicodeTrieBenchmark
{
public enum CodepointDistribution
{
/// <summary>Printable ASCII (0x20..0x7E) — common BMP-non-surrogate path.</summary>
Ascii,
/// <summary>Full BMP excluding the surrogate range (0..0xD7FF, 0xE000..0xFFFF).</summary>
Bmp,
/// <summary>Supplementary plane (0x10000..0x10FFFF) — exercises two-level lookup.</summary>
Supplementary,
}
private const int CodepointCount = 4096;
private uint[] _codepoints = [];
[Params(CodepointDistribution.Ascii, CodepointDistribution.Bmp, CodepointDistribution.Supplementary)]
public CodepointDistribution Distribution { get; set; }
[GlobalSetup]
public void Setup()
{
// Fixed seed for reproducibility — numbers across CI runs should be
// comparable in the absence of generator / trie-walk changes.
var rng = new Random(42);
_codepoints = new uint[CodepointCount];
for (var i = 0; i < CodepointCount; i++)
{
_codepoints[i] = Distribution switch
{
CodepointDistribution.Ascii => (uint)rng.Next(0x20, 0x7F),
CodepointDistribution.Bmp => SampleBmp(rng),
CodepointDistribution.Supplementary => (uint)rng.Next(0x10000, 0x110000),
_ => 0,
};
}
}
private static uint SampleBmp(Random rng)
{
// Avoid the surrogate range — supplementary callers don't hit it in
// practice and we cover it via the dedicated codepoint fixture below.
var v = (uint)rng.Next(0, 0xFFFE);
return v is >= 0xD800 and <= 0xDFFF ? 0xE000 : v;
}
[Benchmark]
public uint Get_UnicodeData()
{
var trie = UnicodeDataTrie.Trie;
var codepoints = _codepoints;
var sum = 0u;
for (var i = 0; i < codepoints.Length; i++)
{
sum += trie.Get(codepoints[i]);
}
return sum;
}
[Benchmark]
public uint Get_BiDi()
{
var trie = BiDiTrie.Trie;
var codepoints = _codepoints;
var sum = 0u;
for (var i = 0; i < codepoints.Length; i++)
{
sum += trie.Get(codepoints[i]);
}
return sum;
}
[Benchmark]
public uint Get_GraphemeBreak()
{
var trie = GraphemeBreakTrie.Trie;
var codepoints = _codepoints;
var sum = 0u;
for (var i = 0; i < codepoints.Length; i++)
{
sum += trie.Get(codepoints[i]);
}
return sum;
}
[Benchmark]
public uint Get_EastAsianWidth()
{
var trie = EastAsianWidthTrie.Trie;
var codepoints = _codepoints;
var sum = 0u;
for (var i = 0; i < codepoints.Length; i++)
{
sum += trie.Get(codepoints[i]);
}
return sum;
}
/// <summary>
/// Worst-case "every property" pass — one <see cref="UnicodeTrie.Get"/> call
/// per trie per codepoint. Matches what a layout pipeline pass that needs
/// every property would pay if the four tries are kept separate. Establishes
/// a baseline for any future "merge packed properties" experiment.
/// </summary>
[Benchmark]
public uint Get_AllTriesPerCodepoint()
{
var unicodeData = UnicodeDataTrie.Trie;
var biDi = BiDiTrie.Trie;
var grapheme = GraphemeBreakTrie.Trie;
var eaw = EastAsianWidthTrie.Trie;
var codepoints = _codepoints;
var sum = 0u;
for (var i = 0; i < codepoints.Length; i++)
{
var cp = codepoints[i];
sum += unicodeData.Get(cp);
sum += biDi.Get(cp);
sum += grapheme.Get(cp);
sum += eaw.Get(cp);
}
return sum;
}
}

297
tests/Avalonia.Skia.UnitTests/Media/TextFormatting/ShapedBufferSharedStorageTests.cs

@ -0,0 +1,297 @@
using System;
using System.Globalization;
using Avalonia.Media;
using Avalonia.Media.TextFormatting;
using Avalonia.Skia;
using Avalonia.UnitTests;
using Xunit;
namespace Avalonia.Skia.UnitTests.Media.TextFormatting
{
/// <summary>
/// Exercises the ref-counted shared storage and the per-write generation
/// counter that <see cref="ShapedBuffer.Split"/> and
/// <see cref="ShapedBuffer.WithBidiLevel"/> rely on. Each test starts from
/// a freshly shaped buffer (so the glyph and cluster arrays are pool-rented
/// and ref-counted) and checks that:
/// <list type="bullet">
/// <item>aliases keep working after their source is disposed,</item>
/// <item><see cref="ShapedBuffer.Dispose"/> is idempotent,</item>
/// <item>indexer mutations propagate to siblings via the generation bump
/// (i.e. nobody is left with a stale cluster cache).</item>
/// </list>
/// </summary>
public class ShapedBufferSharedStorageTests
{
private const string AsciiText = "The quick brown fox";
[Fact]
public void Dispose_Is_Idempotent()
{
using (Start())
{
var buffer = ShapeAscii(AsciiText);
_ = buffer.TotalGlyphAdvance; // prime cluster cache so refs are non-null.
buffer.Dispose();
buffer.Dispose();
buffer.Dispose();
}
}
[Fact]
public void Split_Children_Survive_Parent_Disposal()
{
using (Start())
{
var parent = ShapeAscii(AsciiText);
var totalBefore = parent.TotalGlyphAdvance;
var split = parent.Split(parent.Text.Length / 2);
var first = split.First!;
var second = split.Second!;
// Prime sibling caches via the parent's cluster-cache reference
// (children inherit the parent's prefix sums by ref).
_ = first.TotalGlyphAdvance;
_ = second.TotalGlyphAdvance;
// Release the parent's references first; the ref-counted holders
// must keep the pool arrays alive for the surviving children.
parent.Dispose();
Assert.Equal(totalBefore, first.TotalGlyphAdvance + second.TotalGlyphAdvance, 3);
Assert.Equal(parent.Text.Length / 2, first.Text.Length);
Assert.True(first.Length > 0);
Assert.True(second.Length > 0);
first.Dispose();
second.Dispose();
}
}
[Fact]
public void Split_Parent_Survives_Children_Disposal()
{
using (Start())
{
using var parent = ShapeAscii(AsciiText);
var totalBefore = parent.TotalGlyphAdvance;
var split = parent.Split(parent.Text.Length / 2);
split.First!.Dispose();
split.Second!.Dispose();
// Parent's own refs must still hold the pool arrays alive.
Assert.Equal(totalBefore, parent.TotalGlyphAdvance, 6);
}
}
[Fact]
public void WithBidiLevel_Alias_Survives_Original_Disposal()
{
using (Start())
{
var original = ShapeAscii(AsciiText);
var totalBefore = original.TotalGlyphAdvance;
Assert.Equal(0, original.BidiLevel);
using var alias = original.WithBidiLevel(2);
original.Dispose();
Assert.Equal(totalBefore, alias.TotalGlyphAdvance, 6);
Assert.Equal(2, alias.BidiLevel);
Assert.Equal(original.Text.Length, alias.Text.Length);
}
}
[Fact]
public void WithBidiLevel_Returns_Same_Instance_When_Level_Matches()
{
using (Start())
{
using var original = ShapeAscii(AsciiText);
var alias = original.WithBidiLevel(original.BidiLevel);
Assert.Same(original, alias);
}
}
[Fact]
public void IndexerMutation_Invalidates_Own_Cluster_Cache()
{
using (Start())
{
using var buffer = ShapeAscii(AsciiText);
var advanceBefore = buffer.TotalGlyphAdvance;
var original = buffer[0];
// Mutate the leading glyph's advance via the indexer setter.
const double delta = 50d;
buffer[0] = new GlyphInfo(
original.GlyphIndex, original.GlyphCluster,
original.GlyphAdvance + delta, original.GlyphOffset);
// The cache must be rebuilt against the new glyph data.
Assert.Equal(advanceBefore + delta, buffer.TotalGlyphAdvance, 3);
}
}
[Fact]
public void IndexerMutation_On_Parent_Invalidates_Sibling_Caches_After_Split()
{
using (Start())
{
using var parent = ShapeAscii(AsciiText);
var splitIndex = parent.Text.Length / 2;
var split = parent.Split(splitIndex);
using var first = split.First!;
using var second = split.Second!;
// Prime both children's views of the inherited cluster cache.
var firstBefore = first.TotalGlyphAdvance;
var secondBefore = second.TotalGlyphAdvance;
// Mutate parent[0]: this lives in the first child's slice but
// bumps the generation counter on the shared glyph holder so
// the second child also sees the change (and rebuilds if needed).
const double delta = 25d;
var first0 = parent[0];
parent[0] = new GlyphInfo(
first0.GlyphIndex, first0.GlyphCluster,
first0.GlyphAdvance + delta, first0.GlyphOffset);
Assert.Equal(firstBefore + delta, first.TotalGlyphAdvance, 3);
// Second child's range doesn't include glyph 0 so its advance is unchanged,
// but its cache must still have been invalidated/rebuilt without error.
Assert.Equal(secondBefore, second.TotalGlyphAdvance, 3);
}
}
[Fact]
public void IndexerMutation_On_Child_Invalidates_Parent_And_Sibling()
{
using (Start())
{
using var parent = ShapeAscii(AsciiText);
var splitIndex = parent.Text.Length / 2;
var split = parent.Split(splitIndex);
using var first = split.First!;
using var second = split.Second!;
var parentBefore = parent.TotalGlyphAdvance;
_ = first.TotalGlyphAdvance;
_ = second.TotalGlyphAdvance;
const double delta = 17d;
var glyph = first[0];
first[0] = new GlyphInfo(
glyph.GlyphIndex, glyph.GlyphCluster,
glyph.GlyphAdvance + delta, glyph.GlyphOffset);
Assert.Equal(parentBefore + delta, parent.TotalGlyphAdvance, 3);
}
}
[Fact]
public void IndexerMutation_Invalidates_WithBidiLevel_Alias_Cache()
{
using (Start())
{
using var original = ShapeAscii(AsciiText);
using var alias = original.WithBidiLevel(2);
var aliasBefore = alias.TotalGlyphAdvance;
const double delta = 33d;
var glyph = original[0];
original[0] = new GlyphInfo(
glyph.GlyphIndex, glyph.GlyphCluster,
glyph.GlyphAdvance + delta, glyph.GlyphOffset);
Assert.Equal(aliasBefore + delta, alias.TotalGlyphAdvance, 3);
}
}
// Regression: when a buffer is mutated *before* it is Split / aliased,
// the parent's _cacheGeneration is already > 0 by the time the alias
// inherits the cluster cache. The alias constructor must copy that
// generation onto the child; otherwise the child's stamp stays at 0,
// EnsureClusterCache sees a mismatch on first access and rebuilds a
// fresh cache instead of reusing the parent's pooled arrays — silently
// defeating the cached-split fast path.
[Fact]
public void Split_Children_Share_Parent_Cluster_Cache_When_Parent_Was_Mutated_Before_Split()
{
using (Start())
{
using var parent = ShapeAscii(AsciiText);
// Mutate first so the shared glyph holder's generation is
// non-zero before the cluster cache is built.
var first0 = parent[0];
parent[0] = new GlyphInfo(
first0.GlyphIndex, first0.GlyphCluster,
first0.GlyphAdvance + 10d, first0.GlyphOffset);
// Prime the parent's cluster cache against the bumped generation.
_ = parent.TotalGlyphAdvance;
var parentPrefix = parent.ClusterPrefix;
Assert.NotNull(parentPrefix);
var split = parent.Split(parent.Text.Length / 2);
using var first = split.First!;
using var second = split.Second!;
// Touching the child's metrics must reuse the parent's prefix
// array, not rebuild a fresh one.
_ = first.TotalGlyphAdvance;
_ = second.TotalGlyphAdvance;
Assert.Same(parentPrefix, first.ClusterPrefix);
Assert.Same(parentPrefix, second.ClusterPrefix);
}
}
[Fact]
public void WithBidiLevel_Alias_Shares_Cluster_Cache_When_Original_Was_Mutated_Before_Alias()
{
using (Start())
{
using var original = ShapeAscii(AsciiText);
var first0 = original[0];
original[0] = new GlyphInfo(
first0.GlyphIndex, first0.GlyphCluster,
first0.GlyphAdvance + 7d, first0.GlyphOffset);
_ = original.TotalGlyphAdvance;
var originalPrefix = original.ClusterPrefix;
Assert.NotNull(originalPrefix);
using var alias = original.WithBidiLevel(2);
_ = alias.TotalGlyphAdvance;
Assert.Same(originalPrefix, alias.ClusterPrefix);
}
}
private static ShapedBuffer ShapeAscii(string text)
{
var options = new TextShaperOptions(
Typeface.Default.GlyphTypeface, 12, 0, CultureInfo.CurrentCulture);
return TextShaper.Current.ShapeText(text, options);
}
private static IDisposable Start()
{
return UnitTestApplication.Start(TestServices.MockPlatformRenderInterface
.With(renderInterface: new PlatformRenderInterface(null),
fontManagerImpl: new CustomFontManagerImpl()));
}
}
}

382
tests/Avalonia.Skia.UnitTests/Media/TextFormatting/SplitTextRunsTests.cs

@ -0,0 +1,382 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using Avalonia.Media;
using Avalonia.Media.TextFormatting;
using Avalonia.Utilities;
using Xunit;
using static Avalonia.Media.TextFormatting.FormattingObjectPool;
namespace Avalonia.Skia.UnitTests.Media.TextFormatting
{
/// <summary>
/// Direct tests for <c>TextFormatterImpl.SplitTextRuns</c>. Calls the
/// internal method via <c>InternalsVisibleTo</c> so each branch can be
/// exercised in isolation with synthetic <see cref="TextRun"/> stubs —
/// independent of the wrap algorithm that's its main caller. Many of
/// these scenarios are unreachable through the wrap path on its own
/// (the wrap loop carefully avoids requesting splits inside non-splittable
/// runs), but the method is also called by <c>TextCollapsingProperties</c>
/// and the ellipsis types, which have weaker invariants.
///
/// Key invariants under test (must hold regardless of split position):
/// * Sum of run lengths is preserved (no content lost or duplicated).
/// * Concatenated text (across all runs in first ++ second) equals input.
/// * Reported <c>firstLength</c> equals the sum of lengths in first.
/// </summary>
public class SplitTextRunsTests
{
[Fact]
public void Split_Inside_NonShaped_Run_Does_Not_Drop_Run()
{
// Bug repro: a DrawableTextRun-like atomic run with length > 1, asked
// to split at length=1 (strictly inside). Before the fix, the current
// implementation dropped the run from both halves. After the fix it
// must appear in either first or second.
var pool = FormattingObjectPool.Instance;
var runs = new TextRun[] { new TestStubRun("drawable", length: 3) };
var (first, second) = TextFormatterImpl.SplitTextRuns(runs, length: 1, pool, out var firstLength);
try
{
AssertContentPreserved(runs, first, second, firstLength);
}
finally
{
pool.TextRunLists.Return(ref first);
pool.TextRunLists.Return(ref second);
}
}
[Fact]
public void Split_Length_Zero_Returns_Null_First_And_All_In_Second()
{
var pool = FormattingObjectPool.Instance;
var runs = new TextRun[]
{
new TestStubRun("a", length: 2),
new TestStubRun("b", length: 3),
};
var (first, second) = TextFormatterImpl.SplitTextRuns(runs, length: 0, pool, out var firstLength);
try
{
Assert.Null(first);
Assert.NotNull(second);
Assert.Equal(2, second!.Count);
Assert.Equal(0, firstLength);
Assert.Equal(5, second.Sum(r => r.Length));
}
finally
{
pool.TextRunLists.Return(ref first);
pool.TextRunLists.Return(ref second);
}
}
[Fact]
public void Split_Length_Equals_Total_Puts_All_In_First()
{
var pool = FormattingObjectPool.Instance;
var runs = new TextRun[]
{
new TestStubRun("a", length: 2),
new TestStubRun("b", length: 3),
};
var (first, second) = TextFormatterImpl.SplitTextRuns(runs, length: 5, pool, out var firstLength);
try
{
Assert.NotNull(first);
Assert.Equal(2, first!.Count);
Assert.Null(second);
Assert.Equal(5, firstLength);
AssertContentPreserved(runs, first, second, firstLength);
}
finally
{
pool.TextRunLists.Return(ref first);
pool.TextRunLists.Return(ref second);
}
}
[Fact]
public void Split_Length_Past_Total_Puts_All_In_First()
{
var pool = FormattingObjectPool.Instance;
var runs = new TextRun[] { new TestStubRun("a", length: 2) };
var (first, second) = TextFormatterImpl.SplitTextRuns(runs, length: 99, pool, out var firstLength);
try
{
Assert.NotNull(first);
Assert.Equal(1, first!.Count);
Assert.Null(second);
Assert.Equal(2, firstLength);
}
finally
{
pool.TextRunLists.Return(ref first);
pool.TextRunLists.Return(ref second);
}
}
[Fact]
public void Split_At_Boundary_Between_Two_Runs_Goes_To_First_Or_Second_Cleanly()
{
var pool = FormattingObjectPool.Instance;
var runs = new TextRun[]
{
new TestStubRun("a", length: 2),
new TestStubRun("b", length: 3),
};
// length=2 means "everything up to and including the first run on first".
var (first, second) = TextFormatterImpl.SplitTextRuns(runs, length: 2, pool, out var firstLength);
try
{
Assert.NotNull(first);
Assert.Equal(1, first!.Count);
Assert.Same(runs[0], first[0]);
Assert.NotNull(second);
Assert.Equal(1, second!.Count);
Assert.Same(runs[1], second[0]);
Assert.Equal(2, firstLength);
AssertContentPreserved(runs, first, second, firstLength);
}
finally
{
pool.TextRunLists.Return(ref first);
pool.TextRunLists.Return(ref second);
}
}
[Fact]
public void Split_Before_Drawable_That_Does_Not_Fit_Puts_Drawable_In_Second()
{
// [text(2), drawable(1), text(2)] split at length=2.
// Wrap normally chooses currentLength==length here ("drawable doesn't fit
// on this line, push to next"). The == branch in SplitTextRuns already
// handles this correctly today — assert that it stays correct.
var pool = FormattingObjectPool.Instance;
var runs = new TextRun[]
{
new TestStubRun("ab", length: 2),
new TestStubRun("X", length: 1),
new TestStubRun("cd", length: 2),
};
var (first, second) = TextFormatterImpl.SplitTextRuns(runs, length: 2, pool, out var firstLength);
try
{
Assert.Equal(2, firstLength);
AssertContentPreserved(runs, first, second, firstLength);
Assert.Equal(1, first!.Count);
Assert.Equal(2, second!.Count);
Assert.Same(runs[1], second[0]); // drawable at start of second
}
finally
{
pool.TextRunLists.Return(ref first);
pool.TextRunLists.Return(ref second);
}
}
[Fact]
public void Split_Strictly_Inside_NonShaped_Run_Snaps_Before_It()
{
// [text(2), drawable(3), text(2)] split at length=3 — strictly inside
// the drawable. The drawable is atomic, so the split must snap to a
// boundary. The current contract: snap BEFORE the drawable, so
// firstLength is shorter than requested but content is preserved.
var pool = FormattingObjectPool.Instance;
var runs = new TextRun[]
{
new TestStubRun("ab", length: 2),
new TestStubRun("XXX", length: 3),
new TestStubRun("cd", length: 2),
};
var (first, second) = TextFormatterImpl.SplitTextRuns(runs, length: 3, pool, out var firstLength);
try
{
AssertContentPreserved(runs, first, second, firstLength);
Assert.True(firstLength is 2 or 5,
$"Expected firstLength to snap to 2 (before drawable) or 5 (after drawable); got {firstLength}.");
}
finally
{
pool.TextRunLists.Return(ref first);
pool.TextRunLists.Return(ref second);
}
}
[Fact]
public void Split_Strictly_Inside_NonShaped_Run_At_Start_Of_List_Overflows()
{
// [drawable(5)] split at length=2 — the drawable is the first run, has
// no content before it, and is bigger than the requested length. If we
// snapped before, first would be empty and the caller would loop
// forever. The contract here is to overflow the drawable into first
// (the same "include at least one cluster" rule the wrap loop has for
// ShapedTextRuns at the start of a line).
var pool = FormattingObjectPool.Instance;
var runs = new TextRun[] { new TestStubRun("XXXXX", length: 5) };
var (first, second) = TextFormatterImpl.SplitTextRuns(runs, length: 2, pool, out var firstLength);
try
{
AssertContentPreserved(runs, first, second, firstLength);
Assert.Equal(5, firstLength); // overflow
Assert.NotNull(first);
Assert.Equal(1, first!.Count);
Assert.Same(runs[0], first[0]);
}
finally
{
pool.TextRunLists.Return(ref first);
pool.TextRunLists.Return(ref second);
}
}
[Fact]
public void Split_At_Boundary_Before_Drawable_Mid_List()
{
// [shape(3), drawable(2), shape(3)] split at length=3 — boundary at
// end of first shape. == branch.
var pool = FormattingObjectPool.Instance;
var runs = new TextRun[]
{
new TestStubRun("AAA", length: 3),
new TestStubRun("XX", length: 2),
new TestStubRun("BBB", length: 3),
};
var (first, second) = TextFormatterImpl.SplitTextRuns(runs, length: 3, pool, out var firstLength);
try
{
Assert.Equal(3, firstLength);
AssertContentPreserved(runs, first, second, firstLength);
}
finally
{
pool.TextRunLists.Return(ref first);
pool.TextRunLists.Return(ref second);
}
}
[Fact]
public void Split_At_Boundary_After_Drawable_Mid_List()
{
// Boundary at end of drawable. == branch.
var pool = FormattingObjectPool.Instance;
var runs = new TextRun[]
{
new TestStubRun("AAA", length: 3),
new TestStubRun("XX", length: 2),
new TestStubRun("BBB", length: 3),
};
var (first, second) = TextFormatterImpl.SplitTextRuns(runs, length: 5, pool, out var firstLength);
try
{
Assert.Equal(5, firstLength);
AssertContentPreserved(runs, first, second, firstLength);
Assert.Equal(2, first!.Count);
Assert.Equal(1, second!.Count);
}
finally
{
pool.TextRunLists.Return(ref first);
pool.TextRunLists.Return(ref second);
}
}
[Fact]
public void Split_With_Zero_Length_Run_Inside_Does_Not_Drop_It()
{
// Zero-length runs (e.g. TextEndOfParagraph variants) appear after
// shaped runs. Splitting at the boundary should keep the zero-length
// run somewhere — not silently discard it.
var pool = FormattingObjectPool.Instance;
var runs = new TextRun[]
{
new TestStubRun("ab", length: 2),
new TestStubRun("zero", length: 0),
new TestStubRun("cd", length: 2),
};
var (first, second) = TextFormatterImpl.SplitTextRuns(runs, length: 2, pool, out var firstLength);
try
{
Assert.Equal(2, firstLength);
// The zero-length run still has identity — assert it's in exactly one half.
var allRuns = (first?.Cast<TextRun>() ?? Enumerable.Empty<TextRun>())
.Concat(second?.Cast<TextRun>() ?? Enumerable.Empty<TextRun>())
.ToList();
Assert.Equal(3, allRuns.Count);
Assert.Contains(runs[1], allRuns);
}
finally
{
pool.TextRunLists.Return(ref first);
pool.TextRunLists.Return(ref second);
}
}
/// <summary>
/// Asserts the central invariant: sum of (first|second) run lengths
/// equals the input total, and <paramref name="firstLength"/> matches the
/// sum of first's run lengths. Any test where this fails means content
/// was lost or duplicated.
/// </summary>
private static void AssertContentPreserved(
IReadOnlyList<TextRun> input,
RentedList<TextRun>? first,
RentedList<TextRun>? second,
int firstLength)
{
var inputTotal = input.Sum(r => r.Length);
var firstTotal = first?.Sum(r => r.Length) ?? 0;
var secondTotal = second?.Sum(r => r.Length) ?? 0;
Assert.Equal(inputTotal, firstTotal + secondTotal);
Assert.Equal(firstTotal, firstLength);
}
/// <summary>
/// Minimal concrete <see cref="TextRun"/> for tests — neither a
/// <c>ShapedTextRun</c> nor a <c>DrawableTextRun</c> from the consumer
/// perspective. Behaves like an atomic, non-splittable run with a
/// configurable length, which is exactly the class of input that
/// triggers the <c>SplitTextRuns</c> drop-current-run bug.
/// </summary>
private sealed class TestStubRun : TextRun
{
private readonly string _name;
public TestStubRun(string name, int length)
{
_name = name;
Length = length;
}
public override int Length { get; }
public override string ToString() => $"TestStubRun({_name}, len={Length})";
}
}
}

291
tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterWrapCharacterizationTests.cs

@ -0,0 +1,291 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using Avalonia.Media;
using Avalonia.Media.TextFormatting;
using Avalonia.Media.TextFormatting.Unicode;
using Xunit;
namespace Avalonia.Skia.UnitTests.Media.TextFormatting
{
/// <summary>
/// Characterization tests for <c>TextFormatterImpl.PerformTextWrapping</c>
/// and its helpers (<c>MeasureLength</c>, <c>SplitTextRuns</c>,
/// <c>ResetTrailingWhitespaceBidiLevels</c>).
/// </summary>
public class TextFormatterWrapCharacterizationTests
{
[Fact]
public void Wrap_With_Infinite_Width_Yields_Single_Line_With_All_Runs()
{
using (TextFormatterTests.Start())
{
var line = WrapSingleLine("Hello world", paragraphWidth: double.PositiveInfinity);
Assert.Equal("Hello world".Length, line.Length);
Assert.True(line.WidthIncludingTrailingWhitespace > 0);
}
}
[Fact]
public void Wrap_With_Zero_Width_Forces_Minimum_Cluster()
{
// Width too small to fit any cluster — the implementation falls
// back to one grapheme. This is the documented WrapWithOverflow
// contract that lines 882-902 of TextFormatterImpl encode.
using (TextFormatterTests.Start())
{
var line = WrapSingleLine("Hello", paragraphWidth: 0.001);
Assert.True(line.Length >= 1,
"Wrap should always advance at least one grapheme even at zero width.");
}
}
[Theory]
[InlineData("AAAA BBBB CCCC DDDD", 40)]
[InlineData("AAAA BBBB CCCC DDDD", 80)]
[InlineData("AAAA BBBB CCCC DDDD", 120)]
public void Wrap_Sum_Of_Line_Lengths_Equals_Input_Length(string text, double paragraphWidth)
{
using (TextFormatterTests.Start())
{
var lines = WrapAllLines(text, paragraphWidth);
var totalLength = lines.Sum(l => l.Length);
Assert.Equal(text.Length, totalLength);
}
}
[Theory]
[InlineData("AAAA BBBB CCCC DDDD", 40)]
[InlineData("AAAA BBBB CCCC DDDD", 80)]
public void Wrap_Each_Line_Width_Within_Paragraph_Width(string text, double paragraphWidth)
{
using (TextFormatterTests.Start())
{
var lines = WrapAllLines(text, paragraphWidth);
foreach (var line in lines)
{
// Width (excluding trailing whitespace) should fit the
// paragraph. The +1.0 tolerance handles the documented
// "single cluster wider than paragraph" overflow case.
Assert.True(line.Width <= paragraphWidth + 1.0,
$"Line width {line.Width} exceeds paragraph width {paragraphWidth} by more than 1px.");
}
}
}
[Fact]
public void Wrap_Does_Not_Produce_Empty_Lines_For_NonEmpty_Input()
{
using (TextFormatterTests.Start())
{
var lines = WrapAllLines("the quick brown fox jumps over the lazy dog", paragraphWidth: 50);
foreach (var line in lines)
{
Assert.True(line.Length > 0, "Wrap should never emit a zero-length line for non-empty input.");
}
}
}
[Fact]
public void Wrap_Points_Are_Grapheme_Boundaries()
{
// Multi-codepoint graphemes (emoji ZWJ sequences) must never be
// split by the wrap algorithm — the wrap point has to coincide
// with a grapheme boundary.
using (TextFormatterTests.Start())
{
const string text = "abc 😀😀😀😀 xyz";
var lines = WrapAllLines(text, paragraphWidth: 30);
var boundaries = new HashSet<int>();
var graphemeEnumerator = new GraphemeEnumerator(text.AsSpan());
boundaries.Add(0);
var pos = 0;
while (graphemeEnumerator.MoveNext(out var grapheme))
{
pos += grapheme.Length;
boundaries.Add(pos);
}
var cumulative = 0;
foreach (var line in lines)
{
cumulative += line.Length;
Assert.Contains(cumulative, boundaries);
}
}
}
[Fact]
public void Wrap_Honours_Required_Break_Even_With_Available_Width()
{
using (TextFormatterTests.Start())
{
var line = WrapSingleLine("ab\ncd", paragraphWidth: double.PositiveInfinity);
// Hard break sits at index 2 (the '\n'); PositionWrap is 3
// (consumes the '\n').
Assert.Equal(3, line.Length);
}
}
[Fact]
public void Wrap_Hard_Break_With_CRLF_Counts_Both_Characters()
{
using (TextFormatterTests.Start())
{
var line = WrapSingleLine("ab\r\ncd", paragraphWidth: double.PositiveInfinity);
// CRLF is a single break with PositionWrap = 4 (consumes both chars).
Assert.Equal(4, line.Length);
}
}
[Fact]
public void WrapWithOverflow_Long_Word_Followed_By_Space_Wraps_After_Space()
{
// The word "supercalifragilistic" has no break inside it. At a
// small paragraph width with WrapWithOverflow, the wrap algorithm
// should let the word overflow as a whole, then wrap on the next
// break (the trailing space).
using (TextFormatterTests.Start())
{
var line = WrapSingleLine("supercalifragilistic next", paragraphWidth: 30,
wrapping: TextWrapping.WrapWithOverflow);
Assert.True(line.Length >= "supercalifragilistic".Length,
$"Expected first line to contain at least the whole long word; got length {line.Length}.");
Assert.True(line.Length <= "supercalifragilistic ".Length,
"First line should not extend past the trailing space after the long word.");
}
}
[Fact]
public void Wrap_Strict_Long_Word_Splits_Inside_When_NoWrapPosition_Available()
{
// Pure Wrap (not WrapWithOverflow) on an unbreakable word: the
// implementation falls back to splitting inside the word at the
// best available cluster boundary.
using (TextFormatterTests.Start())
{
var line = WrapSingleLine("supercalifragilistic", paragraphWidth: 30,
wrapping: TextWrapping.Wrap);
Assert.True(line.Length > 0);
Assert.True(line.Length < "supercalifragilistic".Length,
"Strict wrap should split inside the long word.");
}
}
[Fact]
public void Wrap_Continues_From_Previous_LineBreak()
{
using (TextFormatterTests.Start())
{
var text = "AAAA BBBB CCCC DDDD";
var lines = WrapAllLines(text, paragraphWidth: 40);
Assert.True(lines.Count >= 2, "Test setup must wrap onto at least two lines.");
// Lines after the first reuse runs via WrappingTextLineBreak.
// The contract: concatenated, they reproduce the original.
var rebuilt = string.Concat(lines.Select(l => GetLineText(l)));
Assert.Equal(text, rebuilt);
}
}
[Fact]
public void Wrap_With_LTR_Text_Does_Not_Touch_Trailing_Whitespace_Bidi()
{
// ResetTrailingWhitespaceBidiLevels is a no-op when the run's
// BidiLevel already matches the paragraph. The wrap result should
// be identical to a non-wrapped layout of the same paragraph.
using (TextFormatterTests.Start())
{
var text = "Hello world from Avalonia";
var wrappedLines = WrapAllLines(text, paragraphWidth: 80);
var rebuilt = string.Concat(wrappedLines.Select(l => GetLineText(l)));
Assert.Equal(text, rebuilt);
}
}
[Fact]
public void Wrap_Empty_Text_Yields_Null()
{
using (TextFormatterTests.Start())
{
var defaultProperties = new GenericTextRunProperties(Typeface.Default, 12,
foregroundBrush: Brushes.Black);
var paragraphProperties = new GenericTextParagraphProperties(
defaultProperties, textWrapping: TextWrapping.Wrap);
var textSource = new SingleBufferTextSource("", defaultProperties);
var formatter = new TextFormatterImpl();
var line = formatter.FormatLine(textSource, 0, 100, paragraphProperties);
Assert.Null(line);
}
}
private static TextLine WrapSingleLine(string text, double paragraphWidth,
TextWrapping wrapping = TextWrapping.Wrap)
{
var defaultProperties = new GenericTextRunProperties(Typeface.Default, 12,
foregroundBrush: Brushes.Black);
var paragraphProperties = new GenericTextParagraphProperties(defaultProperties,
textWrapping: wrapping);
var textSource = new SingleBufferTextSource(text, defaultProperties);
var formatter = new TextFormatterImpl();
var line = formatter.FormatLine(textSource, 0, paragraphWidth, paragraphProperties);
Assert.NotNull(line);
return line!;
}
private static List<TextLine> WrapAllLines(string text, double paragraphWidth,
TextWrapping wrapping = TextWrapping.Wrap)
{
var defaultProperties = new GenericTextRunProperties(Typeface.Default, 12,
foregroundBrush: Brushes.Black);
var paragraphProperties = new GenericTextParagraphProperties(defaultProperties,
textWrapping: wrapping);
var textSource = new SingleBufferTextSource(text, defaultProperties);
var formatter = new TextFormatterImpl();
var lines = new List<TextLine>();
var pos = 0;
TextLineBreak? previousLineBreak = null;
while (pos < text.Length)
{
var line = formatter.FormatLine(textSource, pos, paragraphWidth,
paragraphProperties, previousLineBreak);
if (line == null)
{
break;
}
lines.Add(line);
previousLineBreak = line.TextLineBreak;
pos += line.Length;
if (pos > 0 && lines.Count > 200)
{
Assert.Fail("Wrap appears to be looping; bailing out.");
}
}
return lines;
}
private static string GetLineText(TextLine line)
{
var sb = new System.Text.StringBuilder();
foreach (var run in line.TextRuns)
{
sb.Append(run.Text.Span);
}
return sb.ToString();
}
}
}

7
tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs

@ -1726,7 +1726,12 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
Assert.Equal(1, bounds.Count);
Assert.Equal(36.005859374999993, bounds[0].Rectangle.Left);
// Layout bounds depend on the order in which floating-point glyph
// advances are summed inside ShapedBuffer; that order changed when
// the cluster-width cache landed, so this value moved by one ULP
// (36.005859374999993 → 36.005859375). Both round to the same
// sub-pixel position; use a tolerant compare to capture intent.
Assert.Equal(36.005859375, bounds[0].Rectangle.Left, 5);
bounds = textLine.GetTextBounds(0, 1);

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

@ -113,6 +113,87 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
}
}
[Fact]
public void ClusterCache_SimpleMode_For_Latin_Text()
{
using (Start())
{
var buffer = TextShaper.Current.ShapeText("ABCDEFGH", new TextShaperOptions(Typeface.Default.GlyphTypeface));
Assert.True(buffer.IsClusterCacheSimple, "Single-codepoint LTR text should use the simple cluster-cache mode.");
}
}
[Fact]
public void ClusterCache_SimpleMode_Measures_Correctly()
{
using (Start())
{
var buffer = TextShaper.Current.ShapeText("ABCDEFGH", new TextShaperOptions(Typeface.Default.GlyphTypeface));
Assert.True(buffer.IsClusterCacheSimple);
// Sum advances linearly and compare to TotalGlyphAdvance.
var expectedTotal = 0d;
for (var i = 0; i < buffer.Length; i++)
{
expectedTotal += buffer[i].GlyphAdvance;
}
Assert.Equal(expectedTotal, buffer.TotalGlyphAdvance, 5);
// Measure: ask for the width of the first 3 glyphs.
var threeGlyphsWidth = buffer[0].GlyphAdvance + buffer[1].GlyphAdvance + buffer[2].GlyphAdvance;
var fit = buffer.MeasureCharactersThatFit(threeGlyphsWidth, out var widthConsumed);
Assert.Equal(3, fit);
Assert.Equal(threeGlyphsWidth, widthConsumed, 5);
// FirstClusterCharLength must be 1 in simple mode.
Assert.Equal(1, buffer.FirstClusterCharLength);
}
}
[Fact]
public void ClusterCache_SimpleMode_Survives_Split()
{
using (Start())
{
var buffer = TextShaper.Current.ShapeText("ABCDEFGH", new TextShaperOptions(Typeface.Default.GlyphTypeface));
Assert.True(buffer.IsClusterCacheSimple);
var split = buffer.Split(3);
Assert.NotNull(split.First);
Assert.NotNull(split.Second);
Assert.Equal(3, split.First!.Length);
Assert.Equal(5, split.Second!.Length);
Assert.True(split.First.IsClusterCacheSimple, "Split halves of a simple-mode buffer should also be simple-mode.");
Assert.True(split.Second.IsClusterCacheSimple);
var firstWidth = buffer[0].GlyphAdvance + buffer[1].GlyphAdvance + buffer[2].GlyphAdvance;
Assert.Equal(firstWidth, split.First.TotalGlyphAdvance, 5);
}
}
[Fact]
public void ClusterCache_NotSimpleMode_For_ComplexClusters()
{
using (Start())
{
var typeface = new Typeface(FontFamily.Parse("resm:Avalonia.Skia.UnitTests.Fonts?assembly=Avalonia.Skia.UnitTests#Cascadia Code"));
// Same text the existing Should_Not_Split_Cluster test uses: contains a
// two-codepoint cluster that breaks the one-char-per-cluster invariant.
var buffer = TextShaper.Current.ShapeText("a\"๊a", new TextShaperOptions(typeface.GlyphTypeface));
Assert.False(buffer.IsClusterCacheSimple,
"Multi-char clusters should fall back to the full cluster-start-chars table.");
}
}
private static IDisposable Start()
{
var disposable = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface

Loading…
Cancel
Save