Browse Source

TextCollapsingProperties bidirectional-text (BiDi) correctness fixes (#21446)

* Fix BiDi correctness across TextCollapsingProperties implementations

Audit and fix logical-vs-visual ordering bugs in every text-collapsing
implementation and the helpers they share, so trimming always operates in
logical order (the consumer re-runs the BiDi reorderer in FinalizeLine, so
pre-applying visual order would be reordered a second time).

- LogicalTextRunEnumerator returned the first run on every MoveNext when
  _indexedTextRuns was null.
- TextLeadingPrefixCharacterEllipsis: walked visual-order runs while splitting
  at logical offsets, guarded the wrong field in the ctor, hardcoded
  LeftToRight for the symbol, appended the suffix in reverse-logical order, and
  mis-tracked the width budget across multi-run lines.
- TextPathSegmentEllipsis: crashed via Split(0) in the fallback path, and
  measured RTL segments as ~0 width (it assumed ascending cluster ids).
- ShapedTextRun.TryMeasureCharacters/Backwards returned visual-order counts for
  RTL runs.

ShapedBuffer gains logical-order, cluster-cache-backed width helpers
(GetCharRangeWidth, FindLeading/FindTrailingCharCountWithinWidth) that are
safe in the one-char-per-cluster simple mode (null _clusterStartChars). Adds a
BiDi characterization test suite and documents the logical-order Collapse
contract.

* Align text width comparisons to epsilon-tolerant MathUtilities

Several width-fit comparisons in the measure/collapse path used raw operators
with no epsilon, while the cluster-cache hot path (MeasureCharactersThatFit)
and the ShapedTextRun branch of MeasureLength already went through
MathUtilities. Replace the raw float comparisons with their epsilon-aware
equivalents (<= -> LessThanOrClose, < -> LessThan, > -> GreaterThan) in
ShapedBuffer.FindLeading/FindTrailingCharCountWithinWidth,
TextPathSegmentEllipsis, and TextLeadingPrefixCharacterEllipsis.

Also unify the DrawableTextRun branch of TextFormatterImpl.MeasureLength with
the ShapedTextRun branch (>= -> GreaterThan) so the "whole run fits" boundary
is identical for both run types: a run fits when width <= remaining (tolerant)
and overflows only when strictly greater.

Integer char-offset/index comparisons and the availableWidth <= 0 positivity
guards are intentionally left as raw comparisons.

* Cleanup some comments

* Fix Split(0) crash in TextLeadingPrefixCharacterEllipsis suffix collection

When a logical-tail run fits entirely within the remaining suffix budget,
TryMeasureCharactersBackwards returns suffixCount == run.Length, so
Split(run.Length - suffixCount) evaluated to Split(0), which throws
ArgumentOutOfRangeException. This was reachable on multi-run lines whose
post-split tail contains a small later run that fully fits.

Guard the split on splitAt > 0 and use the whole run as-is when it fully fits
(dropping it only when suffixCount == 0) — the same boundary pattern already
applied to TextPathSegmentEllipsis. Add a multi-run regression test.

* Remove duplicate ShapedBuffer.MeasureCharactersThatFit

MeasureCharactersThatFit and FindLeadingCharCountWithinWidth ran the
identical cluster-prefix binary search; the only functional difference
was the unused `out widthConsumed`. Drop the former and repoint its
callers (TextFormatterImpl.MeasureLength and the benchmark) at
FindLeadingCharCountWithinWidth, which also unifies the availableWidth<=0
guard. The one test that asserted the consumed width now recovers it via
GetCharRangeWidth(0, fit).

Also tidy GetCharRangeWidth's simple-mode bounds to use Math.Clamp.
release/12.0
Benedikt Stebner 2 months ago
committed by Julien Lebosquain
parent
commit
4fcdb0e24f
  1. 17
      src/Avalonia.Base/Media/TextFormatting/LogicalTextRunEnumerator.cs
  2. 233
      src/Avalonia.Base/Media/TextFormatting/ShapedBuffer.cs
  3. 94
      src/Avalonia.Base/Media/TextFormatting/ShapedTextRun.cs
  4. 19
      src/Avalonia.Base/Media/TextFormatting/TextCollapsingProperties.cs
  5. 4
      src/Avalonia.Base/Media/TextFormatting/TextFormatterImpl.cs
  6. 250
      src/Avalonia.Base/Media/TextFormatting/TextLeadingPrefixCharacterEllipsis.cs
  7. 12
      src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs
  8. 10
      src/Avalonia.Base/Media/TextFormatting/TextRunCache.cs
  9. 173
      src/Avalonia.Base/Media/TextPathSegmentEllipsis.cs
  10. 6
      tests/Avalonia.Benchmarks/Text/ShapedBufferOps.cs
  11. 596
      tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextCollapsingBidiTests.cs
  12. 76
      tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextShaperTests.cs

17
src/Avalonia.Base/Media/TextFormatting/LogicalTextRunEnumerator.cs

@ -3,6 +3,21 @@ using System.Diagnostics.CodeAnalysis;
namespace Avalonia.Media.TextFormatting;
/// <summary>
/// Walks the runs of a <see cref="TextLine"/> in <b>logical</b> (source-text)
/// order. This is the order that splits and length-based offsets are defined
/// in, and is what every <see cref="TextCollapsingProperties.Collapse"/>
/// implementation needs to see — unlike <see cref="TextLine.TextRuns"/>,
/// which exposes the post-BiDi <i>visual</i> ordering used for rendering.
/// </summary>
/// <remarks>
/// When the line has been finalized (the normal case after
/// <c>TextLineImpl.FinalizeLine</c>), the enumerator iterates over
/// <c>_indexedTextRuns</c> — 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 <c>TextLineImpl</c>), it falls back to the raw
/// <see cref="TextLine.TextRuns"/> list.
/// </remarks>
internal ref struct LogicalTextRunEnumerator
{
private readonly IReadOnlyList<TextRun>? _textRuns;
@ -61,7 +76,7 @@ internal ref struct LogicalTextRunEnumerator
}
else if (_textRuns != null)
{
run = _textRuns[0];
run = _textRuns[_index];
}
else
{

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

@ -307,57 +307,6 @@ namespace Avalonia.Media.TextFormatting
}
}
/// <summary>
/// Finds how many text characters from the start of this buffer fit within
/// <paramref name="availableWidth"/>, walking in logical cluster order.
/// Returns the character count and the width consumed (always &lt;= availableWidth
/// unless the very first cluster overflows, in which case the caller is expected
/// to honour the overflow contract documented in <c>TextFormatterImpl.MeasureLength</c>).
/// </summary>
internal int MeasureCharactersThatFit(double availableWidth, out double widthConsumed)
{
var prefix = EnsureClusterCache();
var startIdx = _clusterStartIdx;
var count = _clusterCount;
if (count == 0)
{
widthConsumed = 0d;
return 0;
}
// Find the largest k such that prefix[startIdx + k] - prefix[startIdx] <=
// availableWidth. Standard binary search on the prefix-sum array, with the
// sub-buffer's base width subtracted out.
var basePrefix = prefix[startIdx];
var lo = 0;
var hi = count;
while (lo < hi)
{
var mid = (lo + hi + 1) >> 1;
if (MathUtilities.LessThanOrClose(prefix[startIdx + mid] - basePrefix, availableWidth))
{
lo = mid;
}
else
{
hi = mid - 1;
}
}
widthConsumed = prefix[startIdx + lo] - basePrefix;
// Simple-mode fast path: 1 char per cluster, so the consumed char count
// equals the cluster offset we just resolved (no start-chars table needed).
var starts = _clusterStartChars;
if (starts is null)
{
return lo;
}
return starts[startIdx + lo] - starts[startIdx];
}
/// <summary>
/// Returns the character length of the first logical cluster in this buffer.
/// Used by <c>MeasureLength</c> to satisfy the "include at least one cluster"
@ -799,5 +748,187 @@ namespace Avalonia.Media.TextFormatting
return new SplitResult<ShapedBuffer>(first, second);
}
/// <summary>
/// Returns the cumulative glyph advance for the logical character range
/// <c>[<paramref name="startChar"/>, <paramref name="endChar"/>)</c>
/// 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.
/// </summary>
/// <remarks>
/// The cluster cache is built in <i>logical</i> order for both LTR and
/// RTL buffers (see <see cref="EnsureClusterCache"/>), so callers pass
/// logical char offsets and the same code path serves both directions.
/// Out-of-range arguments are clamped to <c>[0, Text.Length]</c>.
/// </remarks>
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];
}
/// <summary>
/// Binary-search the largest cluster boundary index <c>i ∈ [0, count]</c>
/// such that <c>starts[startIdx + i] - baseChar ≤ charPos</c>. 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).
/// </summary>
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;
}
/// <summary>
/// Finds the largest <c>N</c> such that the first <c>N</c> logical
/// characters of this sub-buffer fit within <paramref name="availableWidth"/>.
/// Cluster-atomic: a multi-glyph cluster either fits completely or not at
/// all. Returns 0 if <paramref name="availableWidth"/> is non-positive
/// or the first cluster's width already exceeds it.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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];
}
/// <summary>
/// Finds the largest <c>N</c> such that the last <c>N</c> logical
/// characters of this sub-buffer fit within <paramref name="availableWidth"/>.
/// Cluster-atomic; <paramref name="consumedWidth"/> reports the actual
/// cumulative advance of those <c>N</c> chars.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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];
}
}
}

