diff --git a/src/Avalonia.Base/Media/TextFormatting/LogicalTextRunEnumerator.cs b/src/Avalonia.Base/Media/TextFormatting/LogicalTextRunEnumerator.cs index 7b391801a6..514c39edd4 100644 --- a/src/Avalonia.Base/Media/TextFormatting/LogicalTextRunEnumerator.cs +++ b/src/Avalonia.Base/Media/TextFormatting/LogicalTextRunEnumerator.cs @@ -3,6 +3,21 @@ using System.Diagnostics.CodeAnalysis; 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 @@ internal ref struct LogicalTextRunEnumerator } 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 581ed901d0..7be486717e 100644 --- a/src/Avalonia.Base/Media/TextFormatting/ShapedBuffer.cs +++ b/src/Avalonia.Base/Media/TextFormatting/ShapedBuffer.cs @@ -307,57 +307,6 @@ namespace Avalonia.Media.TextFormatting } } - /// - /// Finds how many text characters from the start of this buffer fit within - /// , walking in logical cluster order. - /// Returns the character count and the width consumed (always <= availableWidth - /// unless the very first cluster overflows, in which case the caller is expected - /// to honour the overflow contract documented in TextFormatterImpl.MeasureLength). - /// - internal int MeasureCharactersThatFit(double availableWidth, out double widthConsumed) - { - var prefix = EnsureClusterCache(); - var startIdx = _clusterStartIdx; - var count = _clusterCount; - - if (count == 0) - { - widthConsumed = 0d; - return 0; - } - - // Find the largest k such that prefix[startIdx + k] - prefix[startIdx] <= - // availableWidth. Standard binary search on the prefix-sum array, with the - // sub-buffer's base width subtracted out. - var basePrefix = prefix[startIdx]; - - var lo = 0; - var hi = count; - while (lo < hi) - { - var mid = (lo + hi + 1) >> 1; - if (MathUtilities.LessThanOrClose(prefix[startIdx + mid] - basePrefix, availableWidth)) - { - lo = mid; - } - else - { - hi = mid - 1; - } - } - - widthConsumed = prefix[startIdx + lo] - basePrefix; - - // Simple-mode fast path: 1 char per cluster, so the consumed char count - // equals the cluster offset we just resolved (no start-chars table needed). - var starts = _clusterStartChars; - if (starts is null) - { - return lo; - } - return starts[startIdx + lo] - starts[startIdx]; - } - /// /// Returns the character length of the first logical cluster in this buffer. /// Used by MeasureLength to satisfy the "include at least one cluster" @@ -799,5 +748,187 @@ namespace Avalonia.Media.TextFormatting return new SplitResult(first, second); } + + /// + /// 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 = EnsureClusterCache(); + var startIdx = _clusterStartIdx; + var count = _clusterCount; + + var starts = _clusterStartChars; + int startBoundary; + int endBoundary; + if (starts is null) + { + // Simple mode: one char per cluster, so the local char offset equals + // the local cluster index. Clamp to [0, count] to mirror the + // out-of-range handling of FindLargestClusterAtOrBefore. + startBoundary = Math.Clamp(startChar, 0, count); + endBoundary = Math.Clamp(endChar, 0, count); + } + else + { + var baseChar = starts[startIdx]; + startBoundary = FindLargestClusterAtOrBefore(starts, startIdx, count, baseChar, startChar); + 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; + } + + /// + /// 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 = EnsureClusterCache(); + var startIdx = _clusterStartIdx; + var count = _clusterCount; + + var basePrefix = prefix[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 (MathUtilities.LessThanOrClose(prefix[startIdx + mid] - basePrefix, availableWidth)) + { + lo = mid; + } + else + { + hi = mid - 1; + } + } + + var starts = _clusterStartChars; + if (starts is null) + { + // Simple mode: char count == cluster count that fits. + return lo; + } + return starts[startIdx + lo] - starts[startIdx]; + } + + /// + /// 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 = EnsureClusterCache(); + var startIdx = _clusterStartIdx; + var count = _clusterCount; + + var endPrefix = prefix[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 (MathUtilities.LessThanOrClose(endPrefix - prefix[startIdx + mid], availableWidth)) + { + hi = mid; + } + else + { + lo = mid + 1; + } + } + + consumedWidth = endPrefix - prefix[startIdx + lo]; + + var starts = _clusterStartChars; + if (starts is null) + { + // Simple mode: char count == cluster count in the trailing suffix. + return count - lo; + } + return starts[startIdx + count] - starts[startIdx + lo]; + } } } diff --git a/src/Avalonia.Base/Media/TextFormatting/ShapedTextRun.cs b/src/Avalonia.Base/Media/TextFormatting/ShapedTextRun.cs index a75734f356..e8f9e37700 100644 --- a/src/Avalonia.Base/Media/TextFormatting/ShapedTextRun.cs +++ b/src/Avalonia.Base/Media/TextFormatting/ShapedTextRun.cs @@ -103,97 +103,33 @@ namespace Avalonia.Media.TextFormatting } /// - /// 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 b621dada54..e3796fc8c5 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextCollapsingProperties.cs +++ b/src/Avalonia.Base/Media/TextFormatting/TextCollapsingProperties.cs @@ -21,9 +21,26 @@ 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 b402525891..9736b5ea78 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextFormatterImpl.cs +++ b/src/Avalonia.Base/Media/TextFormatting/TextFormatterImpl.cs @@ -787,7 +787,7 @@ namespace Avalonia.Media.TextFormatting } // Some part of the buffer overflows: find the cluster boundary. - var runLength = buffer.MeasureCharactersThatFit(remaining, out _); + var runLength = buffer.FindLeadingCharCountWithinWidth(remaining); // "Include at least one cluster" rule preserves the existing // contract that the caller always advances by at least one @@ -810,7 +810,7 @@ namespace Avalonia.Media.TextFormatting case DrawableTextRun drawableTextRun: { - if (currentWidth + drawableTextRun.Size.Width >= paragraphWidth) + if (MathUtilities.GreaterThan(currentWidth + drawableTextRun.Size.Width, paragraphWidth)) { return measuredLength; } diff --git a/src/Avalonia.Base/Media/TextFormatting/TextLeadingPrefixCharacterEllipsis.cs b/src/Avalonia.Base/Media/TextFormatting/TextLeadingPrefixCharacterEllipsis.cs index e1f36b5f4c..10bd3e13d9 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextLeadingPrefixCharacterEllipsis.cs +++ b/src/Avalonia.Base/Media/TextFormatting/TextLeadingPrefixCharacterEllipsis.cs @@ -1,6 +1,7 @@ // ReSharper disable ForCanBeConvertedToForeach using System; using System.Collections.Generic; +using Avalonia.Utilities; using static Avalonia.Media.TextFormatting.FormattingObjectPool; namespace Avalonia.Media.TextFormatting @@ -27,7 +28,7 @@ namespace Avalonia.Media.TextFormatting TextRunProperties textRunProperties, FlowDirection flowDirection) { - if (_prefixLength < 0) + if (prefixLength < 0) { throw new ArgumentOutOfRangeException(nameof(prefixLength)); } @@ -49,142 +50,205 @@ namespace Avalonia.Media.TextFormatting /// 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 (MathUtilities.LessThan(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 (MathUtilities.GreaterThan(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); - - collapsedRuns.Add(splitSuffix.Second!); + availableSuffixWidth -= suffixWidth; + + var splitAt = run.Length - suffixCount; + + if (splitAt > 0) + { + var splitSuffix = endShapedRun.Split(splitAt); + + reversedSuffix.Add(splitSuffix.Second!); + } + else if (suffixCount > 0) + { + // The whole run fits in the remaining suffix budget, so no + // split is needed; use the run as-is. (Calling Split(0) throws.) + reversedSuffix.Add(endShapedRun); + } + // else: suffixCount == 0, nothing of this run survives. } - } - 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 8809a3fc48..6437d04298 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs +++ b/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs @@ -1171,9 +1171,9 @@ namespace Avalonia.Media.TextFormatting public override void Dispose() { - for (int i = 0; i < _textRuns.Length; i++) + foreach (var textRun in _textRuns) { - if (_textRuns[i] is ShapedTextRun shapedTextRun) + if (textRun is ShapedTextRun shapedTextRun) { shapedTextRun.Dispose(); } @@ -1298,9 +1298,9 @@ namespace Avalonia.Media.TextFormatting var lineHeight = _paragraphProperties.LineHeight; var lineSpacing = _paragraphProperties.LineSpacing; - for (var index = 0; index < _textRuns.Length; index++) + foreach (var run in _textRuns) { - switch (_textRuns[index]) + switch (run) { case ShapedTextRun textRun: { @@ -1345,9 +1345,9 @@ namespace Avalonia.Media.TextFormatting 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 d2545f42e2..13cad83d0f 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextRunCache.cs +++ b/src/Avalonia.Base/Media/TextFormatting/TextRunCache.cs @@ -171,9 +171,9 @@ namespace Avalonia.Media.TextFormatting private static void AddRefShapedRuns(TextRun[] runs) { - for (var i = 0; i < runs.Length; i++) + foreach (var run in runs) { - if (runs[i] is ShapedTextRun shaped) + if (run is ShapedTextRun shaped) { shaped.AddRef(); } @@ -189,11 +189,9 @@ namespace Avalonia.Media.TextFormatting private static void DisposeCachedRuns(CachedShapingResult result) { - var runs = result.ShapedRuns; - - for (var i = 0; i < runs.Length; i++) + 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/TextPathSegmentEllipsis.cs b/src/Avalonia.Base/Media/TextPathSegmentEllipsis.cs index 700f861d4d..99693089a9 100644 --- a/src/Avalonia.Base/Media/TextPathSegmentEllipsis.cs +++ b/src/Avalonia.Base/Media/TextPathSegmentEllipsis.cs @@ -3,6 +3,7 @@ using System.Collections; using System.Collections.Generic; using System.IO; using Avalonia.Media.TextFormatting; +using Avalonia.Utilities; namespace Avalonia.Media { @@ -50,7 +51,7 @@ namespace Avalonia.Media var shapedSymbol = TextFormatter.CreateSymbol(Symbol, FlowDirection); - if (Width < shapedSymbol.Size.Width) + if (MathUtilities.LessThan(Width, shapedSymbol.Size.Width)) { // Nothing to collapse return null; @@ -58,7 +59,7 @@ namespace Avalonia.Media double totalWidth = textLine.Width; - if (totalWidth <= Width) + if (MathUtilities.LessThanOrClose(totalWidth, Width)) { // Nothing to collapse return null; @@ -78,6 +79,18 @@ namespace Avalonia.Media 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 +118,12 @@ namespace Avalonia.Media // 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 +162,7 @@ namespace Avalonia.Media // 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)); } @@ -250,7 +263,7 @@ namespace Avalonia.Media var trimmedWidth = prefix[segEndIndex + 1] - prefix[segStartIndex]; - if (totalWidth - trimmedWidth + shapedSymbol.Size.Width <= Width) + if (MathUtilities.LessThanOrClose(totalWidth - trimmedWidth + shapedSymbol.Size.Width, Width)) { // perform split using character indices var removeStart = segments[segStartIndex].Start; @@ -319,7 +332,7 @@ namespace Avalonia.Media { var segment = segments[segmentIndex]; - if (segmentIndex < segments.Count - 1 && remainingWidth - segment.Width > Width) + if (segmentIndex < segments.Count - 1 && MathUtilities.GreaterThan(remainingWidth - segment.Width, Width)) { remainingWidth -= segment.Width; currentLength += segment.Length; @@ -352,7 +365,17 @@ namespace Avalonia.Media { 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 +441,51 @@ namespace Avalonia.Media /// /// 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 +493,58 @@ namespace Avalonia.Media { 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/tests/Avalonia.Benchmarks/Text/ShapedBufferOps.cs b/tests/Avalonia.Benchmarks/Text/ShapedBufferOps.cs index 255ea6aeb6..33c5a6d76a 100644 --- a/tests/Avalonia.Benchmarks/Text/ShapedBufferOps.cs +++ b/tests/Avalonia.Benchmarks/Text/ShapedBufferOps.cs @@ -10,7 +10,7 @@ namespace Avalonia.Benchmarks.Text; /// /// Micro-benchmark for the cluster-cache hot paths -/// (, , +/// (, , /// and the cached chain). Compares the /// simple-mode fast path (1 char per cluster) against complex clusters by /// shaping random ASCII vs. random-from-extended-Latin so the buffers exercise @@ -77,7 +77,7 @@ public class ShapedBufferOps : IDisposable } /// - /// Repeated targeting + /// Repeated targeting /// half the buffer's total width. Exercises the binary search across the /// prefix table. /// @@ -88,7 +88,7 @@ public class ShapedBufferOps : IDisposable var sum = 0; for (var i = 0; i < 64; i++) { - sum += _primed.MeasureCharactersThatFit(halfWidth, out _); + sum += _primed.FindLeadingCharCountWithinWidth(halfWidth); } return sum; } 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 0000000000..d0963470d5 --- /dev/null +++ b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextCollapsingBidiTests.cs @@ -0,0 +1,596 @@ +#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. + /// + public class TextCollapsingBidiTests + { + [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() + { + 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); + } + } + + [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); + } + } + + [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)); + } + } + + [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); + } + } + + [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); + } + } + + [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); + } + } + + [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() + { + 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); + } + } + + [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}"); + } + } + } + + [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]); + } + } + + [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)); + } + } + + [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); + } + } + + [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"); + } + } + + [Fact] + public void LeadingPrefix_With_Fully_Fitting_Tail_Run_Does_Not_Throw() + { + // Regression: on a multi-run line a logical-tail run can fit entirely + // within the remaining suffix budget. TryMeasureCharactersBackwards then + // returns suffixCount == run.Length, and the old code called + // ShapedTextRun.Split(0), which throws ArgumentOutOfRangeException. The + // long leading run forces the collapse; the short trailing run wholly + // fits the suffix budget and exercises that boundary. + using (TextFormatterTests.Start()) + { + var props = new GenericTextRunProperties(Typeface.Default); + var sourceRuns = new TextRun[] + { + new TextCharacters("AAAAAAAAAAAA", props), // long: forces the collapse + new TextCharacters("B", props), // short: wholly fits the suffix budget + }; + 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 = LeadingPrefix(prefixLength: 2, width: line!.Width * 0.7, FlowDirection.LeftToRight); + + // Previously threw ArgumentOutOfRangeException from Split(0). + var collapsed = line.Collapse(collapsing); + + AssertCollapsed(collapsed, line); + var text = LogicalText(collapsed); + Assert.Contains("…", text); + // The fully-fitting trailing run must survive in the logical-tail suffix. + Assert.Contains("B", text); + } + } + + 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/TextShaperTests.cs b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextShaperTests.cs index b638e01b47..8f039a97e2 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextShaperTests.cs +++ b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextShaperTests.cs @@ -144,7 +144,8 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting // Measure: ask for the width of the first 3 glyphs. var threeGlyphsWidth = buffer[0].GlyphAdvance + buffer[1].GlyphAdvance + buffer[2].GlyphAdvance; - var fit = buffer.MeasureCharactersThatFit(threeGlyphsWidth, out var widthConsumed); + var fit = buffer.FindLeadingCharCountWithinWidth(threeGlyphsWidth); + var widthConsumed = buffer.GetCharRangeWidth(0, fit); Assert.Equal(3, fit); Assert.Equal(threeGlyphsWidth, widthConsumed, 5); @@ -194,6 +195,79 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting } } + [Fact] + public void ClusterCache_SimpleMode_TrimmingHelpers_Are_Correct() + { + using (Start()) + { + var buffer = TextShaper.Current.ShapeText("ABCDEFGH", new TextShaperOptions(Typeface.Default.GlyphTypeface)); + + Assert.True(buffer.IsClusterCacheSimple); + + var advances = new double[buffer.Length]; + for (var i = 0; i < buffer.Length; i++) + { + advances[i] = buffer[i].GlyphAdvance; + } + + double Sum(int start, int end) + { + var w = 0d; + for (var i = start; i < end; i++) + { + w += advances[i]; + } + return w; + } + + // GetCharRangeWidth: exact sub-range sums, including out-of-range clamping. + // These must not throw in simple mode (the regression: _clusterStartChars is null). + Assert.Equal(Sum(0, 3), buffer.GetCharRangeWidth(0, 3), 5); + Assert.Equal(Sum(2, 5), buffer.GetCharRangeWidth(2, 5), 5); + Assert.Equal(Sum(0, 8), buffer.GetCharRangeWidth(-2, 100), 5); // clamped to [0, 8] + Assert.Equal(0d, buffer.GetCharRangeWidth(4, 4), 5); + + // FindLeadingCharCountWithinWidth: budget mid-way into the 4th glyph -> first 3 fit. + var leadingBudget = Sum(0, 3) + advances[3] * 0.5; + Assert.Equal(3, buffer.FindLeadingCharCountWithinWidth(leadingBudget)); + + // FindTrailingCharCountWithinWidth: budget mid-way into glyph index 4 -> last 3 fit. + var trailingBudget = Sum(5, 8) + advances[4] * 0.5; + var trailingCount = buffer.FindTrailingCharCountWithinWidth(trailingBudget, out var consumed); + Assert.Equal(3, trailingCount); + Assert.Equal(Sum(5, 8), consumed, 5); + } + } + + [Fact] + public void ClusterCache_SimpleMode_TrimmingHelpers_Survive_Split() + { + using (Start()) + { + var buffer = TextShaper.Current.ShapeText("ABCDEFGH", new TextShaperOptions(Typeface.Default.GlyphTypeface)); + Assert.True(buffer.IsClusterCacheSimple); + + var split = buffer.Split(3); + var second = split.Second; + Assert.NotNull(second); + Assert.True(second!.IsClusterCacheSimple); + Assert.Equal(5, second.Length); // "DEFGH" + + var advances = new double[second.Length]; + for (var i = 0; i < second.Length; i++) + { + advances[i] = second[i].GlyphAdvance; + } + + // Exercises the _clusterStartIdx offset on a simple-mode sub-buffer. + var firstTwo = advances[0] + advances[1]; + Assert.Equal(firstTwo, second.GetCharRangeWidth(0, 2), 5); + + var leadingBudget = firstTwo + advances[2] * 0.5; + Assert.Equal(2, second.FindLeadingCharCountWithinWidth(leadingBudget)); + } + } + private static IDisposable Start() { var disposable = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface