diff --git a/.gitignore b/.gitignore index 06caf2b7d4..22077f6402 100644 --- a/.gitignore +++ b/.gitignore @@ -225,3 +225,5 @@ src/Browser/Avalonia.Browser/staticwebassets # Claude agent worktrees .claude/worktrees/ /.claude/settings.local.json +/planning +BenchmarkDotNet.Artifacts.* diff --git a/src/Avalonia.Base/Media/GlyphRun.cs b/src/Avalonia.Base/Media/GlyphRun.cs index cccef8f938..399af012eb 100644 --- a/src/Avalonia.Base/Media/GlyphRun.cs +++ b/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; diff --git a/src/Avalonia.Base/Media/TextFormatting/ShapedBuffer.cs b/src/Avalonia.Base/Media/TextFormatting/ShapedBuffer.cs index 3643d7807b..581ed901d0 100644 --- a/src/Avalonia.Base/Media/TextFormatting/ShapedBuffer.cs +++ b/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, IDisposable { - private GlyphInfo[]? _rentedBuffer; + /// + /// Disposable wrapper around an -rented array. + /// Combined with this gives + /// shared, ref-counted ownership of its glyph and cluster-cache storage so + /// that and can safely alias + /// the same backing arrays. + /// + internal sealed class PooledArray : IDisposable + { + private T[]? _array; + private int _generation; + + public PooledArray(int minLength) + { + _array = ArrayPool.Shared.Rent(minLength); + } + + public T[] Array => _array ?? throw new ObjectDisposedException(nameof(PooledArray)); + + /// + /// 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. + /// + 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.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>? _glyphRef; private ArraySlice _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 . + // + // 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.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 + // handles. The hot-path read fields + // _clusterPrefix and _clusterStartChars alias the rented + // arrays directly so measure/split loops stay indirection-free. Split + // children and 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>? _prefixRef; + private IRef>? _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 text, int bufferLength, GlyphTypeface glyphTypeface, double fontRenderingEmSize, sbyte bidiLevel) { Text = text; - _rentedBuffer = ArrayPool.Shared.Rent(bufferLength); - _glyphInfos = new ArraySlice(_rentedBuffer, 0, bufferLength); + _glyphRef = RefCountable.Create(new PooledArray(bufferLength)); + _glyphInfos = new ArraySlice(_glyphRef.Item.Array, 0, bufferLength); GlyphTypeface = glyphTypeface; FontRenderingEmSize = fontRenderingEmSize; BidiLevel = bidiLevel; @@ -32,6 +145,44 @@ namespace Avalonia.Media.TextFormatting BidiLevel = bidiLevel; } + /// + /// Internal constructor used by and to + /// alias the source buffer's pooled glyph storage and (optionally) cluster cache. + /// Each non-null ref is d so the underlying arrays survive + /// until every sibling has been disposed. When + /// is null the alias starts without a cluster cache and will build its own lazily. + /// + private ShapedBuffer(ReadOnlyMemory text, ArraySlice glyphInfos, + GlyphTypeface glyphTypeface, double fontRenderingEmSize, sbyte bidiLevel, + IRef>? sourceGlyphRef, + IRef>? sourcePrefixRef, + IRef>? 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; + } + } + /// /// The buffer's length. /// @@ -69,12 +220,35 @@ namespace Avalonia.Media.TextFormatting public void Dispose() { - if (_rentedBuffer is not null) + if (_disposed) { - ArrayPool.Shared.Return(_rentedBuffer); - _rentedBuffer = null; - _glyphInfos = ArraySlice.Empty; // ensure we don't misuse the returned array + return; } + _disposed = true; + + _glyphRef?.Dispose(); + _glyphRef = null; + _glyphInfos = ArraySlice.Empty; // ensure we don't misuse a returned array + + ReleaseClusterCacheRefs(); + _clusterPrefix = null; + _clusterStartChars = null; + _clusterStartIdx = 0; + _clusterCount = 0; + } + + /// + /// 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 + /// and . + /// + 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(); + } + } + + /// + /// Test hook: indicates that the cluster cache is using the + /// one-char-per-cluster fast path (no _clusterStartChars allocation). + /// Materialises the cache as a side effect, so call after the buffer is + /// fully populated. + /// + internal bool IsClusterCacheSimple + { + get + { + EnsureClusterCache(); + return _clusterStartChars is null; + } + } + + /// + /// Test hook: exposes the backing cluster-prefix array reference so unit + /// tests can assert that / + /// aliases share the parent's cache rather than rebuilding their own. + /// Returns null when no cache has been built yet. + /// + internal double[]? ClusterPrefix => _clusterPrefix; + + /// + /// 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. + /// + internal double TotalGlyphAdvance + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get + { + var prefix = EnsureClusterCache(); + var start = _clusterStartIdx; + return prefix[start + _clusterCount] - prefix[start]; + } + } + + /// + /// Finds how many text characters from the start of this buffer fit within + /// , walking in logical cluster order. + /// Returns the character count and the width consumed (always <= availableWidth + /// unless the very first cluster overflows, in which case the caller is expected + /// to honour the overflow contract documented in TextFormatterImpl.MeasureLength). + /// + 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]; + } + + /// + /// Returns the character length of the first logical cluster in this buffer. + /// Used by MeasureLength to satisfy the "include at least one cluster" + /// rule when even the first cluster does not fit the paragraph width. + /// + 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]; + } + } + + /// + /// Ensures the cluster cache is built in logical 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. + /// + 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(clusters + 1); + _prefixRef = RefCountable.Create(prefixHolder); + var prefix = prefixHolder.Array; + + int[]? startChars = null; + if (!simple) + { + var startsHolder = new PooledArray(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; + } + + /// + /// 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. + /// + 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 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>? prefixRef = null; + IRef>? 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.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(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(first, second); } diff --git a/src/Avalonia.Base/Media/TextFormatting/TextFormatterImpl.cs b/src/Avalonia.Base/Media/TextFormatting/TextFormatterImpl.cs index fe5e3ae8b0..b402525891 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextFormatterImpl.cs +++ b/src/Avalonia.Base/Media/TextFormatting/TextFormatterImpl.cs @@ -186,6 +186,17 @@ namespace Avalonia.Media.TextFormatting /// The split text runs. internal static SplitResult> SplitTextRuns(IReadOnlyList textRuns, int length, FormattingObjectPool objectPool) + => SplitTextRuns(textRuns, length, objectPool, out _); + + /// + /// Split a sequence of runs into two segments at specified length. The actual + /// length of the first segment (which may differ from + /// when the split lands on a cluster boundary) is returned via + /// . This lets the wrap caller avoid a separate + /// second pass to sum run lengths. + /// + internal static SplitResult> SplitTextRuns(IReadOnlyList 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>(null, second); } @@ -242,6 +254,7 @@ namespace Avalonia.Media.TextFormatting first.Add(currentRun); + firstLength = currentLength + currentRunLength; return new SplitResult>(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>(first, second); } } @@ -283,6 +324,7 @@ namespace Avalonia.Media.TextFormatting first.Add(textRuns[i]); } + firstLength = currentLength; return new SplitResult>(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 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.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(); + remainingRuns = new List(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, diff --git a/src/Avalonia.Base/Media/TextFormatting/Unicode/LineBreakEnumerator.cs b/src/Avalonia.Base/Media/TextFormatting/Unicode/LineBreakEnumerator.cs index 63f4ea0f45..576f0ad872 100644 --- a/src/Avalonia.Base/Media/TextFormatting/Unicode/LineBreakEnumerator.cs +++ b/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 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 text, ref LineBreakState state) @@ -164,6 +204,7 @@ namespace Avalonia.Media.TextFormatting.Unicode /// /// LB3: Always break at the end of text. /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] private static RuleResult LB03(ReadOnlySpan text, ref LineBreakState state) { if (state.Current.EndOfText) @@ -177,6 +218,7 @@ namespace Avalonia.Media.TextFormatting.Unicode /// /// LB4: Always break after hard line breaks. /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] private static RuleResult LB04(ReadOnlySpan 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 /// /// LB6: Do not break before hard line breaks. /// - /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] private static RuleResult LB06(ReadOnlySpan 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 /// /// LB7: Do not break before spaces or zero width space. /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] private static RuleResult LB07(ReadOnlySpan 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 /// private static RuleResult LB08(ReadOnlySpan 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 /// /// LB8a: Do not break after a zero width joiner. /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] private static RuleResult LB08a(ReadOnlySpan 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 /// /// LB11: Do not break before or after Word joiner and related characters. /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] private static RuleResult LB11(ReadOnlySpan 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 /// /// LB12: Do not break after NBSP and related characters. /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] private static RuleResult LB12(ReadOnlySpan text, ref LineBreakState state) { // GL × @@ -357,7 +403,7 @@ namespace Avalonia.Media.TextFormatting.Unicode private static RuleResult LB12a(ReadOnlySpan 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 /// /// LB13: Do not break before ‘]’ or ‘!’ or ‘;’ or ‘/’, even after spaces. /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] private static RuleResult LB13(ReadOnlySpan 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 /// /// LB14: Do not break after ‘[’, even after spaces. /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] private static RuleResult LB14(ReadOnlySpan text, ref LineBreakState state) { // OP SP* × @@ -455,7 +503,7 @@ namespace Avalonia.Media.TextFormatting.Unicode private static RuleResult LB15b(ReadOnlySpan 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 /// /// LB15d: Otherwise, do not break before ‘;’, ‘,’, or ‘.’, even after spaces. /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] private static RuleResult LB15d(ReadOnlySpan 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 /// /// LB18: Break after spaces. /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] private static RuleResult LB18(ReadOnlySpan text, ref LineBreakState state) { // SP ÷ @@ -634,11 +684,12 @@ namespace Avalonia.Media.TextFormatting.Unicode /// /// LB20: Break before and after unresolved CB. /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] private static RuleResult LB20(ReadOnlySpan 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 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; } - /// /// LB21a: Don't break after Hebrew + Hyphen. /// private static RuleResult LB21a(ReadOnlySpan 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 /// /// LB21b: Don’t break between Solidus and Hebrew letters. /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] private static RuleResult LB21b(ReadOnlySpan 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 /// /// LB22: Do not break before ellipses. /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] private static RuleResult LB22(ReadOnlySpan 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; + /// /// LB23a: Do not break between numeric prefixes and ideographs, or between /// ideographs and numeric postfixes. /// private static RuleResult LB23a(ReadOnlySpan 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; - } } /// @@ -842,15 +889,16 @@ namespace Avalonia.Media.TextFormatting.Unicode /// private static RuleResult LB24(ReadOnlySpan 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; } - /// /// LB25: Do not break between the following pairs of classes relevant to numbers /// private static RuleResult LB25(ReadOnlySpan 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 /// /// LB29: Do not break between numeric punctuation and alphabetics (“e.g.”). /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] private static RuleResult LB29(ReadOnlySpan 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 /// private static RuleResult LB30a(ReadOnlySpan 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 /// /// LB31: Break everywhere else. /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] private static RuleResult LB31(ReadOnlySpan 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; } + /// + /// Cached LineBreakClass of the previous BreakUnit. Updated whenever + /// would be reassigned (in + /// or via the Ignored/Inherited fall-through in the getter). + /// + 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 text) { - return _next ??= Peek(text); + if (!_hasNext) + { + _next = Peek(text); + _nextClass = _next.LineBreakClass; + _hasNext = true; + } + return _next; } + /// + /// Cached LineBreakClass of the next BreakUnit. Faster than + /// state.NextClass because it avoids the + /// BreakUnit struct copy. Read is responsible for ensuring _hasNext + /// is set before any rule sees this property. + /// + public LineBreakClass NextClass => _nextClass; + public static BreakUnit After(ReadOnlySpan text, BreakUnit current) { if (current.EndOfText) @@ -1519,12 +1607,17 @@ namespace Avalonia.Media.TextFormatting.Unicode public void IgnoreNext(ReadOnlySpan 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 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 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, - ]; } } diff --git a/src/Avalonia.Base/Media/TextFormatting/Unicode/LineBreakPairTable.cs b/src/Avalonia.Base/Media/TextFormatting/Unicode/LineBreakPairTable.cs deleted file mode 100644 index fd37eed68d..0000000000 --- a/src/Avalonia.Base/Media/TextFormatting/Unicode/LineBreakPairTable.cs +++ /dev/null @@ -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 - { - /// - /// Direct break opportunity - /// - public const byte DIBRK = 0; - - /// - /// Indirect break opportunity - /// - public const byte INBRK = 1; - - /// - /// Indirect break opportunity for combining marks - /// - public const byte CIBRK = 2; - - /// - /// Prohibited break for combining marks - /// - public const byte CPBRK = 3; - - /// - /// Prohibited break - /// - 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 - }; - } -} diff --git a/src/Skia/Avalonia.Skia/GlyphRunImpl.cs b/src/Skia/Avalonia.Skia/GlyphRunImpl.cs index e7f772190d..d2e97c5e23 100644 --- a/src/Skia/Avalonia.Skia/GlyphRunImpl.cs +++ b/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.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.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.Shared.Return(glyphBounds); + } } public double FontRenderingEmSize { get; } diff --git a/tests/Avalonia.Base.UnitTests/Media/TextFormatting/CodepointTests.cs b/tests/Avalonia.Base.UnitTests/Media/TextFormatting/CodepointTests.cs new file mode 100644 index 0000000000..07d2a4a6ab --- /dev/null +++ b/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; + +/// +/// Direct coverage for — surrogate-pair decoding via +/// , the small +/// helper properties / methods, and the bitmask-based IsWhiteSpace path +/// that depends on every used value fitting in 64 +/// bits. +/// +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.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(); + while (enumerator.MoveNext(out var cp)) + { + actual.Add(cp.Value); + } + + Assert.Equal(expected, actual); + } + + /// + /// uses a bitmask trick that assumes every + /// value used in the mask fits in 64 bits. + /// If Control, Format, or SpaceSeparator ever moves past + /// position 63 in the enum, the mask silently produces wrong results. This + /// guards against that. + /// + [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)); + } +} diff --git a/tests/Avalonia.Base.UnitTests/Media/TextFormatting/PropertyValueAliasHelperTests.cs b/tests/Avalonia.Base.UnitTests/Media/TextFormatting/PropertyValueAliasHelperTests.cs new file mode 100644 index 0000000000..d55115fc5b --- /dev/null +++ b/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; + +/// +/// Lightweight round-trip checks for the generated . +/// Catches regressions in the alias-helper writer (e.g. wrong typeName, missing +/// entries, casing mismatches) without re-asserting the UCD aliases themselves. +/// +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)); + } +} diff --git a/tests/Avalonia.Base.UnitTests/Media/TextFormatting/UnicodeDataTests.cs b/tests/Avalonia.Base.UnitTests/Media/TextFormatting/UnicodeDataTests.cs new file mode 100644 index 0000000000..08be1cb4ee --- /dev/null +++ b/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; + +/// +/// Spot-checks for . 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. +/// +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)); + } + + /// + /// 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). + /// + [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)); + } +} diff --git a/tests/Avalonia.Base.UnitTests/Media/TextFormatting/UnicodeTrieTests.cs b/tests/Avalonia.Base.UnitTests/Media/TextFormatting/UnicodeTrieTests.cs new file mode 100644 index 0000000000..bd6841e514 --- /dev/null +++ b/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; + +/// +/// Direct coverage for and . +/// The production tries (UnicodeData, BiDi, GraphemeBreak, EastAsianWidth) are +/// used to exercise the four branches of ; a small +/// synthetic trie covers the error-value path (which is otherwise unreachable +/// because the committed tries are generated with errorValue == 0) and the +/// builder round-trip. +/// +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)); + } +} diff --git a/tests/Avalonia.Benchmarks/Program.cs b/tests/Avalonia.Benchmarks/Program.cs index 8f7aa3eb79..c81225ea6c 100644 --- a/tests/Avalonia.Benchmarks/Program.cs +++ b/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); + } } } diff --git a/tests/Avalonia.Benchmarks/Text/CodepointBenchmark.cs b/tests/Avalonia.Benchmarks/Text/CodepointBenchmark.cs new file mode 100644 index 0000000000..9eb9044304 --- /dev/null +++ b/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; + +/// +/// End-to-end benchmarks for the hot path: surrogate +/// decoding via , property accessors that +/// perform Unicode data lookups via generated static +/// instances, and a representative "all properties" pass that approximates +/// what a layout pipeline traversal pays per codepoint. +/// +[MemoryDiagnoser] +[MinIterationTime(150)] +[MaxWarmupCount(15)] +public class CodepointBenchmark +{ + public enum TextDistribution + { + /// Pure ASCII (no surrogate decoding cost). + Ascii, + + /// BMP mix of Latin, Cyrillic, Greek, CJK (one UTF-16 unit per scalar). + Bmp, + + /// Mix of supplementary plane scalars (math, emoji, CJK Ext B) — every other + /// scalar requires surrogate-pair decoding. + 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; + } + + /// + /// 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. + /// + [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; + } +} diff --git a/tests/Avalonia.Benchmarks/Text/HugeTextLayout.cs b/tests/Avalonia.Benchmarks/Text/HugeTextLayout.cs index da2519188e..fabeb5bca8 100644 --- a/tests/Avalonia.Benchmarks/Text/HugeTextLayout.cs +++ b/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() diff --git a/tests/Avalonia.Benchmarks/Text/ShapedBufferOps.cs b/tests/Avalonia.Benchmarks/Text/ShapedBufferOps.cs new file mode 100644 index 0000000000..255ea6aeb6 --- /dev/null +++ b/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; + +/// +/// Micro-benchmark for the cluster-cache hot paths +/// (, , +/// and the cached 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 --memory to see allocation impact of +/// the -backed cluster cache. +/// +[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; + } + + /// + /// Cost of shaping + building the cluster cache from scratch. Captures both + /// the HarfBuzz call and the prefix-sum allocation/initialisation. + /// + [Benchmark] + public double ShapeAndPrime() + { + using var buffer = TextShaper.Current.ShapeText(_text, _options); + return buffer.TotalGlyphAdvance; + } + + /// + /// Repeated on a primed buffer. + /// Should be ~O(1) per call regardless of glyph count. + /// + [Benchmark] + public double TotalAdvance_Cached() + { + var sum = 0d; + for (var i = 0; i < 64; i++) + { + sum += _primed!.TotalGlyphAdvance; + } + return sum; + } + + /// + /// Repeated targeting + /// half the buffer's total width. Exercises the binary search across the + /// prefix table. + /// + [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; + } + + /// + /// 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. + /// + [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(); + } +} diff --git a/tests/Avalonia.Benchmarks/Text/TextLayoutProfile.cs b/tests/Avalonia.Benchmarks/Text/TextLayoutProfile.cs new file mode 100644 index 0000000000..ed7b0557b7 --- /dev/null +++ b/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; + +/// +/// Single-variant benchmark used as a profiling target. Pins the most expensive +/// realistic case from (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. +/// +[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(); +} diff --git a/tests/Avalonia.Benchmarks/Text/UnicodeBreakEnumeratorBenchmark.cs b/tests/Avalonia.Benchmarks/Text/UnicodeBreakEnumeratorBenchmark.cs new file mode 100644 index 0000000000..166d5453e0 --- /dev/null +++ b/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; + +/// +/// 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. +/// +[MemoryDiagnoser] +[MinIterationTime(150)] +[MaxWarmupCount(15)] +public class UnicodeBreakEnumeratorBenchmark +{ + public enum TextDistribution + { + /// Pure ASCII — no surrogate decoding cost. + Ascii, + + /// BMP mix of Latin, Cyrillic, Greek, CJK. + Bmp, + + /// Mix of BMP and supplementary plane scalars. + 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; + } +} diff --git a/tests/Avalonia.Benchmarks/Text/UnicodeTrieBenchmark.cs b/tests/Avalonia.Benchmarks/Text/UnicodeTrieBenchmark.cs new file mode 100644 index 0000000000..f4ba6220a5 --- /dev/null +++ b/tests/Avalonia.Benchmarks/Text/UnicodeTrieBenchmark.cs @@ -0,0 +1,153 @@ +using System; +using Avalonia.Media.TextFormatting.Unicode; +using BenchmarkDotNet.Attributes; + +namespace Avalonia.Benchmarks.Text; + +/// +/// Raw 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. +/// +[MemoryDiagnoser] +[MinIterationTime(150)] +[MaxWarmupCount(15)] +public class UnicodeTrieBenchmark +{ + public enum CodepointDistribution + { + /// Printable ASCII (0x20..0x7E) — common BMP-non-surrogate path. + Ascii, + + /// Full BMP excluding the surrogate range (0..0xD7FF, 0xE000..0xFFFF). + Bmp, + + /// Supplementary plane (0x10000..0x10FFFF) — exercises two-level lookup. + 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; + } + + /// + /// Worst-case "every property" pass — one 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. + /// + [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; + } +} diff --git a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/ShapedBufferSharedStorageTests.cs b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/ShapedBufferSharedStorageTests.cs new file mode 100644 index 0000000000..ae98e79739 --- /dev/null +++ b/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 +{ + /// + /// Exercises the ref-counted shared storage and the per-write generation + /// counter that and + /// 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: + /// + /// aliases keep working after their source is disposed, + /// is idempotent, + /// indexer mutations propagate to siblings via the generation bump + /// (i.e. nobody is left with a stale cluster cache). + /// + /// + 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())); + } + } +} diff --git a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/SplitTextRunsTests.cs b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/SplitTextRunsTests.cs new file mode 100644 index 0000000000..da9b78f25c --- /dev/null +++ b/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 +{ + /// + /// Direct tests for TextFormatterImpl.SplitTextRuns. Calls the + /// internal method via InternalsVisibleTo so each branch can be + /// exercised in isolation with synthetic 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 TextCollapsingProperties + /// 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 firstLength equals the sum of lengths in first. + /// + 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() ?? Enumerable.Empty()) + .Concat(second?.Cast() ?? Enumerable.Empty()) + .ToList(); + Assert.Equal(3, allRuns.Count); + Assert.Contains(runs[1], allRuns); + } + finally + { + pool.TextRunLists.Return(ref first); + pool.TextRunLists.Return(ref second); + } + } + + /// + /// Asserts the central invariant: sum of (first|second) run lengths + /// equals the input total, and matches the + /// sum of first's run lengths. Any test where this fails means content + /// was lost or duplicated. + /// + private static void AssertContentPreserved( + IReadOnlyList input, + RentedList? first, + RentedList? 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); + } + + /// + /// Minimal concrete for tests — neither a + /// ShapedTextRun nor a DrawableTextRun 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 SplitTextRuns drop-current-run bug. + /// + 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})"; + } + } +} diff --git a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterWrapCharacterizationTests.cs b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterWrapCharacterizationTests.cs new file mode 100644 index 0000000000..bb19a0e48e --- /dev/null +++ b/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 +{ + /// + /// Characterization tests for TextFormatterImpl.PerformTextWrapping + /// and its helpers (MeasureLength, SplitTextRuns, + /// ResetTrailingWhitespaceBidiLevels). + /// + 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(); + 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 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(); + 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(); + } + } +} diff --git a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs index 6af9761202..1ec027a12c 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs +++ b/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); diff --git a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextShaperTests.cs b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextShaperTests.cs index 7ce98a6bde..b638e01b47 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextShaperTests.cs +++ b/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