94
src/Avalonia.Base/Media/TextFormatting/ShapedTextRun.cs

@ -103,97 +103,33 @@ namespace Avalonia.Media.TextFormatting
}
/// <summary>
/// Measures the number of characters that fit into available width.
/// Returns the largest count of <b>logical leading</b> characters of this
/// run that fit within <paramref name="availableWidth"/>. 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).
/// </summary>
/// <param name="availableWidth">The available width.</param>
/// <param name="length">The count of fitting characters.</param>
/// <returns>
/// <c>true</c> if characters fit into the available width; otherwise, <c>false</c>.
/// <c>true</c> if at least one character fits within
/// <paramref name="availableWidth"/>; otherwise <c>false</c>.
/// </returns>
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;
}
/// <summary>
/// Returns the largest count of <b>logical trailing</b> characters of
/// this run that fit within <paramref name="availableWidth"/>, along
/// with the cumulative advance they consume. Cluster-atomic and
/// direction-agnostic.
/// </summary>
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;
}

19
src/Avalonia.Base/Media/TextFormatting/TextCollapsingProperties.cs

@ -21,9 +21,26 @@
public abstract FlowDirection FlowDirection { get; }
/// <summary>
/// Collapses given text line.
/// Collapses the given text line and returns the resulting runs, or
/// <see langword="null"/> if no collapse is needed (the consumer
/// then keeps the original line unchanged).
/// </summary>
/// <param name="textLine">Text line to collapse.</param>
/// <remarks>
/// Implementations MUST return runs in <b>logical order</b>. The
/// consumer (<c>TextLineImpl.Collapse</c>) wraps the returned array
/// in a new <see cref="TextLine"/> and re-runs the BiDi reorderer
/// via <c>FinalizeLine</c>, so pre-applying visual order here would
/// be reordered a second time and produce garbled output on RTL or
/// mixed-bidi lines.
/// <para>
/// Iterate the source line's runs via
/// <c>LogicalTextRunEnumerator</c>, not <see cref="TextLine.TextRuns"/>
/// (which is post-bidi visual order). Use
/// <see cref="CreateCollapsedRuns"/> when an implementation only
/// needs the standard "logical prefix + symbol" shape.
/// </para>
/// </remarks>
public abstract TextRun[]? Collapse(TextLine textLine);
/// <summary>

4
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;
}

250
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
/// <inheritdoc />
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<TextRun>();
}
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<TextRun>();
}
// 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<TextRun>? rentedPreSplitRuns = null;
RentedList<TextRun>? rentedPostSplitRuns = null;
var totalFitChars = charsBeforeCurrentRun + measuredLength;
try
if (totalFitChars > 0)
{
IReadOnlyList<TextRun>? effectivePostSplitRuns;
var collapsedRuns = objectPool.TextRunLists.Rent();
if (_prefixLength > 0)
RentedList<TextRun>? rentedPreSplitRuns = null;
RentedList<TextRun>? rentedPostSplitRuns = null;
RentedList<TextRun>? reversedSuffix = null;
try
{
(rentedPreSplitRuns, rentedPostSplitRuns) = TextFormatterImpl.SplitTextRuns(
textRuns, Math.Min(_prefixLength, measuredLength), objectPool);
IReadOnlyList<TextRun>? 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;
}
}
}

12
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:
{

10
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();
}

173
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<int>();
@ -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
/// <summary>
/// Calculates the total width of a specified segment within a sequence of text runs.
/// </summary>
/// <remarks>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.</remarks>
/// <param name="runs">The collection of text runs to measure. Each run represents a contiguous sequence of formatted text.</param>
/// <param name="segmentStart">The zero-based index of the first character in the segment to measure, relative to the combined text runs.</param>
/// <param name="segmentLength">The number of characters in the segment to measure. Must be non-negative.</param>
/// <returns>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.</returns>
private static double MeasureSegmentWidth(IReadOnlyList<TextRun> runs, int segmentStart, int segmentLength)
/// <remarks>
/// Uses the pre-computed <paramref name="runStartChars"/> 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
/// <see cref="ShapedBuffer.GetCharRangeWidth"/>, 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.
/// </remarks>
/// <param name="runs">The collection of text runs to measure.</param>
/// <param name="runStartChars">Cumulative char-offset table; entry <c>i</c> is the
/// total length of runs <c>0..i-1</c>, entry <c>Count</c> is the total char length.</param>
/// <param name="segmentStart">Zero-based start index of the segment, relative to the combined text runs.</param>
/// <param name="segmentLength">Number of characters in the segment. Must be non-negative.</param>
/// <returns>The segment width in device-independent units, or 0 if the segment is empty or out of range.</returns>
private static double MeasureSegmentWidth(IReadOnlyList<TextRun> 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;
}
/// <summary>
/// Binary-search <paramref name="runStartChars"/> for the largest index
/// <c>i</c> such that <c>runStartChars[i] &lt;= charIndex</c>. That index
/// is the first run that can contain or precede <paramref name="charIndex"/>.
/// </summary>
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;
}
}
}

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

