Browse Source
* 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
committed by
Julien Lebosquain
12 changed files with 1176 additions and 314 deletions
@ -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; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
Loading…
Reference in new issue