diff --git a/.gitignore b/.gitignore index 06caf2b7d47..22077f64022 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 cccef8f9387..66041f7f8d0 100644 --- a/src/Avalonia.Base/Media/GlyphRun.cs +++ b/src/Avalonia.Base/Media/GlyphRun.cs @@ -684,15 +684,24 @@ private GlyphRunMetrics CreateGlyphRunMetrics() } 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++) + // A2: 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) { - var advance = _glyphInfos[index].GlyphAdvance; - - widthIncludingTrailingWhitespace += advance; + widthIncludingTrailingWhitespace = shapedBuffer.TotalGlyphAdvance; + } + else + { + widthIncludingTrailingWhitespace = 0d; + for (var index = 0; index < _glyphInfos.Count; index++) + { + widthIncludingTrailingWhitespace += _glyphInfos[index].GlyphAdvance; + } } var width = widthIncludingTrailingWhitespace; diff --git a/src/Avalonia.Base/Media/TextFormatting/LogicalTextRunEnumerator.cs b/src/Avalonia.Base/Media/TextFormatting/LogicalTextRunEnumerator.cs index 7b391801a69..514c39edd4f 100644 --- a/src/Avalonia.Base/Media/TextFormatting/LogicalTextRunEnumerator.cs +++ b/src/Avalonia.Base/Media/TextFormatting/LogicalTextRunEnumerator.cs @@ -3,6 +3,21 @@ namespace Avalonia.Media.TextFormatting; +/// +/// Walks the runs of a in logical (source-text) +/// order. This is the order that splits and length-based offsets are defined +/// in, and is what every +/// implementation needs to see — unlike , +/// which exposes the post-BiDi visual ordering used for rendering. +/// +/// +/// When the line has been finalized (the normal case after +/// TextLineImpl.FinalizeLine), the enumerator iterates over +/// _indexedTextRuns — a level-resolved table that maps each run back +/// to its original logical position. If the line hasn't been finalized (or +/// the line is not a TextLineImpl), it falls back to the raw +/// list. +/// internal ref struct LogicalTextRunEnumerator { private readonly IReadOnlyList? _textRuns; @@ -61,7 +76,7 @@ public bool MoveNext([MaybeNullWhen(false)] out TextRun run) } else if (_textRuns != null) { - run = _textRuns[0]; + run = _textRuns[_index]; } else { diff --git a/src/Avalonia.Base/Media/TextFormatting/ShapedBuffer.cs b/src/Avalonia.Base/Media/TextFormatting/ShapedBuffer.cs index 3643d7807b5..1a96fb89764 100644 --- a/src/Avalonia.Base/Media/TextFormatting/ShapedBuffer.cs +++ b/src/Avalonia.Base/Media/TextFormatting/ShapedBuffer.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Linq; using System.Runtime.CompilerServices; +using System.Threading; using Avalonia.Utilities; namespace Avalonia.Media.TextFormatting @@ -13,6 +14,43 @@ public sealed class ShapedBuffer : IReadOnlyList, IDisposable private GlyphInfo[]? _rentedBuffer; private ArraySlice _glyphInfos; + // Pool-array ownership. The instance that called ArrayPool.Rent owns the + // array via _rentedBuffer and has _arrayOwner == this with _refCount == 1. + // Every view that shares the same array (via Split / WithBidiLevel) sets + // _arrayOwner to that owner and increments owner._refCount. Dispose + // decrements the owner's refcount; the array is only returned to the + // pool when the last reference drops. _arrayOwner stays null for + // instances backed by a GC-managed (non-pooled) array passed in by + // a caller — disposing those is a no-op. + private ShapedBuffer? _arrayOwner; + private int _refCount; + + // Lazily-computed cluster-width cache (A1). 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]`. + // + // 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. + private double[]? _clusterPrefix; + private int[]? _clusterStartChars; + private int _clusterStartIdx; + private int _clusterCount; + public ShapedBuffer(ReadOnlyMemory text, int bufferLength, GlyphTypeface glyphTypeface, double fontRenderingEmSize, sbyte bidiLevel) { Text = text; @@ -21,8 +59,16 @@ public ShapedBuffer(ReadOnlyMemory text, int bufferLength, GlyphTypeface g GlyphTypeface = glyphTypeface; FontRenderingEmSize = fontRenderingEmSize; BidiLevel = bidiLevel; + _arrayOwner = this; + _refCount = 1; } + /// + /// Constructs a buffer backed by a caller-supplied, GC-managed glyph array. + /// The buffer takes no pool ownership and is a no-op. + /// Used for synthetic single-glyph runs where the caller owns the array's + /// lifetime directly. + /// internal ShapedBuffer(ReadOnlyMemory text, ArraySlice glyphInfos, GlyphTypeface glyphTypeface, double fontRenderingEmSize, sbyte bidiLevel) { Text = text; @@ -30,6 +76,63 @@ internal ShapedBuffer(ReadOnlyMemory text, ArraySlice glyphInfo GlyphTypeface = glyphTypeface; FontRenderingEmSize = fontRenderingEmSize; BidiLevel = bidiLevel; + // _arrayOwner stays null: nothing pooled, nothing to refcount. + } + + /// + /// Constructs a view over another buffer's array. Used by + /// and by for views that + /// don't carry the cluster cache. The view shares pool ownership with + /// : incrementing the owner's + /// refcount keeps the pool array alive until this view is also disposed. + /// + private ShapedBuffer(ShapedBuffer arrayOwnerSource, ReadOnlyMemory text, ArraySlice glyphInfos, GlyphTypeface glyphTypeface, double fontRenderingEmSize, sbyte bidiLevel) + { + Text = text; + _glyphInfos = glyphInfos; + GlyphTypeface = glyphTypeface; + FontRenderingEmSize = fontRenderingEmSize; + BidiLevel = bidiLevel; + AcquireArrayOwner(arrayOwnerSource); + } + + /// + /// Constructs a view over another buffer's array that also inherits the + /// shared cluster-width cache. Used by 's + /// SplitAscending / SplitDescending paths so the cache + /// (built once on the owner) is shared across all post-split sub-buffers. + /// Same lifetime semantics as the other view constructor. + /// + private ShapedBuffer(ShapedBuffer arrayOwnerSource, ReadOnlyMemory text, ArraySlice glyphInfos, + GlyphTypeface glyphTypeface, double fontRenderingEmSize, sbyte bidiLevel, + double[] sharedClusterPrefix, int[] sharedClusterStartChars, + int clusterStartIdx, int clusterCount) + { + Text = text; + _glyphInfos = glyphInfos; + GlyphTypeface = glyphTypeface; + FontRenderingEmSize = fontRenderingEmSize; + BidiLevel = bidiLevel; + _clusterPrefix = sharedClusterPrefix; + _clusterStartChars = sharedClusterStartChars; + _clusterStartIdx = clusterStartIdx; + _clusterCount = clusterCount; + AcquireArrayOwner(arrayOwnerSource); + } + + /// + /// Adopt 's pool-array owner (chain-root) and + /// take a reference on it. No-op when the source isn't backed by a + /// pooled array. + /// + private void AcquireArrayOwner(ShapedBuffer source) + { + var owner = source._arrayOwner; + if (owner != null) + { + Interlocked.Increment(ref owner._refCount); + _arrayOwner = owner; + } } /// @@ -67,13 +170,43 @@ internal ShapedBuffer(ReadOnlyMemory text, ArraySlice glyphInfo /// public ReadOnlyMemory Text { get; } + /// + /// Test hook: true while this instance still holds an + /// -rented GlyphInfo[]. Flips to + /// false once the array is returned to the pool — either when + /// the last reference to a pool-backed buffer is disposed, or for + /// instances that were constructed with a caller-supplied GC-managed + /// array (which never had pool ownership in the first place). + /// + internal bool IsPoolArrayRented => _rentedBuffer is not null; + public void Dispose() { - if (_rentedBuffer is not null) + var owner = _arrayOwner; + if (owner == null) { - ArrayPool.Shared.Return(_rentedBuffer); - _rentedBuffer = null; - _glyphInfos = ArraySlice.Empty; // ensure we don't misuse the returned array + // Either already disposed (idempotent path) or a non-pooled + // (GC-managed) buffer that owns no ArrayPool resource. + return; + } + + // Release THIS view's reference. Two-step so a double-Dispose on the + // same instance is a no-op the second time. + _arrayOwner = null; + _glyphInfos = ArraySlice.Empty; + + if (Interlocked.Decrement(ref owner._refCount) != 0) + { + // Other views still hold the array; don't return it yet. + return; + } + + // Last reference — actually return the pool array. + if (owner._rentedBuffer is not null) + { + ArrayPool.Shared.Return(owner._rentedBuffer); + owner._rentedBuffer = null; + owner._glyphInfos = ArraySlice.Empty; } } @@ -82,7 +215,413 @@ public GlyphInfo this[int index] [MethodImpl(MethodImplOptions.AggressiveInlining)] get => _glyphInfos[index]; [MethodImpl(MethodImplOptions.AggressiveInlining)] - set => _glyphInfos[index] = value; + set + { + _glyphInfos[index] = value; + InvalidateClusterCache(); + } + } + + /// + /// 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 = _clusterPrefix ?? 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 = _clusterPrefix ?? EnsureClusterCache(); + var starts = _clusterStartChars!; + 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 baseChar = starts[startIdx]; + + var lo = 0; + var hi = count; + while (lo < hi) + { + var mid = (lo + hi + 1) >> 1; + if (prefix[startIdx + mid] - basePrefix <= availableWidth) + { + lo = mid; + } + else + { + hi = mid - 1; + } + } + + widthConsumed = prefix[startIdx + lo] - basePrefix; + return starts[startIdx + lo] - baseChar; + } + + /// + /// 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 + { + _ = _clusterPrefix ?? EnsureClusterCache(); + if (_clusterCount == 0) + { + return 0; + } + var starts = _clusterStartChars!; + var startIdx = _clusterStartIdx; + return starts[startIdx + 1] - starts[startIdx]; + } + } + + /// + /// Returns the width consumed by the first characters, + /// using the cluster cache. The result is the prefix advance up to the first cluster + /// whose start character is >= . + /// + internal double GetWidthForCharCount(int charCount) + { + if (charCount <= 0) + { + return 0d; + } + + var prefix = _clusterPrefix ?? EnsureClusterCache(); + var starts = _clusterStartChars!; + var startIdx = _clusterStartIdx; + var count = _clusterCount; + + var basePrefix = prefix[startIdx]; + var baseChar = starts[startIdx]; + + // Cluster starts are non-decreasing within the sub-buffer's range. + // Linear search from the end; typical callers pass the line's full length + // and hit the last cluster immediately. + for (var i = count; i >= 0; i--) + { + if (starts[startIdx + i] - baseChar <= charCount) + { + return prefix[startIdx + i] - basePrefix; + } + } + return 0d; + } + + /// + /// Finds the largest N such that the first N logical + /// characters of this sub-buffer fit within . + /// Cluster-atomic: a multi-glyph cluster either fits completely or not at + /// all. Returns 0 if is non-positive + /// or the first cluster's width already exceeds it. + /// + /// + /// Walks the cluster cache (built in logical order for both LTR and RTL + /// buffers) via binary search, so each call is O(log clusters) and the + /// returned count is the correct logical-leading char count regardless + /// of the buffer's visual direction. + /// + internal int FindLeadingCharCountWithinWidth(double availableWidth) + { + if (availableWidth <= 0) + { + return 0; + } + + var prefix = _clusterPrefix ?? EnsureClusterCache(); + var starts = _clusterStartChars!; + var startIdx = _clusterStartIdx; + var count = _clusterCount; + + var basePrefix = prefix[startIdx]; + var baseChar = starts[startIdx]; + + // Largest k in [0, count] with prefix[startIdx + k] - basePrefix <= availableWidth. + var lo = 0; + var hi = count; + while (lo < hi) + { + var mid = (lo + hi + 1) >> 1; + if (prefix[startIdx + mid] - basePrefix <= availableWidth) + { + lo = mid; + } + else + { + hi = mid - 1; + } + } + + return starts[startIdx + lo] - baseChar; + } + + /// + /// Finds the largest N such that the last N logical + /// characters of this sub-buffer fit within . + /// Cluster-atomic; reports the actual + /// cumulative advance of those N chars. + /// + /// + /// O(log clusters) via the cluster cache; direction-agnostic (cache is + /// always in logical order). The returned count is the logical-trailing + /// char count regardless of whether the buffer is LTR or RTL. + /// + internal int FindTrailingCharCountWithinWidth(double availableWidth, out double consumedWidth) + { + consumedWidth = 0; + + if (availableWidth <= 0) + { + return 0; + } + + var prefix = _clusterPrefix ?? EnsureClusterCache(); + var starts = _clusterStartChars!; + var startIdx = _clusterStartIdx; + var count = _clusterCount; + + var endPrefix = prefix[startIdx + count]; + var endChar = starts[startIdx + count]; + + // Smallest k in [0, count] with endPrefix - prefix[startIdx + k] <= availableWidth. + // (That cluster index marks where the trailing-fitting suffix starts.) + var lo = 0; + var hi = count; + while (lo < hi) + { + var mid = (lo + hi) >> 1; + if (endPrefix - prefix[startIdx + mid] <= availableWidth) + { + hi = mid; + } + else + { + lo = mid + 1; + } + } + + consumedWidth = endPrefix - prefix[startIdx + lo]; + return endChar - starts[startIdx + lo]; + } + + /// + /// Returns the cumulative glyph advance for the logical character range + /// [, ) + /// within this sub-buffer. Uses the cluster cache via binary search, so + /// each call is O(log clusters) regardless of how big the buffer is or + /// where the range sits inside it. + /// + /// + /// The cluster cache is built in logical order for both LTR and + /// RTL buffers (see ), so callers pass + /// logical char offsets and the same code path serves both directions. + /// Out-of-range arguments are clamped to [0, Text.Length]. + /// + internal double GetCharRangeWidth(int startChar, int endChar) + { + if (endChar <= startChar) + { + return 0d; + } + + var prefix = _clusterPrefix ?? EnsureClusterCache(); + var starts = _clusterStartChars!; + var startIdx = _clusterStartIdx; + var count = _clusterCount; + + var basePrefix = prefix[startIdx]; + var baseChar = starts[startIdx]; + + var startBoundary = FindLargestClusterAtOrBefore(starts, startIdx, count, baseChar, startChar); + var endBoundary = FindLargestClusterAtOrBefore(starts, startIdx, count, baseChar, endChar); + + return prefix[startIdx + endBoundary] - prefix[startIdx + startBoundary]; + } + + /// + /// Binary-search the largest cluster boundary index i ∈ [0, count] + /// such that starts[startIdx + i] - baseChar ≤ charPos. Cluster + /// starts are non-decreasing within the sub-buffer range, so a standard + /// upper-bound search works in both LTR and RTL buffers (the cache is + /// always built in logical order). + /// + private static int FindLargestClusterAtOrBefore(int[] starts, int startIdx, int count, int baseChar, int charPos) + { + if (charPos < 0) + { + return 0; + } + + // Standard "rightmost <= charPos" upper-bound shape. + var lo = 0; + var hi = count; + while (lo < hi) + { + var mid = (lo + hi + 1) >> 1; + if (starts[startIdx + mid] - baseChar <= charPos) + { + lo = mid; + } + else + { + hi = mid - 1; + } + } + return lo; + } + + /// + /// Builds the cluster cache 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 glyphInfos = _glyphInfos.Span; + var bufferLength = _glyphInfos.Length; + + if (bufferLength == 0) + { + _clusterCount = 0; + _clusterStartChars = new[] { 0 }; + return _clusterPrefix = new[] { 0d }; + } + + var isLtr = IsLeftToRight; + var step = isLtr ? 1 : -1; + var start = isLtr ? 0 : bufferLength - 1; + var end = isLtr ? bufferLength : -1; + + // First pass: count clusters by counting cluster-id transitions in + // logical order. + var clusters = 1; + for (int j = start + step, prevId = glyphInfos[start].GlyphCluster; j != end; j += step) + { + var id = glyphInfos[j].GlyphCluster; + if (id != prevId) + { + clusters++; + prevId = id; + } + } + + var prefix = new double[clusters + 1]; + var startChars = new int[clusters + 1]; + + // The first logical glyph's cluster value is the absolute source-text + // offset of the run's first character. We anchor character offsets here. + var baseCluster = glyphInfos[start].GlyphCluster; + var textLength = Text.Length; + + var clusterIndex = 0; + var currentClusterId = baseCluster; + var currentWidth = 0d; + 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; + // 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; + startChars[clusterIndex + 1] = textLength; + + _clusterCount = clusters; + _clusterStartChars = startChars; + _clusterPrefix = prefix; + 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!; + 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() + { + _clusterPrefix = null; + _clusterStartChars = null; + _clusterStartIdx = 0; + _clusterCount = 0; } public IEnumerator GetEnumerator() => _glyphInfos.GetEnumerator(); @@ -99,7 +638,9 @@ internal ShapedBuffer WithBidiLevel(sbyte paragraphEmbeddingLevel) return this; } - return new ShapedBuffer(Text, _glyphInfos, GlyphTypeface, FontRenderingEmSize, paragraphEmbeddingLevel); + // View into our glyph array — share pool ownership so the array + // outlives both `this` and the new view independently. + return new ShapedBuffer(this, Text, _glyphInfos, GlyphTypeface, FontRenderingEmSize, paragraphEmbeddingLevel); } int IReadOnlyCollection.Count => _glyphInfos.Length; @@ -119,7 +660,7 @@ public SplitResult Split(int textLength) if (textLength <= 0) { var emptyBuffer = new ShapedBuffer( - Text.Slice(0, 0), _glyphInfos.Slice(_glyphInfos.Start, 0), + this, Text.Slice(0, 0), _glyphInfos.Slice(_glyphInfos.Start, 0), GlyphTypeface, FontRenderingEmSize, BidiLevel); return new SplitResult(emptyBuffer, this); @@ -189,9 +730,16 @@ private SplitResult SplitAscending(int textLength) var firstText = Text.Slice(0, splitCharCount); var secondText = Text.Slice(splitCharCount); + // A1-followup: 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). + var sharedPrefix = _clusterPrefix ?? EnsureClusterCache(); + var sharedStarts = _clusterStartChars!; + var leadingClusterCount = FindClusterOffsetForSplit(splitCharCount); + var leading = new ShapedBuffer( - firstText, firstGlyphs, - GlyphTypeface, FontRenderingEmSize, BidiLevel); + this, firstText, firstGlyphs, + GlyphTypeface, FontRenderingEmSize, BidiLevel, + sharedPrefix, sharedStarts, _clusterStartIdx, leadingClusterCount); if (secondText.Length == 0) { @@ -199,8 +747,10 @@ private SplitResult SplitAscending(int textLength) } var trailing = new ShapedBuffer( - secondText, secondGlyphs, - GlyphTypeface, FontRenderingEmSize, BidiLevel); + this, secondText, secondGlyphs, + GlyphTypeface, FontRenderingEmSize, BidiLevel, + sharedPrefix, sharedStarts, + _clusterStartIdx + leadingClusterCount, _clusterCount - leadingClusterCount); return new SplitResult(leading, trailing); } @@ -253,9 +803,18 @@ private SplitResult SplitDescending(int textLength) var firstText = Text.Slice(0, textLength); var secondText = Text.Slice(textLength); + // A1-followup: 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. + var sharedPrefix = _clusterPrefix ?? EnsureClusterCache(); + var sharedStarts = _clusterStartChars!; + var firstClusterCount = FindClusterOffsetForSplit(textLength); + var first = new ShapedBuffer( - firstText, firstGlyphs, - GlyphTypeface, FontRenderingEmSize, BidiLevel); + this, firstText, firstGlyphs, + GlyphTypeface, FontRenderingEmSize, BidiLevel, + sharedPrefix, sharedStarts, _clusterStartIdx, firstClusterCount); if (secondText.Length == 0 || secondGlyphs.Length == 0) { @@ -263,8 +822,10 @@ private SplitResult SplitDescending(int textLength) } var second = new ShapedBuffer( - secondText, secondGlyphs, - GlyphTypeface, FontRenderingEmSize, BidiLevel); + this, secondText, secondGlyphs, + GlyphTypeface, FontRenderingEmSize, BidiLevel, + sharedPrefix, sharedStarts, + _clusterStartIdx + firstClusterCount, _clusterCount - firstClusterCount); return new SplitResult(first, second); } diff --git a/src/Avalonia.Base/Media/TextFormatting/ShapedTextRun.cs b/src/Avalonia.Base/Media/TextFormatting/ShapedTextRun.cs index a75734f3562..e8f9e377000 100644 --- a/src/Avalonia.Base/Media/TextFormatting/ShapedTextRun.cs +++ b/src/Avalonia.Base/Media/TextFormatting/ShapedTextRun.cs @@ -103,97 +103,33 @@ public override void Draw(DrawingContext drawingContext, Point origin) } /// - /// Measures the number of characters that fit into available width. + /// Returns the largest count of logical leading characters of this + /// run that fit within . Cluster-atomic + /// and direction-agnostic — for RTL runs the result is the count of chars + /// from the logical start (not the visually-leftmost chars, which would + /// be the logical tail). /// /// The available width. /// The count of fitting characters. /// - /// true if characters fit into the available width; otherwise, false. + /// true if at least one character fits within + /// ; otherwise false. /// public bool TryMeasureCharacters(double availableWidth, out int length) { - length = 0; - - if (ShapedBuffer.Length == 0) - { - return false; - } - - var currentWidth = 0.0; - var charactersSpan = GlyphRun.Characters.Span; - var isLeftToRight = ShapedBuffer.IsLeftToRight; - var bufferLength = ShapedBuffer.Length; - var textLength = Text.Length; - - // Previous visual glyph's cluster — used in RTL mode to compute the char count - // contributed by the current glyph (which spans [currentCluster, prevCluster) logically). - var previousCluster = 0; - - for (var i = 0; i < bufferLength; i++) - { - var advance = ShapedBuffer[i].GlyphAdvance; - var currentCluster = ShapedBuffer[i].GlyphCluster; - - if (currentWidth + advance > availableWidth) - { - break; - } - - int count; - - if (isLeftToRight) - { - if (i + 1 < bufferLength) - { - var nextCluster = ShapedBuffer[i + 1].GlyphCluster; - count = nextCluster - currentCluster; - } - else - { - Codepoint.ReadAt(charactersSpan, length, out count); - } - } - else - { - if (i == 0) - { - count = textLength - currentCluster; - } - else - { - count = previousCluster - currentCluster; - } - } - - length += count; - currentWidth += advance; - previousCluster = currentCluster; - } - + length = ShapedBuffer.FindLeadingCharCountWithinWidth(availableWidth); return length > 0; } + /// + /// Returns the largest count of logical trailing characters of + /// this run that fit within , along + /// with the cumulative advance they consume. Cluster-atomic and + /// direction-agnostic. + /// internal bool TryMeasureCharactersBackwards(double availableWidth, out int length, out double width) { - length = 0; - width = 0; - var charactersSpan = GlyphRun.Characters.Span; - - for (var i = ShapedBuffer.Length - 1; i >= 0; i--) - { - var advance = ShapedBuffer[i].GlyphAdvance; - - if (width + advance > availableWidth) - { - break; - } - - Codepoint.ReadAt(charactersSpan, length, out var count); - - length += count; - width += advance; - } - + length = ShapedBuffer.FindTrailingCharCountWithinWidth(availableWidth, out width); return length > 0; } diff --git a/src/Avalonia.Base/Media/TextFormatting/TextCollapsingProperties.cs b/src/Avalonia.Base/Media/TextFormatting/TextCollapsingProperties.cs index b621dada54f..e3796fc8c56 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextCollapsingProperties.cs +++ b/src/Avalonia.Base/Media/TextFormatting/TextCollapsingProperties.cs @@ -21,9 +21,26 @@ public abstract class TextCollapsingProperties public abstract FlowDirection FlowDirection { get; } /// - /// Collapses given text line. + /// Collapses the given text line and returns the resulting runs, or + /// if no collapse is needed (the consumer + /// then keeps the original line unchanged). /// /// Text line to collapse. + /// + /// Implementations MUST return runs in logical order. The + /// consumer (TextLineImpl.Collapse) wraps the returned array + /// in a new and re-runs the BiDi reorderer + /// via FinalizeLine, so pre-applying visual order here would + /// be reordered a second time and produce garbled output on RTL or + /// mixed-bidi lines. + /// + /// Iterate the source line's runs via + /// LogicalTextRunEnumerator, not + /// (which is post-bidi visual order). Use + /// when an implementation only + /// needs the standard "logical prefix + symbol" shape. + /// + /// public abstract TextRun[]? Collapse(TextLine textLine); /// diff --git a/src/Avalonia.Base/Media/TextFormatting/TextFormatterImpl.cs b/src/Avalonia.Base/Media/TextFormatting/TextFormatterImpl.cs index fe5e3ae8b02..dd8e8459a8f 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextFormatterImpl.cs +++ b/src/Avalonia.Base/Media/TextFormatting/TextFormatterImpl.cs @@ -145,11 +145,10 @@ private static TextLine FormatLineFromCache(CachedShapingResult cached, int firs { var runs = new List(cached.ShapedRuns.Length); - for (var i = 0; i < cached.ShapedRuns.Length; i++) + // foreach over T[] benefits from JIT pattern-match bounds-check elision. + foreach (var cachedRun in cached.ShapedRuns) { - runs.Add(cached.ShapedRuns[i] is ShapedTextRun shaped - ? shaped.AddRef() - : cached.ShapedRuns[i]); + runs.Add(cachedRun is ShapedTextRun shaped ? shaped.AddRef() : cachedRun); } return PerformTextWrapping(runs, false, firstTextSourceIndex, @@ -186,6 +185,17 @@ private static TextRun[] AddRefShapedRuns(IReadOnlyList runs) /// 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 +206,7 @@ internal static SplitResult> SplitTextRuns(IReadOnlyList>(null, second); } @@ -242,6 +253,7 @@ internal static SplitResult> SplitTextRuns(IReadOnlyList>(first, second); } else @@ -249,6 +261,8 @@ internal static SplitResult> SplitTextRuns(IReadOnlyList> SplitTextRuns(IReadOnlyList> SplitTextRuns(IReadOnlyList>(first, second); } } @@ -283,6 +323,7 @@ internal static SplitResult> SplitTextRuns(IReadOnlyList>(first, null); } @@ -720,81 +761,49 @@ private static int MeasureLength(IReadOnlyList textRuns, double paragra { case ShapedTextRun shapedTextCharacters: { - if (shapedTextCharacters.ShapedBuffer.Length > 0) + // A1: 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 +917,18 @@ private static TextLineImpl PerformTextWrapping(List textRuns, bool can var currentPosition = 0; - for (var index = 0; index < textRuns.Count; index++) + // B1: hoist invariants out of the inner wrap loop. TextWrapping + // and textRuns.Count are read once per inner iteration today; both + // are loop-invariant. + 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 +951,7 @@ private static TextLineImpl PerformTextWrapping(List textRuns, bool can if (currentLength + lineBreak.PositionMeasure > measuredLength) { - if (paragraphProperties.TextWrapping == TextWrapping.WrapWithOverflow) + if (wrappingMode == TextWrapping.WrapWithOverflow) { if (lastWrapPosition > 0) { @@ -947,9 +963,9 @@ private static TextLineImpl PerformTextWrapping(List textRuns, bool can } //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 +979,20 @@ private static TextLineImpl PerformTextWrapping(List textRuns, bool can { 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 +1020,7 @@ private static TextLineImpl PerformTextWrapping(List textRuns, bool can break; } - if (lineBreak.PositionMeasure != lineBreak.PositionWrap || lineBreak.PositionWrap != currentRun.Length) + if (lineBreak.PositionMeasure != lineBreak.PositionWrap || lineBreak.PositionWrap != currentRunLength) { lastWrapPosition = currentLength + lineBreak.PositionWrap; } @@ -1015,7 +1032,7 @@ private static TextLineImpl PerformTextWrapping(List textRuns, bool can if (!breakFound) { - currentLength += currentRun.Length; + currentLength += currentRunLength; continue; } @@ -1025,7 +1042,7 @@ private static TextLineImpl PerformTextWrapping(List textRuns, bool can break; } - var (preSplitRuns, postSplitRuns) = SplitTextRuns(textRuns, measuredLength, objectPool); + var (preSplitRuns, postSplitRuns) = SplitTextRuns(textRuns, measuredLength, objectPool, out var splitLength); try { @@ -1033,6 +1050,7 @@ private static TextLineImpl PerformTextWrapping(List textRuns, bool can 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 +1058,19 @@ private static TextLineImpl PerformTextWrapping(List textRuns, bool can { remainingRuns = textRuns; remainingRuns.Clear(); + // B2: 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 +1096,16 @@ private static TextLineImpl PerformTextWrapping(List textRuns, bool can 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/TextLeadingPrefixCharacterEllipsis.cs b/src/Avalonia.Base/Media/TextFormatting/TextLeadingPrefixCharacterEllipsis.cs index e1f36b5f4cb..1896f08f8cd 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextLeadingPrefixCharacterEllipsis.cs +++ b/src/Avalonia.Base/Media/TextFormatting/TextLeadingPrefixCharacterEllipsis.cs @@ -27,7 +27,7 @@ public TextLeadingPrefixCharacterEllipsis( TextRunProperties textRunProperties, FlowDirection flowDirection) { - if (_prefixLength < 0) + if (prefixLength < 0) { throw new ArgumentOutOfRangeException(nameof(prefixLength)); } @@ -49,142 +49,197 @@ public TextLeadingPrefixCharacterEllipsis( /// public override TextRun[]? Collapse(TextLine textLine) { - var textRuns = textLine.TextRuns; - - var runIndex = 0; - var currentWidth = 0.0; - var shapedSymbol = TextFormatter.CreateSymbol(Symbol, FlowDirection.LeftToRight); - - if (Width < shapedSymbol.GlyphRun.Bounds.Width) + // Materialize runs in LOGICAL order. The consumer (TextLineImpl.Collapse) + // wraps our result in a new TextLine and runs the BiDi reorderer via + // FinalizeLine, so we must hand back runs in logical order — not the + // visual order exposed via textLine.TextRuns. + var objectPool = FormattingObjectPool.Instance; + var logicalRuns = objectPool.TextRunLists.Rent(); + + try { - return Array.Empty(); - } + var enumerator = new LogicalTextRunEnumerator(textLine); + while (enumerator.MoveNext(out var r)) + { + logicalRuns.Add(r); + } - // Overview of ellipsis structure - // Prefix length run | Ellipsis symbol | Post split run growing from the end | - var availableWidth = Width - shapedSymbol.Size.Width; + var shapedSymbol = TextFormatter.CreateSymbol(Symbol, FlowDirection); - while (runIndex < textRuns.Count) - { - var currentRun = textRuns[runIndex]; + if (Width < shapedSymbol.GlyphRun.Bounds.Width) + { + return Array.Empty(); + } + + // Overview of ellipsis structure + // Prefix length run | Ellipsis symbol | Post split run growing from the end | + var totalBudget = Width - shapedSymbol.Size.Width; + var availableWidth = totalBudget; + var charsBeforeCurrentRun = 0; - switch (currentRun) + for (var runIndex = 0; runIndex < logicalRuns.Count; runIndex++) { - case ShapedTextRun shapedRun: - { - currentWidth += shapedRun.Size.Width; + var currentRun = logicalRuns[runIndex]; - if (currentWidth > availableWidth) + switch (currentRun) + { + case ShapedTextRun shapedRun: { - shapedRun.TryMeasureCharacters(availableWidth, out var measuredLength); - - if (measuredLength > 0) + // Per-run check: does THIS run alone exceed what's left? + // (The earlier `currentWidth +=` / `currentWidth > availableWidth` + // pattern was comparing cumulative-so-far against budget-remaining, + // which double-counted and tripped overflow far too early on + // multi-run lines.) + if (shapedRun.Size.Width > availableWidth) { - var objectPool = FormattingObjectPool.Instance; - - var collapsedRuns = objectPool.TextRunLists.Rent(); + shapedRun.TryMeasureCharacters(availableWidth, out var measuredLength); - RentedList? rentedPreSplitRuns = null; - RentedList? rentedPostSplitRuns = null; + var totalFitChars = charsBeforeCurrentRun + measuredLength; - try + if (totalFitChars > 0) { - IReadOnlyList? effectivePostSplitRuns; + var collapsedRuns = objectPool.TextRunLists.Rent(); - if (_prefixLength > 0) + RentedList? rentedPreSplitRuns = null; + RentedList? rentedPostSplitRuns = null; + RentedList? reversedSuffix = null; + + try { - (rentedPreSplitRuns, rentedPostSplitRuns) = TextFormatterImpl.SplitTextRuns( - textRuns, Math.Min(_prefixLength, measuredLength), objectPool); + IReadOnlyList? effectivePostSplitRuns; - effectivePostSplitRuns = rentedPostSplitRuns; + // Split at GLOBAL character index totalFitChars-capped-by-prefixLength. + // (Previously this used `Math.Min(_prefixLength, measuredLength)` + // treating per-run `measuredLength` as a global offset, which + // produced a prefix from the wrong characters on multi-run lines.) + var prefixCutoff = Math.Min(_prefixLength, totalFitChars); - // rentedPreSplitRuns cannot be null here as _prefixLength > 0 and measuredLength > 0 - foreach (var preSplitRun in rentedPreSplitRuns!) + if (prefixCutoff > 0) { - collapsedRuns.Add(preSplitRun); + (rentedPreSplitRuns, rentedPostSplitRuns) = TextFormatterImpl.SplitTextRuns( + logicalRuns, prefixCutoff, objectPool); + + effectivePostSplitRuns = rentedPostSplitRuns; + + if (rentedPreSplitRuns is not null) + { + foreach (var preSplitRun in rentedPreSplitRuns) + { + collapsedRuns.Add(preSplitRun); + } + } + } + else + { + effectivePostSplitRuns = logicalRuns; } - } - else - { - effectivePostSplitRuns = textRuns; - } - collapsedRuns.Add(shapedSymbol); + collapsedRuns.Add(shapedSymbol); - if (measuredLength <= _prefixLength || effectivePostSplitRuns is null) - { - return collapsedRuns.ToArray(); - } + if (totalFitChars <= _prefixLength || effectivePostSplitRuns is null) + { + return collapsedRuns.ToArray(); + } - var availableSuffixWidth = availableWidth; + // Suffix budget = total budget minus the actual prefix width. + // (Previously this used the loop's `availableWidth` which had + // over-subtracted: it assumed entire fully-fitting runs went + // to the prefix, even when prefixLength capped the prefix + // partway through one of them. Deriving from the actual + // preSplit run widths gives the correct remaining budget.) + var availableSuffixWidth = totalBudget; - if (rentedPreSplitRuns is not null) - { - foreach (var run in rentedPreSplitRuns) + if (rentedPreSplitRuns is not null) { - if (run is DrawableTextRun drawableTextRun) + foreach (var run in rentedPreSplitRuns) { - availableSuffixWidth -= drawableTextRun.Size.Width; + switch (run) + { + case ShapedTextRun preShaped: + availableSuffixWidth -= preShaped.Size.Width; + break; + case DrawableTextRun preDrawable: + availableSuffixWidth -= preDrawable.Size.Width; + break; + } } } - } - for (var i = effectivePostSplitRuns.Count - 1; i >= 0; i--) - { - var run = effectivePostSplitRuns[i]; + // Walk the post-split runs from the logical tail back toward the + // prefix, fitting trailing characters into availableSuffixWidth. + // We collect each split into reversedSuffix here (so the LAST + // logical run lands at index 0) and then drain reversedSuffix + // backwards when appending to collapsedRuns, which restores + // LOGICAL order. FinalizeLine handles the visual re-bidi. + reversedSuffix = objectPool.TextRunLists.Rent(); - switch (run) + for (var i = effectivePostSplitRuns.Count - 1; i >= 0; i--) { - case ShapedTextRun endShapedRun: + var run = effectivePostSplitRuns[i]; + + switch (run) { - if (endShapedRun.TryMeasureCharactersBackwards(availableSuffixWidth, - out var suffixCount, out var suffixWidth)) + case ShapedTextRun endShapedRun: { - availableSuffixWidth -= suffixWidth; - - if (suffixCount > 0) + if (endShapedRun.TryMeasureCharactersBackwards(availableSuffixWidth, + out var suffixCount, out var suffixWidth)) { - var splitSuffix = - endShapedRun.Split(run.Length - suffixCount); + availableSuffixWidth -= suffixWidth; + + if (suffixCount > 0) + { + var splitSuffix = + endShapedRun.Split(run.Length - suffixCount); - collapsedRuns.Add(splitSuffix.Second!); + reversedSuffix.Add(splitSuffix.Second!); + } } - } - break; + break; + } } } - } - return collapsedRuns.ToArray(); - } - finally - { - objectPool.TextRunLists.Return(ref rentedPreSplitRuns); - objectPool.TextRunLists.Return(ref rentedPostSplitRuns); - objectPool.TextRunLists.Return(ref collapsedRuns); + for (var i = reversedSuffix.Count - 1; i >= 0; i--) + { + collapsedRuns.Add(reversedSuffix[i]); + } + + return collapsedRuns.ToArray(); + } + finally + { + objectPool.TextRunLists.Return(ref rentedPreSplitRuns); + objectPool.TextRunLists.Return(ref rentedPostSplitRuns); + objectPool.TextRunLists.Return(ref reversedSuffix); + objectPool.TextRunLists.Return(ref collapsedRuns); + } } + + return new TextRun[] { shapedSymbol }; } - return new TextRun[] { shapedSymbol }; - } + availableWidth -= shapedRun.Size.Width; - availableWidth -= shapedRun.Size.Width; + break; + } + case DrawableTextRun drawableTextRun: + { + availableWidth -= drawableTextRun.Size.Width; - break; - } - case DrawableTextRun drawableTextRun: - { - availableWidth -= drawableTextRun.Size.Width; + break; + } + } - break; - } - } + charsBeforeCurrentRun += currentRun.Length; + } - runIndex++; + return null; + } + finally + { + objectPool.TextRunLists.Return(ref logicalRuns); } - - return null; } } } diff --git a/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs b/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs index 8809a3fc480..abe2acb30f2 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs +++ b/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs @@ -1171,9 +1171,11 @@ private TextRunBounds GetRunBounds(ShapedTextRun currentRun, double currentX, in public override void Dispose() { - for (int i = 0; i < _textRuns.Length; i++) + // JIT pattern-matches the canonical foreach over T[] for bounds-check + // elision; an explicit for over .Length is measurably slower on hot paths. + foreach (var textRun in _textRuns) { - if (_textRuns[i] is ShapedTextRun shapedTextRun) + if (textRun is ShapedTextRun shapedTextRun) { shapedTextRun.Dispose(); } @@ -1298,9 +1300,11 @@ private TextLineMetrics CreateLineMetrics() var lineHeight = _paragraphProperties.LineHeight; var lineSpacing = _paragraphProperties.LineSpacing; - for (var index = 0; index < _textRuns.Length; index++) + // JIT pattern-matches the canonical foreach over T[] for bounds-check + // elision; an explicit for over .Length is measurably slower on hot paths. + foreach (var run in _textRuns) { - switch (_textRuns[index]) + switch (run) { case ShapedTextRun textRun: { @@ -1345,9 +1349,9 @@ private TextLineMetrics CreateLineMetrics() var inkBounds = new Rect(); - for (var index = 0; index < _textRuns.Length; index++) + foreach (var run in _textRuns) { - switch (_textRuns[index]) + switch (run) { case ShapedTextRun textRun: { diff --git a/src/Avalonia.Base/Media/TextFormatting/TextRunCache.cs b/src/Avalonia.Base/Media/TextFormatting/TextRunCache.cs index d2545f42e2a..94a65c3e854 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextRunCache.cs +++ b/src/Avalonia.Base/Media/TextFormatting/TextRunCache.cs @@ -171,9 +171,10 @@ internal void Add(int firstTextSourceIndex, CachedShapingResult result) private static void AddRefShapedRuns(TextRun[] runs) { - for (var i = 0; i < runs.Length; i++) + // foreach over T[] benefits from JIT pattern-match bounds-check elision. + foreach (var run in runs) { - if (runs[i] is ShapedTextRun shaped) + if (run is ShapedTextRun shaped) { shaped.AddRef(); } @@ -189,11 +190,10 @@ public void Dispose() private static void DisposeCachedRuns(CachedShapingResult result) { - var runs = result.ShapedRuns; - - for (var i = 0; i < runs.Length; i++) + // foreach over T[] benefits from JIT pattern-match bounds-check elision. + foreach (var run in result.ShapedRuns) { - if (runs[i] is ShapedTextRun shaped) + if (run is ShapedTextRun shaped) { shaped.Dispose(); } diff --git a/src/Avalonia.Base/Media/TextFormatting/Unicode/LineBreakEnumerator.cs b/src/Avalonia.Base/Media/TextFormatting/Unicode/LineBreakEnumerator.cs index 63f4ea0f459..0b4da04304c 100644 --- a/src/Avalonia.Base/Media/TextFormatting/Unicode/LineBreakEnumerator.cs +++ b/src/Avalonia.Base/Media/TextFormatting/Unicode/LineBreakEnumerator.cs @@ -71,7 +71,7 @@ private static LineBreak GetLineBreak(ReadOnlySpan text, LineBreakState st case LineBreakClass.CarriageReturn: case LineBreakClass.LineFeed: { - if (state.Previous.LineBreakClass == LineBreakClass.CarriageReturn) + if (state.PreviousClass == LineBreakClass.CarriageReturn) { positionMeasure = FindPriorNonWhitespace(text, state.Previous.Start); } @@ -122,6 +122,8 @@ private static int FindPriorNonWhitespace(ReadOnlySpan text, int from) private static LineBreak? ExecuteRules(ReadOnlySpan text, ref LineBreakState state) { + // JIT elides bounds checks and hoists the array reference more aggressively for the + // canonical foreach. Do not rewrite this as an explicit for loop. foreach (var rule in s_rules) { var res = rule.Invoke(text, ref state); @@ -196,7 +198,7 @@ private static RuleResult LB05(ReadOnlySpan text, ref LineBreakState state switch (state.Current.LineBreakClass) { case LineBreakClass.CarriageReturn: - if (state.Next(text).LineBreakClass == LineBreakClass.LineFeed) + if (state.NextClass == LineBreakClass.LineFeed) { return RuleResult.NoBreak; // CR × LF } @@ -211,14 +213,38 @@ private static RuleResult LB05(ReadOnlySpan text, ref LineBreakState state } } + /// + /// Combines LB06 and LB07. Both rules read state.NextClass + /// only; sharing that read removes a state-access pair from every codepoint + /// advance through this range. + /// + private static RuleResult LB06_LB07_Combined(ReadOnlySpan text, ref LineBreakState state) + { + var nextClass = state.NextClass; + + // LB06: × (BK | CR | LF | NL) + if (IsBreakClass(nextClass)) + { + return RuleResult.NoBreak; + } + + // LB07: × SP | ZW + if (nextClass == LineBreakClass.Space || nextClass == LineBreakClass.ZWSpace) + { + return RuleResult.NoBreak; + } + + return RuleResult.Pass; + } + /// /// LB6: Do not break before hard line breaks. /// - /// + /// Subsumed by ; kept for documentation parity with UAX#14. 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; } @@ -233,7 +259,7 @@ 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 +277,7 @@ private static RuleResult LB07(ReadOnlySpan text, ref LineBreakState state /// 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; } @@ -290,7 +316,7 @@ private static RuleResult LB09(ReadOnlySpan text, ref LineBreakState state return RuleResult.Pass; } - switch (state.Next(text).LineBreakClass) + switch (state.NextClass) { case LineBreakClass.CombiningMark: case LineBreakClass.ZWJ: @@ -323,12 +349,63 @@ private static RuleResult LB10(ReadOnlySpan text, ref LineBreakState state return RuleResult.Pass; } + /// + /// Combines LB11, LB12, LB12a, LB13. All four are pure functions of + /// (currentClass, nextClass) and form a contiguous run between the + /// state-machine rules LB10 (preceding) and LB14 (following). + /// + private static RuleResult LB11_LB13_Combined(ReadOnlySpan text, ref LineBreakState state) + { + var currentClass = state.Current.LineBreakClass; + var nextClass = state.NextClass; + + // LB11: × WJ; WJ × + if (nextClass == LineBreakClass.WordJoiner || currentClass == LineBreakClass.WordJoiner) + { + return RuleResult.NoBreak; + } + + // LB12: GL × + if (currentClass == LineBreakClass.Glue) + { + return RuleResult.NoBreak; + } + + // LB12a: [^SP BA HY UnambiguousHyphen] × GL + if (nextClass == LineBreakClass.Glue) + { + switch (currentClass) + { + case LineBreakClass.Space: + case LineBreakClass.BreakAfter: + case LineBreakClass.Hyphen: + case LineBreakClass.UnambiguousHyphen: + break; + default: + return RuleResult.NoBreak; + } + } + + // LB13: × CL | CP | EX | SY + switch (nextClass) + { + case LineBreakClass.ClosePunctuation: + case LineBreakClass.CloseParenthesis: + case LineBreakClass.Exclamation: + case LineBreakClass.BreakSymbols: + return RuleResult.NoBreak; + } + + return RuleResult.Pass; + } + /// /// LB11: Do not break before or after Word joiner and related characters. /// + /// Subsumed by ; kept for documentation parity with UAX#14. 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; @@ -357,7 +434,7 @@ private static RuleResult LB12(ReadOnlySpan text, ref LineBreakState state 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) { @@ -383,7 +460,7 @@ private static RuleResult LB13(ReadOnlySpan text, ref LineBreakState state // × CP // × EX // × SY - switch (state.Next(text).LineBreakClass) + switch (state.NextClass) { case LineBreakClass.ClosePunctuation: case LineBreakClass.CloseParenthesis: @@ -455,7 +532,7 @@ static bool IsStartLike(BreakUnit unit) 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 +573,7 @@ private static RuleResult LB15c(ReadOnlySpan text, ref LineBreakState stat // 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: { @@ -514,7 +591,7 @@ private static RuleResult LB15c(ReadOnlySpan text, ref LineBreakState stat 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; } @@ -638,7 +715,7 @@ 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 +732,7 @@ private static RuleResult LB20a(ReadOnlySpan text, ref LineBreakState stat 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) { @@ -690,13 +767,98 @@ static bool IsMatch(BreakUnit unit) } } + /// + /// Combines LB21, LB21b, LB22, LB23, LB23a, LB24 into a single dispatched rule. + /// All six are pure functions of (currentClass, nextClass) and form a + /// contiguous run between the state-machine rules LB21a (preceding) and LB25 + /// (following) — so their relative priority is preserved by evaluating them in + /// their original order inside this function, while reading state.Current + /// and state.Next only once for the whole group. + /// + private static RuleResult LB21_LB24_Combined(ReadOnlySpan text, ref LineBreakState state) + { + var currentClass = state.Current.LineBreakClass; + var nextClass = state.NextClass; + + // LB21: × (BA | HY | UnambiguousHyphen | NS) + switch (nextClass) + { + case LineBreakClass.BreakAfter: + case LineBreakClass.UnambiguousHyphen: + case LineBreakClass.Hyphen: + case LineBreakClass.Nonstarter: + return RuleResult.NoBreak; + } + + // LB21: BB × + if (currentClass == LineBreakClass.BreakBefore) + { + return RuleResult.NoBreak; + } + + // LB21b: SY × HL + if (currentClass == LineBreakClass.BreakSymbols && nextClass == LineBreakClass.HebrewLetter) + { + return RuleResult.NoBreak; + } + + // LB22: × IN + if (nextClass == LineBreakClass.Inseparable) + { + return RuleResult.NoBreak; + } + + // LB23: (AL | HL) × NU; NU × (AL | HL) + if (nextClass == LineBreakClass.Numeric && + (currentClass == LineBreakClass.Alphabetic || currentClass == LineBreakClass.HebrewLetter)) + { + return RuleResult.NoBreak; + } + if (currentClass == LineBreakClass.Numeric && + (nextClass == LineBreakClass.Alphabetic || nextClass == LineBreakClass.HebrewLetter)) + { + return RuleResult.NoBreak; + } + + // LB23a: PR × (ID | EB | EM); (ID | EB | EM) × PO + if (currentClass == LineBreakClass.PrefixNumeric && IsIdEbEm(nextClass)) + { + return RuleResult.NoBreak; + } + if (nextClass == LineBreakClass.PostfixNumeric && IsIdEbEm(currentClass)) + { + return RuleResult.NoBreak; + } + + // LB24: (PR | PO) × (AL | HL); (AL | HL) × (PR | PO) + if (IsPrPo(currentClass) && (nextClass == LineBreakClass.Alphabetic || nextClass == LineBreakClass.HebrewLetter)) + { + return RuleResult.NoBreak; + } + if ((currentClass == LineBreakClass.Alphabetic || currentClass == LineBreakClass.HebrewLetter) && IsPrPo(nextClass)) + { + return RuleResult.NoBreak; + } + + return RuleResult.Pass; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + static bool IsIdEbEm(LineBreakClass cls) + => cls == LineBreakClass.Ideographic || cls == LineBreakClass.EBase || cls == LineBreakClass.EModifier; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + static bool IsPrPo(LineBreakClass cls) + => cls == LineBreakClass.PrefixNumeric || cls == LineBreakClass.PostfixNumeric; + } + /// /// LB21: Do not break before hyphen-minus, other hyphens, fixed-width spaces, small kana, and other non-starters, or after acute accents. /// + /// Subsumed by in the rule dispatch table; kept for documentation parity with UAX#14. 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: @@ -724,10 +886,10 @@ private static RuleResult LB21(ReadOnlySpan text, ref LineBreakState state /// 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; @@ -743,7 +905,7 @@ private static RuleResult LB21a(ReadOnlySpan text, ref LineBreakState stat 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; } @@ -757,7 +919,7 @@ private static RuleResult LB21b(ReadOnlySpan text, ref LineBreakState stat 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 +938,7 @@ private static RuleResult LB23(ReadOnlySpan text, ref LineBreakState state case LineBreakClass.HebrewLetter: { // (AL | HL) × NU - if (state.Next(text).LineBreakClass == LineBreakClass.Numeric) + if (state.NextClass == LineBreakClass.Numeric) { return RuleResult.NoBreak; } @@ -787,7 +949,7 @@ private static RuleResult LB23(ReadOnlySpan text, ref LineBreakState state 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; } @@ -808,13 +970,13 @@ private static RuleResult LB23a(ReadOnlySpan text, ref LineBreakState stat { // PR × (ID | EB | EM) if ((state.Current.LineBreakClass == LineBreakClass.PrefixNumeric) - && IsMatch(state.Next(text).LineBreakClass)) + && IsMatch(state.NextClass)) { return RuleResult.NoBreak; } // (ID | EB | EM) × PO - if ((state.Next(text).LineBreakClass == LineBreakClass.PostfixNumeric) + if ((state.NextClass == LineBreakClass.PostfixNumeric) && IsMatch(state.Current.LineBreakClass)) { return RuleResult.NoBreak; @@ -844,13 +1006,13 @@ 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) + && state.NextClass is LineBreakClass.Alphabetic or 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) + && state.NextClass is LineBreakClass.PrefixNumeric or LineBreakClass.PostfixNumeric) { return RuleResult.NoBreak; } @@ -864,7 +1026,7 @@ private static RuleResult LB24(ReadOnlySpan text, ref LineBreakState state /// 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 +1036,7 @@ private static RuleResult LB25(ReadOnlySpan text, ref LineBreakState state // [25.04] NU(SY|IS)* CP × PR case LineBreakClass.CloseParenthesis: { - switch (state.Previous.LineBreakClass) + switch (state.PreviousClass) { case LineBreakClass.Numeric: { @@ -903,7 +1065,7 @@ private static RuleResult LB25(ReadOnlySpan text, ref LineBreakState state case LineBreakClass.BreakSymbols: case LineBreakClass.InfixNumeric: { - if (state.Previous.LineBreakClass == LineBreakClass.Numeric) + if (state.PreviousClass == LineBreakClass.Numeric) { return RuleResult.NoBreak; } @@ -913,7 +1075,7 @@ private static RuleResult LB25(ReadOnlySpan text, ref LineBreakState state // [25.03] NU(SY|IS)* CL × PR case LineBreakClass.ClosePunctuation: { - switch (state.Previous.LineBreakClass) + switch (state.PreviousClass) { case LineBreakClass.Numeric: { @@ -922,7 +1084,7 @@ private static RuleResult LB25(ReadOnlySpan text, ref LineBreakState state case LineBreakClass.BreakSymbols: case LineBreakClass.InfixNumeric: { - if (state.Previous.LineBreakClass == LineBreakClass.Numeric) + if (state.PreviousClass == LineBreakClass.Numeric) { return RuleResult.NoBreak; } @@ -949,7 +1111,7 @@ private static RuleResult LB25(ReadOnlySpan text, ref LineBreakState state case LineBreakClass.BreakSymbols: case LineBreakClass.InfixNumeric: { - if (state.Previous.LineBreakClass == LineBreakClass.Numeric) + if (state.PreviousClass == LineBreakClass.Numeric) { return RuleResult.NoBreak; } @@ -968,7 +1130,7 @@ private static RuleResult LB25(ReadOnlySpan text, ref LineBreakState state // [25.01] NU(SY|IS)* CL × PO case LineBreakClass.ClosePunctuation: { - switch (state.Previous.LineBreakClass) + switch (state.PreviousClass) { case LineBreakClass.Numeric: { @@ -977,7 +1139,7 @@ private static RuleResult LB25(ReadOnlySpan text, ref LineBreakState state case LineBreakClass.BreakSymbols: case LineBreakClass.InfixNumeric: { - if (state.Previous.LineBreakClass == LineBreakClass.Numeric) + if (state.PreviousClass == LineBreakClass.Numeric) { return RuleResult.NoBreak; } @@ -996,7 +1158,7 @@ private static RuleResult LB25(ReadOnlySpan text, ref LineBreakState state case LineBreakClass.BreakSymbols: case LineBreakClass.InfixNumeric: { - if (state.Previous.LineBreakClass == LineBreakClass.Numeric) + if (state.PreviousClass == LineBreakClass.Numeric) { return RuleResult.NoBreak; } @@ -1011,7 +1173,7 @@ private static RuleResult LB25(ReadOnlySpan text, ref LineBreakState state if (state.Current.LineBreakClass == LineBreakClass.PrefixNumeric) { - switch (state.Next(text).LineBreakClass) + switch (state.NextClass) { case LineBreakClass.OpenPunctuation: { @@ -1041,7 +1203,7 @@ private static RuleResult LB25(ReadOnlySpan text, ref LineBreakState state if (state.Current.LineBreakClass == LineBreakClass.PostfixNumeric) { - switch (state.Next(text).LineBreakClass) + switch (state.NextClass) { case LineBreakClass.OpenPunctuation: { @@ -1076,7 +1238,7 @@ private static RuleResult LB25(ReadOnlySpan text, ref LineBreakState state case LineBreakClass.Hyphen: case LineBreakClass.InfixNumeric: { - if (state.Next(text).LineBreakClass == LineBreakClass.Numeric) + if (state.NextClass == LineBreakClass.Numeric) { return RuleResult.NoBreak; } @@ -1087,9 +1249,92 @@ private static RuleResult LB25(ReadOnlySpan text, ref LineBreakState state return RuleResult.Pass; } + /// + /// Combines LB26, LB27, LB28. All three are pure functions of + /// (currentClass, nextClass) and form a contiguous run between the + /// state-machine rules LB25 (preceding) and LB28a (following). LB28 in + /// particular fires for almost every position in word-heavy text, so the + /// shared state read pays off most here. + /// + private static RuleResult LB26_LB28_Combined(ReadOnlySpan text, ref LineBreakState state) + { + var currentClass = state.Current.LineBreakClass; + var nextClass = state.NextClass; + + // LB26: Korean syllable composition. + switch (currentClass) + { + case LineBreakClass.JL: + // JL × (JL | JV | H2 | H3) + switch (nextClass) + { + case LineBreakClass.JL: + case LineBreakClass.JV: + case LineBreakClass.H2: + case LineBreakClass.H3: + return RuleResult.NoBreak; + } + break; + case LineBreakClass.JV: + case LineBreakClass.H2: + // (JV | H2) × (JV | JT) + if (nextClass == LineBreakClass.JV || nextClass == LineBreakClass.JT) + { + return RuleResult.NoBreak; + } + break; + case LineBreakClass.JT: + case LineBreakClass.H3: + // (JT | H3) × JT + if (nextClass == LineBreakClass.JT) + { + return RuleResult.NoBreak; + } + break; + } + + // LB27: Korean syllable + numeric prefix/postfix. + switch (currentClass) + { + case LineBreakClass.JL: + case LineBreakClass.JV: + case LineBreakClass.JT: + case LineBreakClass.H2: + case LineBreakClass.H3: + // (JL | JV | JT | H2 | H3) × PO + if (nextClass == LineBreakClass.PostfixNumeric) + { + return RuleResult.NoBreak; + } + break; + case LineBreakClass.PrefixNumeric: + // PR × (JL | JV | JT | H2 | H3) + switch (nextClass) + { + case LineBreakClass.JL: + case LineBreakClass.JV: + case LineBreakClass.JT: + case LineBreakClass.H2: + case LineBreakClass.H3: + return RuleResult.NoBreak; + } + break; + } + + // LB28: (AL | HL) × (AL | HL) + if ((currentClass == LineBreakClass.Alphabetic || currentClass == LineBreakClass.HebrewLetter) + && (nextClass == LineBreakClass.Alphabetic || nextClass == LineBreakClass.HebrewLetter)) + { + return RuleResult.NoBreak; + } + + return RuleResult.Pass; + } + /// /// LB26: Do not break a Korean syllable. /// + /// Subsumed by ; kept for documentation parity with UAX#14. private static RuleResult LB26(ReadOnlySpan text, ref LineBreakState state) { switch (state.Current.LineBreakClass) @@ -1097,7 +1342,7 @@ private static RuleResult LB26(ReadOnlySpan text, ref LineBreakState state case LineBreakClass.JL: { // JL × (JL | JV | H2 | H3) - switch (state.Next(text).LineBreakClass) + switch (state.NextClass) { case LineBreakClass.JL: case LineBreakClass.JV: @@ -1112,7 +1357,7 @@ private static RuleResult LB26(ReadOnlySpan text, ref LineBreakState state case LineBreakClass.H2: { // (JV | H2) × (JV | JT) - switch (state.Next(text).LineBreakClass) + switch (state.NextClass) { case LineBreakClass.JV: case LineBreakClass.JT: @@ -1124,7 +1369,7 @@ private static RuleResult LB26(ReadOnlySpan text, ref LineBreakState state case LineBreakClass.H3: { // (JT | H3) × JT - if (state.Next(text).LineBreakClass == LineBreakClass.JT) + if (state.NextClass == LineBreakClass.JT) { return RuleResult.NoBreak; } @@ -1149,7 +1394,7 @@ private static RuleResult LB27(ReadOnlySpan text, ref LineBreakState state 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 +1403,7 @@ private static RuleResult LB27(ReadOnlySpan text, ref LineBreakState state 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 +1431,7 @@ private static RuleResult LB28(ReadOnlySpan text, ref LineBreakState state case LineBreakClass.Alphabetic: case LineBreakClass.HebrewLetter: { - switch (state.Next(text).LineBreakClass) + switch (state.NextClass) { case LineBreakClass.Alphabetic: case LineBreakClass.HebrewLetter: @@ -1218,7 +1463,7 @@ private static RuleResult LB28a(ReadOnlySpan text, ref LineBreakState stat // [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 +1471,7 @@ private static RuleResult LB28a(ReadOnlySpan text, ref LineBreakState stat // [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; } @@ -1255,7 +1500,7 @@ 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 +1533,7 @@ private static RuleResult LB30(ReadOnlySpan text, ref LineBreakState state // [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 +1558,7 @@ private static RuleResult LB30(ReadOnlySpan text, ref LineBreakState state /// 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 +1578,7 @@ private static RuleResult LB30b(ReadOnlySpan text, ref LineBreakState stat 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 +1591,7 @@ private static RuleResult LB30b(ReadOnlySpan text, ref LineBreakState stat // 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) { @@ -1413,11 +1658,6 @@ private static LineBreakClass MapClass(Codepoint cp) return LineBreakClass.Alphabetic; } - if (cp.Value == 327685) - { - return LineBreakClass.Alphabetic; - } - // LB 1 // ========================================== // Resolved Original General_Category @@ -1460,14 +1700,25 @@ private static LineBreakClass MapClass(Codepoint cp) 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 +1726,24 @@ public LineBreakState() 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 +1751,7 @@ public BreakUnit Previous if (_previous.Ignored || _previous.Inherited) { _previous = LastBeforeWhitespace; + _previousClass = LastBeforeWhitespace.LineBreakClass; } return _previous; @@ -1490,9 +1760,23 @@ public BreakUnit Previous 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 +1803,17 @@ public static BreakUnit Before(ReadOnlySpan text, BreakUnit current) 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 +1857,7 @@ public BreakUnit Peek(ReadOnlySpan text) public BreakUnit Read(ReadOnlySpan text) { _previous = Current; + _previousClass = Current.LineBreakClass; var next = Next(text); @@ -1575,12 +1865,17 @@ public BreakUnit Read(ReadOnlySpan text) 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 +1885,6 @@ public BreakUnit Read(ReadOnlySpan text) 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; @@ -1633,16 +1926,12 @@ internal static LineBreakClass ClassAfterSpaces(ReadOnlySpan text, BreakUn LB03, LB04, LB05, - LB06, - LB07, + LB06_LB07_Combined, // Replaces LB06, LB07 LB08, LB08a, LB09, LB10, - LB11, - LB12, - LB12a, - LB13, + LB11_LB13_Combined, // Replaces LB11, LB12, LB12a, LB13 LB14, LB15a, LB15b, @@ -1654,17 +1943,10 @@ internal static LineBreakClass ClassAfterSpaces(ReadOnlySpan text, BreakUn LB19, LB20, LB20a, - LB21a, // Must be before LB21 - LB21, - LB21b, - LB22, - LB23, - LB23a, - LB24, + LB21a, // Must be before LB21_LB24_Combined + LB21_LB24_Combined, // Replaces LB21, LB21b, LB22, LB23, LB23a, LB24 LB25, - LB26, - LB27, - LB28, + LB26_LB28_Combined, // Replaces LB26, LB27, LB28 LB28a, LB29, LB30, 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 fd37eed68dd..00000000000 --- 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/Avalonia.Base/Media/TextPathSegmentEllipsis.cs b/src/Avalonia.Base/Media/TextPathSegmentEllipsis.cs index 700f861d4d4..0a8d089177f 100644 --- a/src/Avalonia.Base/Media/TextPathSegmentEllipsis.cs +++ b/src/Avalonia.Base/Media/TextPathSegmentEllipsis.cs @@ -78,6 +78,18 @@ public TextPathSegmentEllipsis(string ellipsis, double width, TextRunProperties logicalRuns.Add(r); } + // Pre-compute cumulative run start char positions so that + // MeasureSegmentWidth can binary-search to the first overlapping + // run instead of re-scanning from index 0 on every call. Built + // once per Collapse; reused by every segment-width measurement. + // runStartChars[i] = sum of lengths of runs 0..i-1; + // runStartChars[Count] = total char length (sentinel). + var runStartChars = new int[logicalRuns.Count + 1]; + for (var i = 0; i < logicalRuns.Count; i++) + { + runStartChars[i + 1] = runStartChars[i] + logicalRuns[i].Length; + } + // Segment ranges var segments = new List<(int Start, int Length, double Width, bool IsSeparator)>(); var candidateSegmentIndices = new List(); @@ -105,12 +117,12 @@ public TextPathSegmentEllipsis(string ellipsis, double width, TextRunProperties // finish previous non-separator segment if (!inSeparator && globalIndex - currentSegStart > 0) { - var segmentWidth = TextPathSegmentEllipsis.MeasureSegmentWidth(logicalRuns, currentSegStart, globalIndex - currentSegStart); + var segmentWidth = MeasureSegmentWidth(logicalRuns, runStartChars, currentSegStart, globalIndex - currentSegStart); segments.Add((currentSegStart, globalIndex - currentSegStart, segmentWidth, false)); } - var separatorWidth = TextPathSegmentEllipsis.MeasureSegmentWidth(logicalRuns, globalIndex, 1); + var separatorWidth = MeasureSegmentWidth(logicalRuns, runStartChars, globalIndex, 1); // separator as its own segment segments.Add((globalIndex, 1, separatorWidth, true)); @@ -149,7 +161,7 @@ public TextPathSegmentEllipsis(string ellipsis, double width, TextRunProperties // Add last pending segment if any if (globalIndex - currentSegStart > 0) { - var segmentWidth = TextPathSegmentEllipsis.MeasureSegmentWidth(logicalRuns, currentSegStart, globalIndex - currentSegStart); + var segmentWidth = MeasureSegmentWidth(logicalRuns, runStartChars, currentSegStart, globalIndex - currentSegStart); segments.Add((currentSegStart, globalIndex - currentSegStart, segmentWidth, false)); } @@ -352,7 +364,17 @@ public TextPathSegmentEllipsis(string ellipsis, double width, TextRunProperties { var splitAt = shapedRun.Length - length; - (_, trimmedRun) = shapedRun.Split(splitAt); + if (splitAt > 0) + { + (_, trimmedRun) = shapedRun.Split(splitAt); + } + else if (length > 0) + { + // The whole run fits in the remaining budget — no split needed, + // use the run as-is. (Calling Split(0) throws.) + trimmedRun = shapedRun; + } + // else: length == 0 → nothing of this run survives; trimmedRun stays null. } } } @@ -418,46 +440,51 @@ private bool IsSeparator(char ch) /// /// Calculates the total width of a specified segment within a sequence of text runs. /// - /// The method accounts for partial overlaps between the segment and individual text - /// runs. Drawable runs are measured as a whole if any part overlaps the segment. - /// The collection of text runs to measure. Each run represents a contiguous sequence of formatted text. - /// The zero-based index of the first character in the segment to measure, relative to the combined text runs. - /// The number of characters in the segment to measure. Must be non-negative. - /// The total width, in device-independent units, of the specified text segment. Returns 0.0 if the segment is - /// empty or does not overlap any runs. - private static double MeasureSegmentWidth(IReadOnlyList runs, int segmentStart, int segmentLength) + /// + /// Uses the pre-computed cumulative-offset table + /// to binary-search the first overlapping run (O(log N)) instead of re-scanning all + /// runs from index 0 on every call. For each shaped overlap, delegates to + /// , which uses the cluster-width cache — + /// O(log clusters) per call and direction-agnostic (the cache is built in logical + /// order for both LTR and RTL buffers). Drawable runs are measured as a whole if + /// they fully overlap the segment, matching the original behavior. + /// + /// The collection of text runs to measure. + /// Cumulative char-offset table; entry i is the + /// total length of runs 0..i-1, entry Count is the total char length. + /// Zero-based start index of the segment, relative to the combined text runs. + /// Number of characters in the segment. Must be non-negative. + /// The segment width in device-independent units, or 0 if the segment is empty or out of range. + private static double MeasureSegmentWidth(IReadOnlyList runs, int[] runStartChars, int segmentStart, int segmentLength) { - // segment range in global character indices + if (segmentLength <= 0) + { + return 0.0; + } + var segmentEnd = segmentStart + segmentLength; - var currentChar = 0; - double width = 0.0; - for (var i = 0; i < runs.Count; i++) - { - var run = runs[i]; - var runStart = currentChar; - var runEnd = runStart + run.Length; + // Binary search runStartChars for the largest i with runStartChars[i] <= segmentStart. + // That's the first run whose range can overlap the segment. + var i = FindFirstOverlappingRun(runStartChars, segmentStart); - // no overlap with requested segment - if (runEnd <= segmentStart) - { - currentChar = runEnd; - continue; - } + double width = 0.0; + for (; i < runs.Count; i++) + { + var runStart = runStartChars[i]; if (runStart >= segmentEnd) { break; } - // overlap range within this run [overlapStart, overlapEnd) + var run = runs[i]; + var runEnd = runStart + run.Length; + var overlapStart = Math.Max(segmentStart, runStart); var overlapEnd = Math.Min(segmentEnd, runEnd); - var overlapLen = overlapEnd - overlapStart; - - if (overlapLen <= 0) + if (overlapEnd <= overlapStart) { - currentChar = runEnd; continue; } @@ -465,55 +492,58 @@ private static double MeasureSegmentWidth(IReadOnlyList runs, int segme { case ShapedTextRun shaped: { - var buffer = shaped.ShapedBuffer; - if (buffer.Length == 0) - { - break; - } - - // local char offsets inside this run - var localStart = overlapStart - runStart; - var localEnd = overlapEnd - runStart; - - // base cluster used by this buffer (see ShapedBuffer.Split logic) - var baseCluster = buffer[0].GlyphCluster; - - // glyph clusters are increasing — stop once we passed localEnd - for (var gi = 0; gi < buffer.Length; gi++) - { - var g = buffer[gi]; - var clusterLocal = g.GlyphCluster - baseCluster; - - if (clusterLocal < localStart) - continue; - - if (clusterLocal >= localEnd) - break; - - width += g.GlyphAdvance; - } - + // ShapedBuffer.GetCharRangeWidth uses the cluster cache; O(log clusters). + width += shaped.ShapedBuffer.GetCharRangeWidth(overlapStart - runStart, overlapEnd - runStart); break; } case DrawableTextRun d: { - // For drawable runs, count full width if they completely overlap - if (overlapLen >= d.Length) + // Drawables are atomic: count full width when they completely overlap. + if (overlapEnd - overlapStart >= d.Length) { width += d.Size.Width; } break; } - default: - { - break; - } } - - currentChar = runEnd; } return width; } + + /// + /// Binary-search for the largest index + /// i such that runStartChars[i] <= charIndex. That index + /// is the first run that can contain or precede . + /// + private static int FindFirstOverlappingRun(int[] runStartChars, int charIndex) + { + if (charIndex <= 0) + { + return 0; + } + + var lo = 0; + // Upper bound excludes the sentinel entry; we want a run index, not a boundary. + var hi = runStartChars.Length - 2; + if (hi < 0) + { + return 0; + } + + while (lo < hi) + { + var mid = (lo + hi + 1) >> 1; + if (runStartChars[mid] <= charIndex) + { + lo = mid; + } + else + { + hi = mid - 1; + } + } + return lo; + } } } diff --git a/src/Skia/Avalonia.Skia/GlyphRunImpl.cs b/src/Skia/Avalonia.Skia/GlyphRunImpl.cs index e7f772190d7..1d10a54765a 100644 --- a/src/Skia/Avalonia.Skia/GlyphRunImpl.cs +++ b/src/Skia/Avalonia.Skia/GlyphRunImpl.cs @@ -39,18 +39,14 @@ public GlyphRunImpl(GlyphTypeface glyphTypeface, double fontRenderingEmSize, _glyphIndices = new ushort[count]; _glyphPositions = new SKPoint[count]; - var currentX = 0.0; - + // C1: 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,22 +63,30 @@ public GlyphRunImpl(GlyphTypeface glyphTypeface, double fontRenderingEmSize, using var font = CreateFont(defaultTextOptions); - var runBounds = new Rect(); var glyphBounds = ArrayPool.Shared.Rent(count); font.GetGlyphWidths(_glyphIndices, null, glyphBounds.AsSpan(0, count)); - currentX = 0; + // C1 fused walk: 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++) + for (int i = 0; i < count; i++) { + var glyphInfo = glyphInfos[i]; + var offset = glyphInfo.GlyphOffset; var gBounds = glyphBounds[i]; - var advance = glyphInfos[i].GlyphAdvance; + + _glyphPositions[i] = new SKPoint((float)(currentX + offset.X), (float)offset.Y); runBounds = runBounds.Union(new Rect(currentX + gBounds.Left, gBounds.Top, gBounds.Width, gBounds.Height)); - currentX += advance; + currentX += glyphInfo.GlyphAdvance; } + ArrayPool.Shared.Return(glyphBounds); BaselineOrigin = baselineOrigin; 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 00000000000..07d2a4a6ab8 --- /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/LineBreakEnumeratorTests.cs b/tests/Avalonia.Base.UnitTests/Media/TextFormatting/LineBreakEnumeratorTests.cs index 59ddf6932ef..0d67257196f 100644 --- a/tests/Avalonia.Base.UnitTests/Media/TextFormatting/LineBreakEnumeratorTests.cs +++ b/tests/Avalonia.Base.UnitTests/Media/TextFormatting/LineBreakEnumeratorTests.cs @@ -88,7 +88,7 @@ public void ForwardTextWithOuterWhitespace() Assert.Equal(21, positionsF[3].PositionMeasure); } - [Theory(Skip = "Only run when we update Unicode data.")] + [Theory] [ClassData(typeof(LineBreakTestDataGenerator))] public void ShouldFindBreaks(int lineNumber, int[] codePoints, int[] breakPoints, string rules) { 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 00000000000..d55115fc5b3 --- /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 00000000000..08be1cb4ee1 --- /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/UnicodeEnumeratorAllocationTests.cs b/tests/Avalonia.Base.UnitTests/Media/TextFormatting/UnicodeEnumeratorAllocationTests.cs new file mode 100644 index 00000000000..2cbba25b66c --- /dev/null +++ b/tests/Avalonia.Base.UnitTests/Media/TextFormatting/UnicodeEnumeratorAllocationTests.cs @@ -0,0 +1,124 @@ +using System; +using System.Linq; +using Avalonia.Media.TextFormatting.Unicode; +using Xunit; + +namespace Avalonia.Base.UnitTests.Media.TextFormatting; + +/// +/// Locks the "no managed allocations on the hot enumeration path" contract for +/// the three Unicode break enumerators. Uses +/// as the ground truth, +/// which is more reliable than BenchmarkDotNet's MemoryDiagnoser when +/// the latter runs in-process (its own bookkeeping leaks into the per-op +/// allocation count). Any future change that introduces a per-iteration +/// allocation in , , +/// or fails this test instead of hiding behind +/// benchmark noise. +/// +public class UnicodeEnumeratorAllocationTests +{ + private const int WarmupIterations = 100; + private const int MeasureIterations = 1000; + + [Fact] + public void LineBreakEnumerator_DoesNotAllocate() + { + var text = BuildSampleText(); + + // Warm up: JIT every hot path, fault in any lazily-initialised statics. + for (var i = 0; i < WarmupIterations; i++) + { + var w = new LineBreakEnumerator(text.AsSpan()); + while (w.MoveNext(out _)) { } + } + + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + + var before = GC.GetAllocatedBytesForCurrentThread(); + for (var i = 0; i < MeasureIterations; i++) + { + var e = new LineBreakEnumerator(text.AsSpan()); + while (e.MoveNext(out _)) { } + } + var after = GC.GetAllocatedBytesForCurrentThread(); + + Assert.Equal(0L, after - before); + } + + [Fact] + public void WordBreakEnumerator_DoesNotAllocate() + { + var text = BuildSampleText(); + + for (var i = 0; i < WarmupIterations; i++) + { + var w = new WordBreakEnumerator(text.AsSpan()); + while (w.MoveNext(out _)) { } + } + + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + + var before = GC.GetAllocatedBytesForCurrentThread(); + for (var i = 0; i < MeasureIterations; i++) + { + var e = new WordBreakEnumerator(text.AsSpan()); + while (e.MoveNext(out _)) { } + } + var after = GC.GetAllocatedBytesForCurrentThread(); + + Assert.Equal(0L, after - before); + } + + [Fact] + public void GraphemeEnumerator_DoesNotAllocate() + { + var text = BuildSampleText(); + + for (var i = 0; i < WarmupIterations; i++) + { + var w = new GraphemeEnumerator(text.AsSpan()); + while (w.MoveNext(out _)) { } + } + + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + + var before = GC.GetAllocatedBytesForCurrentThread(); + for (var i = 0; i < MeasureIterations; i++) + { + var e = new GraphemeEnumerator(text.AsSpan()); + while (e.MoveNext(out _)) { } + } + var after = GC.GetAllocatedBytesForCurrentThread(); + + Assert.Equal(0L, after - before); + } + + /// + /// Mixed Latin / Cyrillic / Greek / CJK in the BMP — same distribution as + /// the benchmark's Bmp fixture. Exercising the non-ASCII paths in + /// the enumerators is the case where any latent allocation is most likely + /// to surface. + /// + private static string BuildSampleText() + { + var rng = new Random(42); + return new string(Enumerable.Range(0, 1024).Select(_ => + { + var bucket = rng.Next(4); + return bucket switch + { + 0 => (char)rng.Next(0x0020, 0x007F), + 1 => (char)rng.Next(0x0400, 0x0500), + 2 => (char)rng.Next(0x0370, 0x0400), + _ => (char)rng.Next(0x4E00, 0x9FFF), + }; + }).ToArray()); + } +} 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 00000000000..6301057ba14 --- /dev/null +++ b/tests/Avalonia.Base.UnitTests/Media/TextFormatting/UnicodeTrieTests.cs @@ -0,0 +1,183 @@ +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 +{ + // --- Production trie branch coverage ------------------------------------ + + [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)); + } + + // --- Synthetic trie ----------------------------------------------------- + + [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 8f7aa3eb79e..85c81467b7d 100644 --- a/tests/Avalonia.Benchmarks/Program.cs +++ b/tests/Avalonia.Benchmarks/Program.cs @@ -1,6 +1,14 @@ +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.Platform; +using Avalonia.Skia; +using Avalonia.UnitTests; using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Configs; using BenchmarkDotNet.Running; @@ -11,6 +19,17 @@ class Program { 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 +52,65 @@ static void Main(string[] args) 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 00000000000..6c47f741fe0 --- /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 (which each +/// construct a new ), 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 da2519188e7..fabeb5bca8a 100644 --- a/tests/Avalonia.Benchmarks/Text/HugeTextLayout.cs +++ b/tests/Avalonia.Benchmarks/Text/HugeTextLayout.cs @@ -66,7 +66,7 @@ It may reveal how the matters of peculiar interest slowly the goals and objectiv [Benchmark] public TextLayout BuildTextLayout() => MakeLayout(Text); - private const string Emojis = @"😀 😁 😂 🤣 😃 😄 😅 😆 😉 😊 😋 😎 😍 😘 🥰 😗 😙 😚 ☺️ 🙂 🤗 🤩 🤔 🤨 😐 😑 😶 🙄 😏 😣 😥 😮 🤐 😯 😪 😫 😴 😌 😛 😜 😝 🤤 😒 😓 😔 😕 🙃 🤑 😲 ☹️ 🙁 😖 😞 😟 😤 😢 😭 😦 😧 😨 😩 🤯 😬 😰 😱 🥵 🥶 😳 🤪 😵 😡 😠 🤬 😷 🤒 🤕 🤢 🤮 🤧 😇 🤠 🤡 🥳 🥴 🥺 🤥 🤫 🤭 🧐 🤓 😈 👿 👹 👺 💀 👻 👽 🤖 💩 😺 😸 😹 😻 😼 😽 🙀 😿 😾 + internal const string EmojisText = @"😀 😁 😂 🤣 😃 😄 😅 😆 😉 😊 😋 😎 😍 😘 🥰 😗 😙 😚 ☺️ 🙂 🤗 🤩 🤔 🤨 😐 😑 😶 🙄 😏 😣 😥 😮 🤐 😯 😪 😫 😴 😌 😛 😜 😝 🤤 😒 😓 😔 😕 🙃 🤑 😲 ☹️ 🙁 😖 😞 😟 😤 😢 😭 😦 😧 😨 😩 🤯 😬 😰 😱 🥵 🥶 😳 🤪 😵 😡 😠 🤬 😷 🤒 🤕 🤢 🤮 🤧 😇 🤠 🤡 🥳 🥴 🥺 🤥 🤫 🤭 🧐 🤓 😈 👿 👹 👺 💀 👻 👽 🤖 💩 😺 😸 😹 😻 😼 😽 🙀 😿 😾 👶 👧 🧒 👦 👩 🧑 👨 👵 🧓 👴 👲 👳‍♀️ 👳‍♂️ 🧕 🧔 👱‍♂️ 👱‍♀️ 👨‍🦰 👩‍🦰 👨‍🦱 👩‍🦱 👨‍🦲 👩‍🦲 👨‍🦳 👩‍🦳 🦸‍♀️ 🦸‍♂️ 🦹‍♀️ 🦹‍♂️ 👮‍♀️ 👮‍♂️ 👷‍♀️ 👷‍♂️ 💂‍♀️ 💂‍♂️ 🕵️‍♀️ 🕵️‍♂️ 👩‍⚕️ 👨‍⚕️ 👩‍🌾 👨‍🌾 👩‍🍳 👨‍🍳 👩‍🎓 👨‍🎓 👩‍🎤 👨‍🎤 👩‍🏫 👨‍🏫 👩‍🏭 👨‍🏭 👩‍💻 👨‍💻 👩‍💼 👨‍💼 👩‍🔧 👨‍🔧 👩‍🔬 👨‍🔬 👩‍🎨 👨‍🎨 👩‍🚒 👨‍🚒 👩‍✈️ 👨‍✈️ 👩‍🚀 👨‍🚀 👩‍⚖️ 👨‍⚖️ 👰 🤵 👸 🤴 🤶 🎅 🧙‍♀️ 🧙‍♂️ 🧝‍♀️ 🧝‍♂️ 🧛‍♀️ 🧛‍♂️ 🧟‍♀️ 🧟‍♂️ 🧞‍♀️ 🧞‍♂️ 🧜‍♀️ 🧜‍♂️ 🧚‍♀️ 🧚‍♂️ 👼 🤰 🤱 🙇‍♀️ 🙇‍♂️ 💁‍♀️ 💁‍♂️ 🙅‍♀️ 🙅‍♂️ 🙆‍♀️ 🙆‍♂️ 🙋‍♀️ 🙋‍♂️ 🤦‍♀️ 🤦‍♂️ 🤷‍♀️ 🤷‍♂️ 🙎‍♀️ 🙎‍♂️ 🙍‍♀️ 🙍‍♂️ 💇‍♀️ 💇‍♂️ 💆‍♀️ 💆‍♂️ 🧖‍♀️ 🧖‍♂️ 💅 🤳 💃 🕺 👯‍♀️ 👯‍♂️ 🕴 🚶‍♀️ 🚶‍♂️ 🏃‍♀️ 🏃‍♂️ 👫 👭 👬 💑 👩‍❤️‍👩 👨‍❤️‍👨 💏 👩‍❤️‍💋‍👩 👨‍❤️‍💋‍👨 👪 👨‍👩‍👧 👨‍👩‍👧‍👦 👨‍👩‍👦‍👦 👨‍👩‍👧‍👧 👩‍👩‍👦 👩‍👩‍👧 👩‍👩‍👧‍👦 👩‍👩‍👦‍👦 👩‍👩‍👧‍👧 👨‍👨‍👦 👨‍👨‍👧 👨‍👨‍👧‍👦 👨‍👨‍👦‍👦 👨‍👨‍👧‍👧 👩‍👦 👩‍👧 👩‍👧‍👦 👩‍👦‍👦 👩‍👧‍👧 👨‍👦 👨‍👧 👨‍👧‍👦 👨‍👦‍👦 👨‍👧‍👧 🤲 👐 🙌 👏 🤝 👍 👎 👊 ✊ 🤛 🤜 🤞 ✌️ 🤟 🤘 👌 👈 👉 👆 👇 ☝️ ✋ 🤚 🖐 🖖 👋 🤙 💪 🦵 🦶 🖕 ✍️ 🙏 💍 💄 💋 👄 👅 👂 👃 👣 👁 👀 🧠 🦴 🦷 🗣 👤 👥 🧥 👚 👕 👖 👔 👗 👙 👘 👠 👡 👢 👞 👟 🥾 🥿 🧦 🧤 🧣 🎩 🧢 👒 🎓 ⛑ 👑 👝 👛 👜 💼 🎒 👓 🕶 🥽 🥼 🌂 🧵 🧶 👶🏻 👦🏻 👧🏻 👨🏻 👩🏻 👱🏻‍♀️ 👱🏻 👴🏻 👵🏻 👲🏻 👳🏻‍♀️ 👳🏻 👮🏻‍♀️ 👮🏻 👷🏻‍♀️ 👷🏻 💂🏻‍♀️ 💂🏻 🕵🏻‍♀️ 🕵🏻 👩🏻‍⚕️ 👨🏻‍⚕️ 👩🏻‍🌾 👨🏻‍🌾 👩🏻‍🍳 👨🏻‍🍳 👩🏻‍🎓 👨🏻‍🎓 👩🏻‍🎤 👨🏻‍🎤 👩🏻‍🏫 👨🏻‍🏫 👩🏻‍🏭 👨🏻‍🏭 👩🏻‍💻 👨🏻‍💻 👩🏻‍💼 👨🏻‍💼 👩🏻‍🔧 👨🏻‍🔧 👩🏻‍🔬 👨🏻‍🔬 👩🏻‍🎨 👨🏻‍🎨 👩🏻‍🚒 👨🏻‍🚒 👩🏻‍✈️ 👨🏻‍✈️ 👩🏻‍🚀 👨🏻‍🚀 👩🏻‍⚖️ 👨🏻‍⚖️ 🤶🏻 🎅🏻 👸🏻 🤴🏻 👰🏻 🤵🏻 👼🏻 🤰🏻 🙇🏻‍♀️ 🙇🏻 💁🏻 💁🏻‍♂️ 🙅🏻 🙅🏻‍♂️ 🙆🏻 🙆🏻‍♂️ 🙋🏻 🙋🏻‍♂️ 🤦🏻‍♀️ 🤦🏻‍♂️ 🤷🏻‍♀️ 🤷🏻‍♂️ 🙎🏻 🙎🏻‍♂️ 🙍🏻 🙍🏻‍♂️ 💇🏻 💇🏻‍♂️ 💆🏻 💆🏻‍♂️ 🕴🏻 💃🏻 🕺🏻 🚶🏻‍♀️ 🚶🏻 🏃🏻‍♀️ 🏃🏻 🤲🏻 👐🏻 🙌🏻 👏🏻 🙏🏻 👍🏻 👎🏻 👊🏻 ✊🏻 🤛🏻 🤜🏻 🤞🏻 ✌🏻 🤟🏻 🤘🏻 👌🏻 👈🏻 👉🏻 👆🏻 👇🏻 ☝🏻 ✋🏻 🤚🏻 🖐🏻 🖖🏻 👋🏻 🤙🏻 💪🏻 🖕🏻 ✍🏻 🤳🏻 💅🏻 👂🏻 👃🏻 @@ -84,7 +84,7 @@ It may reveal how the matters of peculiar interest slowly the goals and objectiv 🥱 🤏 🦾 🦿 🦻 🧏 🧏‍♂️ 🧏‍♀️ 🧍 🧍‍♂️ 🧍‍♀️ 🧎 🧎‍♂️ 🧎‍♀️ 👨‍🦯 👩‍🦯 👨‍🦼 👩‍🦼 👨‍🦽 👩‍🦽 🦧 🦮 🐕‍🦺 🦥 🦦 🦨 🦩 🧄 🧅 🧇 🧆 🧈 🦪 🧃 🧉 🧊 🛕 🦽 🦼 🛺 🪂 🪐 🤿 🪀 🪁 🦺 🥻 🩱 🩲 🩳 🩰 🪕 🪔 🪓 🦯 🩸 🩹 🩺 🪑 🪒 🤎 🤍 🟠 🟡 🟢 🟣 🟤 🟥 🟧 🟨 🟩 🟦 🟪 🟫"; [Benchmark] - public TextLayout BuildEmojisTextLayout() => MakeLayout(Emojis); + public TextLayout BuildEmojisTextLayout() => MakeLayout(EmojisText); [Benchmark] public TextLayout[] BuildManySmallTexts() diff --git a/tests/Avalonia.Benchmarks/Text/TextLayoutProfile.cs b/tests/Avalonia.Benchmarks/Text/TextLayoutProfile.cs new file mode 100644 index 00000000000..ed7b0557b77 --- /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 00000000000..166d5453e07 --- /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 00000000000..f4ba6220a5e --- /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/ShapedBufferLifetimeTests.cs b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/ShapedBufferLifetimeTests.cs new file mode 100644 index 00000000000..c4bf84a8bed --- /dev/null +++ b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/ShapedBufferLifetimeTests.cs @@ -0,0 +1,188 @@ +#nullable enable + +using System.Linq; +using Avalonia.Media; +using Avalonia.Media.TextFormatting; +using Xunit; + +namespace Avalonia.Skia.UnitTests.Media.TextFormatting +{ + /// + /// rents its glyph storage from + /// ArrayPool<GlyphInfo>.Shared and / + /// produce child buffers that view into + /// the owner's pool-rented array via ArraySlice. Without a shared + /// ownership refcount, disposing the owner (which + /// TextFormatterImpl.SplitTextRun does unconditionally) returned + /// the pool array while children still referenced it; later pool consumers + /// could then corrupt those views' glyph data. + /// + /// The tests below assert the ownership invariant directly via the + /// internal ShapedBuffer.IsPoolArrayRented test hook — that's + /// deterministic regardless of whether ArrayPool happens to re-rent + /// the same array, and it pins the contract the fix introduces: the pool + /// array survives as long as any view into it is still alive. + /// + public class ShapedBufferLifetimeTests + { + [Fact] + public void Split_Children_Keep_Pool_Array_Alive_Until_All_Disposed() + { + using (TextFormatterTests.Start()) + { + const string text = "Hello world abcdef"; + var ownerRun = BuildShapedRun(text, FlowDirection.LeftToRight); + var ownerBuffer = ownerRun.ShapedBuffer; + Assert.True(ownerBuffer.IsPoolArrayRented); + + var split = ownerRun.Split(text.Length / 2); + Assert.NotNull(split.First); + Assert.NotNull(split.Second); + + // Mimic TextFormatterImpl.SplitTextRuns: dispose the owner + // immediately after splitting. The contract: while child views + // still reference the rented array, it must NOT be returned + // to the pool. + ownerRun.Dispose(); + Assert.True(ownerBuffer.IsPoolArrayRented); + + split.First!.Dispose(); + Assert.True(ownerBuffer.IsPoolArrayRented); + + split.Second!.Dispose(); + // Last reference released — now the pool array is returned. + Assert.False(ownerBuffer.IsPoolArrayRented); + } + } + + [Fact] + public void Split_Children_Keep_Pool_Array_Alive_Rtl() + { + using (TextFormatterTests.Start()) + { + const string text = "السلام عليكم ورحمة الله وبركاته"; + var ownerRun = BuildShapedRun(text, FlowDirection.RightToLeft); + var ownerBuffer = ownerRun.ShapedBuffer; + Assert.True(ownerBuffer.IsPoolArrayRented); + + var split = ownerRun.Split(text.Length / 2); + Assert.NotNull(split.First); + Assert.NotNull(split.Second); + + ownerRun.Dispose(); + Assert.True(ownerBuffer.IsPoolArrayRented); + + split.First!.Dispose(); + split.Second!.Dispose(); + Assert.False(ownerBuffer.IsPoolArrayRented); + } + } + + [Fact] + public void Chained_Splits_All_Share_Owner_Refcount() + { + using (TextFormatterTests.Start()) + { + const string text = "Hello world abcdef ghijkl"; + var ownerRun = BuildShapedRun(text, FlowDirection.LeftToRight); + var ownerBuffer = ownerRun.ShapedBuffer; + + // Split, then split a child again. The grandchild must also + // keep the original owner's pool array alive. + var split1 = ownerRun.Split(text.Length / 2); + Assert.NotNull(split1.First); + Assert.NotNull(split1.Second); + + var split2 = split1.Second!.Split(4); + Assert.NotNull(split2.First); + Assert.NotNull(split2.Second); + + ownerRun.Dispose(); + split1.First!.Dispose(); + split1.Second!.Dispose(); + Assert.True(ownerBuffer.IsPoolArrayRented); + + split2.First!.Dispose(); + Assert.True(ownerBuffer.IsPoolArrayRented); + + split2.Second!.Dispose(); + Assert.False(ownerBuffer.IsPoolArrayRented); + } + } + + [Fact] + public void WithBidiLevel_View_Keeps_Pool_Array_Alive() + { + using (TextFormatterTests.Start()) + { + const string text = "aaa bbb"; + var ownerRun = BuildShapedRun(text, FlowDirection.LeftToRight); + var ownerBuffer = ownerRun.ShapedBuffer; + + // WithBidiLevel returns `this` when the level matches; flip to + // ensure a fresh view is created (which is what trips the bug). + var differentLevel = (sbyte)(ownerBuffer.BidiLevel == 0 ? 1 : 0); + var viewBuffer = ownerBuffer.WithBidiLevel(differentLevel); + Assert.NotSame(ownerBuffer, viewBuffer); + + ownerRun.Dispose(); + Assert.True(ownerBuffer.IsPoolArrayRented); + + viewBuffer.Dispose(); + Assert.False(ownerBuffer.IsPoolArrayRented); + } + } + + [Fact] + public void Double_Dispose_On_Owner_Is_Safe() + { + using (TextFormatterTests.Start()) + { + const string text = "abcdef"; + var run = BuildShapedRun(text, FlowDirection.LeftToRight); + run.Dispose(); + // Without an idempotent dispose we'd double-return the pool + // array, corrupting ArrayPool state. + run.Dispose(); + } + } + + [Fact] + public void Double_Dispose_On_Split_Child_Is_Safe() + { + using (TextFormatterTests.Start()) + { + const string text = "abcdef"; + var run = BuildShapedRun(text, FlowDirection.LeftToRight); + + var split = run.Split(3); + Assert.NotNull(split.First); + Assert.NotNull(split.Second); + + split.First!.Dispose(); + split.First!.Dispose(); + split.Second!.Dispose(); + run.Dispose(); + } + } + + // -- helpers -------------------------------------------------------- + + private static ShapedTextRun BuildShapedRun(string text, FlowDirection flow) + { + var props = new GenericTextRunProperties(Typeface.Default, 12, foregroundBrush: Brushes.Black); + var paragraphProps = new GenericTextParagraphProperties( + flow, TextAlignment.Left, true, true, props, TextWrapping.NoWrap, 0, 0, 0); + var source = new SingleBufferTextSource(text, props); + var formatter = new TextFormatterImpl(); + var line = formatter.FormatLine(source, 0, double.PositiveInfinity, paragraphProps); + Assert.NotNull(line); + var run = line!.TextRuns.OfType().FirstOrDefault(); + Assert.NotNull(run); + // Note: managed object scope doesn't auto-dispose, so we don't need + // to AddRef — the returned ShapedTextRun starts at refcount=1 and + // the test controls disposal explicitly. + return run!; + } + } +} 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 00000000000..1706cd0a0a0 --- /dev/null +++ b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/SplitTextRunsTests.cs @@ -0,0 +1,396 @@ +#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 + { + // ----- Failing test that pins the bug we're fixing -------------------- + + [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); + } + } + + // ----- length-boundary cases ------------------------------------------ + + [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); + } + } + + // ----- exact-boundary cases (between runs) ---------------------------- + + [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); + } + } + + // ----- non-splittable run, snap-before cases (the bug) ---------------- + + [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); + } + } + + // ----- mixed sequences ------------------------------------------------- + + [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); + } + } + + // ----- zero-length runs (cluster boundaries) -------------------------- + + [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); + } + } + + // ----- helpers -------------------------------------------------------- + + /// + /// 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/TextCollapsingBidiTests.cs b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextCollapsingBidiTests.cs new file mode 100644 index 00000000000..83b8aec8a94 --- /dev/null +++ b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextCollapsingBidiTests.cs @@ -0,0 +1,638 @@ +#nullable enable + +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Avalonia.Media; +using Avalonia.Media.TextFormatting; +using Xunit; + +namespace Avalonia.Skia.UnitTests.Media.TextFormatting +{ + /// + /// Characterization tests for + /// implementations, with emphasis on BiDi correctness. Pins current + /// behavior before the fixes described in + /// planning/text-collapsing-bidi-plan.md. + /// + /// Conventions: + /// * Tests that pass today are [Fact] — they protect against + /// regressions from the upcoming refactor. + /// * Tests that document a known bug carry [Fact(Skip = "Bx: …")] + /// so the suite stays green; remove the Skip when the + /// corresponding fix lands. + /// * Assertions target invariants (content preserved, ellipsis present, + /// prefix preserved, etc.) rather than exact glyph output so they + /// survive font changes. + /// + public class TextCollapsingBidiTests + { + // --- LTR sanity (regression guards) ----------------------------------- + + [Fact] + public void Ltr_TrailingCharacter_Trims_From_End() + { + using (TextFormatterTests.Start()) + { + var line = BuildLine("Hello world", FlowDirection.LeftToRight); + var collapsing = TrailingChar(line.Width / 2, FlowDirection.LeftToRight); + var collapsed = line.Collapse(collapsing); + + AssertCollapsed(collapsed, line); + var text = LogicalText(collapsed); + Assert.Contains("…", text); + Assert.StartsWith("H", text); + } + } + + [Fact] + public void Ltr_TrailingWord_Trims_On_Word_Boundary() + { + using (TextFormatterTests.Start()) + { + var line = BuildLine("Hello world foo", FlowDirection.LeftToRight); + var collapsing = TrailingWord(line.Width / 2, FlowDirection.LeftToRight); + var collapsed = line.Collapse(collapsing); + + AssertCollapsed(collapsed, line); + Assert.Contains("…", LogicalText(collapsed)); + } + } + + [Fact] + public void Ltr_PrefixCharacterEllipsis_Preserves_Prefix_And_Suffix() + { + // Matches the existing Should_Collapse_Line LTR baseline: + // "01234 01234 01234" @ width=120, prefixLength=8 → "01234 01…4 01234" + using (TextFormatterTests.Start()) + { + var line = BuildLine("01234 01234 01234", FlowDirection.LeftToRight); + var collapsing = LeadingPrefix(prefixLength: 8, width: 120.0, FlowDirection.LeftToRight); + var collapsed = line.Collapse(collapsing); + + AssertCollapsed(collapsed, line); + var text = LogicalText(collapsed); + Assert.StartsWith("01234 01", text); + Assert.Contains("…", text); + // Suffix must reappear after the symbol. + Assert.EndsWith("4 01234", text); + } + } + + [Fact] + public void Ltr_PathSegmentEllipsis_Collapses_Middle() + { + using (TextFormatterTests.Start()) + { + var line = BuildLine("verylongdirectory\\file.txt", FlowDirection.LeftToRight); + var collapsing = new TextPathSegmentEllipsis( + "…", line.Width / 2, + new GenericTextRunProperties(Typeface.Default), + FlowDirection.LeftToRight); + var collapsed = line.Collapse(collapsing); + + AssertCollapsed(collapsed, line); + var text = LogicalText(collapsed); + Assert.Contains("…", text); + // Last segment ("file.txt") should be preserved on at least + // some prefix; we don't assert exact width because Width math + // depends on the font. + Assert.Contains(".txt", text); + } + } + + // --- Width edges ------------------------------------------------------- + + [Fact] + public void Width_Greater_Than_Line_Returns_Same_Line() + { + using (TextFormatterTests.Start()) + { + var line = BuildLine("abc", FlowDirection.LeftToRight); + var collapsing = TrailingChar(line.Width + 100, FlowDirection.LeftToRight); + var collapsed = line.Collapse(collapsing); + + // Collapse returns null → TextLineImpl.Collapse returns `this`. + Assert.Same(line, collapsed); + Assert.False(collapsed.HasCollapsed); + } + } + + [Fact] + public void Width_Less_Than_Symbol_Returns_Empty_Collapsed_Line() + { + using (TextFormatterTests.Start()) + { + var line = BuildLine("abcdef", FlowDirection.LeftToRight); + + // Width below symbol width → implementation returns [] → line + // gets HasCollapsed = true but no runs. + var collapsing = TrailingChar(width: 0.001, FlowDirection.LeftToRight); + var collapsed = line.Collapse(collapsing); + + Assert.True(collapsed.HasCollapsed); + Assert.Empty(collapsed.TextRuns); + } + } + + // --- RTL paragraph ----------------------------------------------------- + // Per the plan: trimming should happen in LOGICAL order. The consumer + // (TextLineImpl.Collapse → FinalizeLine → BidiReorderer) handles the + // visual reordering. So for an RTL paragraph, the logical prefix + // (start of the original string) must be preserved by trailing-* + // ellipsis, and the ellipsis symbol must appear in the output. + + [Fact] + public void Rtl_TrailingCharacter_Preserves_Logical_Prefix() + { + using (TextFormatterTests.Start()) + { + const string text = "السلام عليكم ورحمة الله وبركاته"; + var line = BuildLine(text, FlowDirection.RightToLeft); + var collapsing = TrailingChar(line.Width / 2, FlowDirection.RightToLeft); + var collapsed = line.Collapse(collapsing); + + AssertCollapsed(collapsed, line); + var logical = LogicalText(collapsed); + Assert.Contains("…", logical); + Assert.StartsWith(text.Substring(0, 1), logical); + } + } + + [Fact] + public void Rtl_TrailingWord_Preserves_Logical_Prefix() + { + using (TextFormatterTests.Start()) + { + const string text = "السلام عليكم ورحمة الله وبركاته"; + var line = BuildLine(text, FlowDirection.RightToLeft); + var collapsing = TrailingWord(line.Width / 2, FlowDirection.RightToLeft); + var collapsed = line.Collapse(collapsing); + + AssertCollapsed(collapsed, line); + Assert.Contains("…", LogicalText(collapsed)); + } + } + + // Note: this passes today because a single-run RTL line has visual + // order == logical order, so the B2 visual-iteration bug doesn't + // manifest. The multi-run B2 case is covered by + // Mixed_PrefixCharacterEllipsis_Preserves_Logical_Prefix_And_Suffix. + [Fact] + public void Rtl_PrefixCharacterEllipsis_Preserves_Logical_Prefix() + { + using (TextFormatterTests.Start()) + { + const string text = "السلام عليكم ورحمة الله وبركاته"; + var line = BuildLine(text, FlowDirection.RightToLeft); + var collapsing = LeadingPrefix(prefixLength: 4, width: line.Width / 2, FlowDirection.RightToLeft); + var collapsed = line.Collapse(collapsing); + + AssertCollapsed(collapsed, line); + var logical = LogicalText(collapsed); + Assert.StartsWith(text.Substring(0, 4), logical); + Assert.Contains("…", logical); + } + } + + // --- Mixed bidi -------------------------------------------------------- + + [Fact] + public void Mixed_TrailingCharacter_Preserves_Logical_Prefix() + { + using (TextFormatterTests.Start()) + { + const string text = "Hello مرحبا world"; + var line = BuildLine(text, FlowDirection.LeftToRight); + var collapsing = TrailingChar(line.Width * 0.6, FlowDirection.LeftToRight); + var collapsed = line.Collapse(collapsing); + + AssertCollapsed(collapsed, line); + Assert.StartsWith("Hello", LogicalText(collapsed)); + } + } + + [Fact] + public void Mixed_PrefixCharacterEllipsis_Preserves_Logical_Prefix_And_Suffix() + { + using (TextFormatterTests.Start()) + { + const string text = "Hello مرحبا world"; + var line = BuildLine(text, FlowDirection.LeftToRight); + var collapsing = LeadingPrefix(prefixLength: 5, width: line.Width * 0.6, FlowDirection.LeftToRight); + var collapsed = line.Collapse(collapsing); + + AssertCollapsed(collapsed, line); + var logical = LogicalText(collapsed); + Assert.StartsWith("Hello", logical); + Assert.Contains("…", logical); + } + } + + // --- Phase 3: TextEllipsisHelper + TextPathSegmentEllipsis BiDi ------- + // These classes were already structured correctly (both use + // LogicalTextRunEnumerator) but the original test surface only had LTR + // cases. The tests below pin BiDi behavior so future refactors can't + // silently break it. + + [Fact] + public void Mixed_TrailingWord_Preserves_Logical_Prefix() + { + using (TextFormatterTests.Start()) + { + const string text = "Hello مرحبا world"; + var line = BuildLine(text, FlowDirection.LeftToRight); + var collapsing = TrailingWord(line.Width * 0.6, FlowDirection.LeftToRight); + var collapsed = line.Collapse(collapsing); + + AssertCollapsed(collapsed, line); + var logical = LogicalText(collapsed); + Assert.Contains("…", logical); + Assert.StartsWith("Hello", logical); + } + } + + [Fact] + public void Mixed_PathSegmentEllipsis_Preserves_Last_Segment() + { + using (TextFormatterTests.Start()) + { + // Mixed-bidi path: ASCII-only separators with an RTL directory + // name embedded. Segmentation is separator-driven, so the + // logical-tail segment ("file.txt") must survive. + const string text = "C:\\folder\\مجلد\\file.txt"; + var line = BuildLine(text, FlowDirection.LeftToRight); + var collapsing = new TextPathSegmentEllipsis( + "…", line.Width / 2, + new GenericTextRunProperties(Typeface.Default), + FlowDirection.LeftToRight); + var collapsed = line.Collapse(collapsing); + + AssertCollapsed(collapsed, line); + var logical = LogicalText(collapsed); + Assert.Contains("…", logical); + Assert.Contains("file.txt", logical); + } + } + + [Fact] + public void Rtl_PathSegmentEllipsis_Preserves_Last_Segment() + { + using (TextFormatterTests.Start()) + { + // Pure-RTL path. Avalonia's font fallback may render Arabic as + // .notdef glyphs in the test environment, but segmentation is + // character-driven (separators are ASCII '/' and '\\') so the + // logical-tail segment "ملف.txt" must still be detected and + // preserved. + const string text = "مجلد/مجلد2/ملف.txt"; + var line = BuildLine(text, FlowDirection.RightToLeft); + var collapsing = new TextPathSegmentEllipsis( + "…", line.Width / 2, + new GenericTextRunProperties(Typeface.Default), + FlowDirection.RightToLeft); + var collapsed = line.Collapse(collapsing); + + AssertCollapsed(collapsed, line); + var logical = LogicalText(collapsed); + Assert.Contains("…", logical); + Assert.Contains("ملف.txt", logical); + } + } + + // --- Middle-collapse coverage (distinguishes the main path from the + // "trim from start" fallback). For an LTR path with the + // middle segment large enough to make a single-segment collapse + // bring the line under budget, the result should keep BOTH + // the first and last segments. The fallback would drop the + // first segments. + + [Fact] + public void Ltr_PathSegmentEllipsis_Middle_Collapse_Preserves_First_And_Last_Segments() + { + using (TextFormatterTests.Start()) + { + // 3-segment path; middle is intentionally long so collapsing + // it alone produces a fitting result. + const string text = "a/middlemiddlemiddlemiddlemiddlemiddlemiddlemiddlemiddlemiddle/c.txt"; + var line = BuildLine(text, FlowDirection.LeftToRight); + var budget = line.Width * 0.3; + var collapsing = new TextPathSegmentEllipsis( + "…", budget, + new GenericTextRunProperties(Typeface.Default), + FlowDirection.LeftToRight); + var collapsed = line.Collapse(collapsing); + + AssertCollapsed(collapsed, line); + var logical = LogicalText(collapsed); + Assert.Contains("…", logical); + Assert.Contains("a", logical); + Assert.Contains("c.txt", logical); + } + } + + [Fact] + public void Rtl_PathSegmentEllipsis_Middle_Collapse_Preserves_First_And_Last_Segments() + { + // B8: TextPathSegmentEllipsis.MeasureSegmentWidth assumes the + // ShapedBuffer's GlyphCluster values increase monotonically with + // glyph index. That holds for LTR buffers but NOT for RTL — RTL + // buffers have glyphs in visual order with cluster values + // DECREASING. The result: any RTL segment's measured width comes + // out close to a single glyph's advance, never close to its real + // width. The middle-collapse algorithm then can't find a window + // whose collapse-savings make the line fit, and falls back to + // "trim from start" — which drops the logical-first segment. + using (TextFormatterTests.Start()) + { + const string text = "اول/منتصفمنتصفمنتصفمنتصفمنتصفمنتصفمنتصفمنتصف/اخر.txt"; + var line = BuildLine(text, FlowDirection.RightToLeft); + var budget = line.Width * 0.3; + var collapsing = new TextPathSegmentEllipsis( + "…", budget, + new GenericTextRunProperties(Typeface.Default), + FlowDirection.RightToLeft); + var collapsed = line.Collapse(collapsing); + + AssertCollapsed(collapsed, line); + var logical = LogicalText(collapsed); + Assert.Contains("…", logical); + Assert.Contains("اول", logical); + Assert.Contains("اخر.txt", logical); + } + } + + // --- B9: TryMeasureCharacters/Backwards bidi contract ---------------- + // These two methods walk the ShapedBuffer in visual order. For LTR the + // visual order equals the logical order, so the returned "length" + // (counted via cluster deltas) is the logical-leading / logical-trailing + // char count that callers want. For RTL the visual order is the + // logical-REVERSE order, so the cumulative width is measured for the + // wrong set of characters even though the COUNT happens to come out + // right for uniform-width fonts. The contract callers depend on: + // * TryMeasureCharacters(budget) → largest N such that the LOGICAL + // leading N characters fit within `budget`. + // * TryMeasureCharactersBackwards(budget) → largest N such that the + // LOGICAL trailing N characters fit within `budget`. + // We verify each by cross-checking the returned length against the + // cluster-cache-based logical width measurement. + + [Theory] + [InlineData("Hello world abcdef", true)] + [InlineData("السلام عليكم ورحمة", false)] + public void TryMeasureCharacters_Returned_Length_Fits_Logical_Leading_In_Budget(string text, bool ltr) + { + using (TextFormatterTests.Start()) + { + var dir = ltr ? FlowDirection.LeftToRight : FlowDirection.RightToLeft; + var line = BuildLine(text, dir); + var shapedRun = line.TextRuns.OfType().FirstOrDefault(); + Assert.NotNull(shapedRun); + + var buffer = shapedRun!.ShapedBuffer; + var totalWidth = shapedRun.Size.Width; + + // Probe at several budget points; the contract must hold at all of them. + for (var i = 1; i < 10; i++) + { + var budget = totalWidth * i / 10; + if (!shapedRun.TryMeasureCharacters(budget, out var measured) || measured <= 0) + { + continue; + } + + // The width of the LOGICAL leading `measured` characters must fit + // in `budget`. GetCharRangeWidth uses the cluster cache, which is + // built in logical order for both directions. + var actualLeadingWidth = buffer.GetCharRangeWidth(0, measured); + + Assert.True(actualLeadingWidth <= budget + 0.5, + $"{dir}: budget={budget:F2}, measured={measured}, " + + $"actual logical-leading width={actualLeadingWidth:F2}"); + } + } + } + + [Theory] + [InlineData("Hello world abcdef", true)] + [InlineData("السلام عليكم ورحمة", false)] + public void TryMeasureCharactersBackwards_Returned_Length_Fits_Logical_Trailing_In_Budget(string text, bool ltr) + { + using (TextFormatterTests.Start()) + { + var dir = ltr ? FlowDirection.LeftToRight : FlowDirection.RightToLeft; + var line = BuildLine(text, dir); + var shapedRun = line.TextRuns.OfType().FirstOrDefault(); + Assert.NotNull(shapedRun); + + var buffer = shapedRun!.ShapedBuffer; + var totalWidth = shapedRun.Size.Width; + var textLength = shapedRun.Length; + + for (var i = 1; i < 10; i++) + { + var budget = totalWidth * i / 10; + if (!shapedRun.TryMeasureCharactersBackwards(budget, out var measured, out _) || measured <= 0) + { + continue; + } + + var actualTrailingWidth = buffer.GetCharRangeWidth(textLength - measured, textLength); + + Assert.True(actualTrailingWidth <= budget + 0.5, + $"{dir}: budget={budget:F2}, measured={measured}, " + + $"actual logical-trailing width={actualTrailingWidth:F2}"); + } + } + } + + // --- B1: LogicalTextRunEnumerator ------------------------------------ + + [Fact] + public void LogicalTextRunEnumerator_Without_IndexedRuns_Returns_Distinct_Runs() + { + using (TextFormatterTests.Start()) + { + var props = new GenericTextRunProperties(Typeface.Default); + var runs = new TextRun[] + { + new TextCharacters("AAA", props), + new TextCharacters("BBB", props), + new TextCharacters("CCC", props), + }; + + // Construct TextLineImpl directly and SKIP FinalizeLine so that + // _indexedTextRuns stays null. This is exactly the branch + // LogicalTextRunEnumerator handles incorrectly today. + var paragraphProps = new GenericTextParagraphProperties(props); + var line = new TextLineImpl(runs, 0, 9, double.PositiveInfinity, paragraphProps); + + var enumerator = new LogicalTextRunEnumerator(line); + var seen = new List(); + while (enumerator.MoveNext(out var run)) + { + seen.Add(run!); + } + + Assert.Equal(3, seen.Count); + Assert.Same(runs[0], seen[0]); + Assert.Same(runs[1], seen[1]); + Assert.Same(runs[2], seen[2]); + } + } + + // --- B3: TextLeadingPrefixCharacterEllipsis constructor validation ---- + + [Fact] + public void LeadingPrefix_Negative_PrefixLength_Throws() + { + using (TextFormatterTests.Start()) + { + var props = new GenericTextRunProperties(Typeface.Default); + Assert.Throws( + () => new TextLeadingPrefixCharacterEllipsis( + "…", prefixLength: -1, width: 100, props, FlowDirection.LeftToRight)); + } + } + + // --- B4: TextLeadingPrefixCharacterEllipsis honours FlowDirection ----- + + [Fact] + public void LeadingPrefix_Honours_FlowDirection_For_Symbol() + { + using (TextFormatterTests.Start()) + { + const string text = "السلام عليكم ورحمة"; + var line = BuildLine(text, FlowDirection.RightToLeft); + var collapsing = LeadingPrefix(prefixLength: 4, width: line.Width / 2, FlowDirection.RightToLeft); + var collapsed = line.Collapse(collapsing); + + AssertCollapsed(collapsed, line); + + // The ellipsis symbol's run should pick up the RTL bidi level + // from the FlowDirection passed to the constructor. Today the + // ctor in Collapse() hardcodes LeftToRight, so the symbol run + // has IsLeftToRight == true. + var ellipsisRun = collapsed.TextRuns + .OfType() + .FirstOrDefault(r => r.Text.ToString().Contains("…")); + Assert.NotNull(ellipsisRun); + Assert.False(ellipsisRun!.ShapedBuffer.IsLeftToRight); + } + } + + // --- Mixed shaped + drawable / multi-run ------------------------------- + + [Fact] + public void Collapse_With_Multiple_Shaped_Runs_Preserves_Ellipsis() + { + // Three independent runs via FixedRunsTextSource. Trim point lands + // somewhere in the middle — collapse must not silently drop a run + // or duplicate one (covers the SplitTextRuns interaction). + using (TextFormatterTests.Start()) + { + var props = new GenericTextRunProperties(Typeface.Default); + var sourceRuns = new TextRun[] + { + new TextCharacters("AAAA", props), + new TextCharacters("BBBB", props), + new TextCharacters("CCCC", props), + }; + var src = new FixedRunsTextSource(sourceRuns); + var formatter = new TextFormatterImpl(); + var line = formatter.FormatLine(src, 0, double.PositiveInfinity, + new GenericTextParagraphProperties(props)); + Assert.NotNull(line); + + var collapsing = TrailingChar(line!.Width / 2, FlowDirection.LeftToRight); + var collapsed = line.Collapse(collapsing); + + AssertCollapsed(collapsed, line); + var text = LogicalText(collapsed); + Assert.Contains("…", text); + // Every preserved character must come from the original source + // text in original order — no garbage. + var preserved = text.Replace("…", string.Empty); + Assert.StartsWith(preserved, "AAAABBBBCCCC"); + } + } + + // --- Helpers ----------------------------------------------------------- + + private static TextLine BuildLine(string text, FlowDirection flow) + { + var props = new GenericTextRunProperties(Typeface.Default, 12, foregroundBrush: Brushes.Black); + var paragraphProps = new GenericTextParagraphProperties( + flow, TextAlignment.Left, true, true, props, TextWrapping.NoWrap, 0, 0, 0); + var source = new SingleBufferTextSource(text, props); + var formatter = new TextFormatterImpl(); + var line = formatter.FormatLine(source, 0, double.PositiveInfinity, paragraphProps); + Assert.NotNull(line); + return line!; + } + + private static TextTrailingCharacterEllipsis TrailingChar(double width, FlowDirection flow) + => new("…", width, new GenericTextRunProperties(Typeface.Default), flow); + + private static TextTrailingWordEllipsis TrailingWord(double width, FlowDirection flow) + => new("…", width, new GenericTextRunProperties(Typeface.Default), flow); + + private static TextLeadingPrefixCharacterEllipsis LeadingPrefix( + int prefixLength, double width, FlowDirection flow) + => new("…", prefixLength, width, + new GenericTextRunProperties(Typeface.Default), flow); + + private static void AssertCollapsed(TextLine collapsed, TextLine original) + { + Assert.NotSame(original, collapsed); + Assert.True(collapsed.HasCollapsed, + "Collapsed line must report HasCollapsed = true."); + } + + /// + /// Concatenates run text in logical order via + /// . For LTR-only lines this + /// equals walking TextRuns directly; for RTL/mixed lines it + /// returns the original-text order (what the collapse contract + /// requires) instead of the visual post-bidi order. + /// + private static string LogicalText(TextLine line) + { + var enumerator = new LogicalTextRunEnumerator(line); + var sb = new StringBuilder(); + while (enumerator.MoveNext(out var run)) + { + sb.Append(run!.Text.Span); + } + return sb.ToString(); + } + + /// + /// Local copy of the FixedRunsTextSource pattern used in + /// TextLineTests — that class is private, so duplicate here. + /// + private sealed class FixedRunsTextSource : ITextSource + { + private readonly IReadOnlyList _textRuns; + + public FixedRunsTextSource(IReadOnlyList textRuns) + { + _textRuns = textRuns; + } + + public TextRun? GetTextRun(int textSourceIndex) + { + var pos = 0; + foreach (var run in _textRuns) + { + if (pos == textSourceIndex) + { + return run; + } + pos += run.Length; + } + return null; + } + } + } +} 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 00000000000..f33bac56290 --- /dev/null +++ b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterWrapCharacterizationTests.cs @@ -0,0 +1,314 @@ +#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). These exist to pin the + /// current observable behaviour before the wrap-path optimisation work + /// in docs/perform-text-wrapping-plan.md. They go through the + /// public TextFormatterImpl.FormatLine entry point so the helpers + /// can remain private. + /// + public class TextFormatterWrapCharacterizationTests + { + // --- MeasureLength behaviour, observed via FormatLine ----------------- + + [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); + } + } + } + + // --- Required (hard) break behaviour ---------------------------------- + + [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); + } + } + + // --- Overflow + lookahead path (lines 962-981 of TextFormatterImpl) --- + // This is the smell flagged as D1 in the optimisation plan: the + // currentPosition += accumulation looks wrong because PositionWrap is + // absolute within the run, not delta. These tests pin what actually + // happens today so we can detect any behavioural change while + // refactoring. + + [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."); + } + } + + // --- Continued wrap (WrappingTextLineBreak path) ---------------------- + + [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); + } + } + + // --- BiDi trailing whitespace path ------------------------------------ + + [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); + } + } + + // --- Sanity / regression -------------------------------------------- + + [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); + } + } + + // --- Helpers --------------------------------------------------------- + + 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 6af97612024..1ec027a12c2 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs +++ b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs @@ -1726,7 +1726,12 @@ public void Should_GetTextBounds_Bidi() 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);