@ -10,7 +10,7 @@ namespace Avalonia.Benchmarks.Text;
/// <summary>
/// Micro-benchmark for the <see cref="ShapedBuffer"/> cluster-cache hot paths
/// (<see cref="ShapedBuffer.TotalGlyphAdvance"/>, <see cref="ShapedBuffer.MeasureCharactersThatFit"/>,
/// (<see cref="ShapedBuffer.TotalGlyphAdvance"/>, <see cref="ShapedBuffer.FindLeadingCharCountWithinWidth"/>,
/// and the cached <see cref="ShapedBuffer.Split"/> chain). Compares the
/// simple-mode fast path (1 char per cluster) against complex clusters by
/// shaping random ASCII vs. random-from-extended-Latin so the buffers exercise
@ -77,7 +77,7 @@ public class ShapedBufferOps : IDisposable
}
/// <summary>
/// Repeated <see cref="ShapedBuffer.MeasureCharactersThatFit"/> targeting
/// Repeated <see cref="ShapedBuffer.FindLeadingCharCountWithinWidth"/> targeting
/// half the buffer's total width. Exercises the binary search across the
/// prefix table.
/// </summary>
@ -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;
}

596
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
{
/// <summary>
/// Characterization tests for <see cref="TextCollapsingProperties"/>
/// implementations, with emphasis on BiDi correctness.
/// </summary>
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<ShapedTextRun>().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<ShapedTextRun>().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<TextRun>();
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<System.ArgumentOutOfRangeException>(
() => 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<ShapedTextRun>()
.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.");
}
/// <summary>
/// Concatenates run text in logical order via
/// <see cref="LogicalTextRunEnumerator"/>. For LTR-only lines this
/// equals walking <c>TextRuns</c> directly; for RTL/mixed lines it
/// returns the original-text order (what the collapse contract
/// requires) instead of the visual post-bidi order.
/// </summary>
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();
}
/// <summary>
/// Local copy of the FixedRunsTextSource pattern used in
/// TextLineTests — that class is private, so duplicate here.
/// </summary>
private sealed class FixedRunsTextSource : ITextSource
{
private readonly IReadOnlyList<TextRun> _textRuns;
public FixedRunsTextSource(IReadOnlyList<TextRun> 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;
}
}
}
}

76
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

Loading…
Cancel
Save