Browse Source
* perf(text): O(1) width queries on ShapedBuffer, cached LB-class lookup
- ShapedBuffer: lazy, pooled cluster-prefix cache shared across Split;
adds TotalGlyphAdvance / MeasureCharactersThatFit.
- GlyphRun/TextFormatterImpl/Skia GlyphRunImpl: read the cache, drop
duplicate scans; SplitTextRuns now returns firstLength via out.
- LineBreakEnumerator: cache Next/PreviousClass per advance; remove
unused LineBreakPairTable.
- Tests + benchmarks for the new paths and a dotnet-trace harness.
~1.32x faster / -5% alloc on the emoji-wrap micro-benchmark; no
observable behaviour or public API change.
* perf(TextFormatting): inline LineBreakEnumerator rule dispatch
Replace the BreakUnitDelegate[] s_rules array dispatch in
LineBreakEnumerator.ExecuteRules with a sequence of direct static
calls and a `goto Done` early-exit. This removes 42 indirect calls
per codepoint and lets the JIT reason across rule boundaries, which
in turn makes [MethodImpl(AggressiveInlining)] meaningful — JIT
cannot inline through delegate.Invoke, so the attribute was a no-op
in the previous shape.
Selectively apply AggressiveInlining to the small single-condition
rules (LB03, LB04, LB06, LB07, LB08a, LB11–LB15d, LB18, LB20,
LB21b, LB22, LB29, LB31) and let the JIT decide on the larger ones
to avoid bloating the merged ExecuteRules.
The static BreakUnitDelegate[] s_rules array is removed.
Benchmarks (BDN default job, --inProcess, N=13–22, rel. StdDev <1.5%):
UnicodeBreakEnumeratorBenchmark.LineBreakEnumerator_Sequence
Ascii 154.6 µs -> 24.84 µs (6.22x)
Bmp 190.2 µs -> 32.81 µs (5.80x)
Supplementary 227.6 µs -> 37.56 µs (6.06x)
TextLayoutProfile.BuildEmojisWrapped
Before (branch, pre-inline) 804.5 µs / 570.15 KB
After (branch, post-inline) 547.0 µs / 570.15 KB (-32.0%)
vs upstream/master (1061 µs) -> ~1.94x total speedup
No allocation change; CPU-only dispatch reshape. All
LineBreakEnumerator unit tests pass (5/5).
* Refactor ShapedBuffer to share pool storage via IRef + generation counter
Wrap the ArrayPool-rented glyph and cluster-cache arrays in a small
PooledArray<T> disposable and expose them through IRef<T>. Split children
and WithBidiLevel aliases now Clone() the refs instead of borrowing raw
pool arrays, so the backing storage survives until every sibling has
been disposed - eliminating the UAF risk that existed when a parent was
disposed before its children.
Add a per-glyph-holder generation counter (Volatile.Read / Interlocked
.Increment). The indexer setter bumps the counter on every write, and
EnsureClusterCache compares its recorded generation against the holder's
current value, rebuilding on mismatch. This lets us drop the previous
"no mutation after Split/WithBidiLevel" contract: mutations performed
through any sibling now propagate to the others' caches transparently.
Dispose is made idempotent via a _disposed guard so overlapping cache
eviction and TextLine teardown only release the IRefs once.
Adds ShapedBufferSharedStorageTests covering sibling lifetime,
Dispose idempotency, and generation-driven cache invalidation across
Split children and WithBidiLevel aliases.
* test(ShapedBuffer): cover cluster-cache sharing across Split/WithBidiLevel aliases
Add a regression test hook (`ClusterPrefix`) exposing the
backing cluster-prefix array reference, plus two tests that mutate a
parent buffer before aliasing it and assert the alias reuses the
parent's pooled prefix array instead of rebuilding. Guards against
forgetting to propagate `_cacheGeneration` to alias buffers, which
would silently defeat the cached-split fast path.
* Correctly use MathUtilities.LessThanOrClose
---------
Co-authored-by: Julien Lebosquain <julien@lebosquain.net>
pull/21441/head
committed by
GitHub
23 changed files with 3462 additions and 356 deletions
@ -1,74 +0,0 @@ |
|||
// Copyright (c) Six Labors.
|
|||
// Licensed under the Apache License, Version 2.0.
|
|||
// Ported from: https://github.com/SixLabors/Fonts/
|
|||
|
|||
namespace Avalonia.Media.TextFormatting.Unicode |
|||
{ |
|||
internal static class LineBreakPairTable |
|||
{ |
|||
/// <summary>
|
|||
/// Direct break opportunity
|
|||
/// </summary>
|
|||
public const byte DIBRK = 0; |
|||
|
|||
/// <summary>
|
|||
/// Indirect break opportunity
|
|||
/// </summary>
|
|||
public const byte INBRK = 1; |
|||
|
|||
/// <summary>
|
|||
/// Indirect break opportunity for combining marks
|
|||
/// </summary>
|
|||
public const byte CIBRK = 2; |
|||
|
|||
/// <summary>
|
|||
/// Prohibited break for combining marks
|
|||
/// </summary>
|
|||
public const byte CPBRK = 3; |
|||
|
|||
/// <summary>
|
|||
/// Prohibited break
|
|||
/// </summary>
|
|||
public const byte PRBRK = 4; |
|||
|
|||
// Based on example pair table from https://www.unicode.org/reports/tr14/tr14-37.html#Table2
|
|||
// - ZWJ special processing for LB8a
|
|||
// - CB manually added as per Rule LB20
|
|||
public static byte[][] Table { get; } = { |
|||
// . OP CL CP QU GL NS EX SY IS PR PO NU AL HL ID IN HY BA BB B2 ZW CM WJ H2 H3 JL JV JT RI EB EM ZWJ CB
|
|||
new[] { PRBRK, PRBRK, PRBRK, PRBRK, PRBRK, PRBRK, PRBRK, PRBRK, PRBRK, PRBRK, PRBRK, PRBRK, PRBRK, PRBRK, PRBRK, PRBRK, PRBRK, PRBRK, PRBRK, PRBRK, PRBRK, CPBRK, PRBRK, PRBRK, PRBRK, PRBRK, PRBRK, PRBRK, PRBRK, PRBRK, PRBRK, PRBRK, PRBRK }, // OP
|
|||
new[] { DIBRK, PRBRK, PRBRK, INBRK, INBRK, PRBRK, PRBRK, PRBRK, PRBRK, INBRK, INBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, INBRK, INBRK, DIBRK, DIBRK, PRBRK, CIBRK, PRBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, INBRK, DIBRK }, // CL
|
|||
new[] { DIBRK, PRBRK, PRBRK, INBRK, INBRK, PRBRK, PRBRK, PRBRK, PRBRK, INBRK, INBRK, INBRK, INBRK, INBRK, DIBRK, DIBRK, INBRK, INBRK, DIBRK, DIBRK, PRBRK, CIBRK, PRBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, INBRK, DIBRK }, // CP
|
|||
new[] { PRBRK, PRBRK, PRBRK, INBRK, INBRK, INBRK, PRBRK, PRBRK, PRBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK, PRBRK, CIBRK, PRBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK }, // QU
|
|||
new[] { INBRK, PRBRK, PRBRK, INBRK, INBRK, INBRK, PRBRK, PRBRK, PRBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK, PRBRK, CIBRK, PRBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK }, // GL
|
|||
new[] { DIBRK, PRBRK, PRBRK, INBRK, INBRK, INBRK, PRBRK, PRBRK, PRBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, INBRK, INBRK, DIBRK, DIBRK, PRBRK, CIBRK, PRBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, INBRK, DIBRK }, // NS
|
|||
new[] { DIBRK, PRBRK, PRBRK, INBRK, INBRK, INBRK, PRBRK, PRBRK, PRBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, INBRK, INBRK, INBRK, DIBRK, DIBRK, PRBRK, CIBRK, PRBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, INBRK, DIBRK }, // EX
|
|||
new[] { DIBRK, PRBRK, PRBRK, INBRK, INBRK, INBRK, PRBRK, PRBRK, PRBRK, DIBRK, DIBRK, INBRK, DIBRK, INBRK, DIBRK, DIBRK, INBRK, INBRK, DIBRK, DIBRK, PRBRK, CIBRK, PRBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, INBRK, DIBRK }, // SY
|
|||
new[] { DIBRK, PRBRK, PRBRK, INBRK, INBRK, INBRK, PRBRK, PRBRK, PRBRK, DIBRK, DIBRK, INBRK, INBRK, INBRK, DIBRK, DIBRK, INBRK, INBRK, DIBRK, DIBRK, PRBRK, CIBRK, PRBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, INBRK, DIBRK }, // IS
|
|||
new[] { INBRK, PRBRK, PRBRK, INBRK, INBRK, INBRK, PRBRK, PRBRK, PRBRK, DIBRK, DIBRK, INBRK, INBRK, INBRK, INBRK, DIBRK, INBRK, INBRK, DIBRK, DIBRK, PRBRK, CIBRK, PRBRK, INBRK, INBRK, INBRK, INBRK, INBRK, DIBRK, INBRK, INBRK, INBRK, DIBRK }, // PR
|
|||
new[] { INBRK, PRBRK, PRBRK, INBRK, INBRK, INBRK, PRBRK, PRBRK, PRBRK, DIBRK, DIBRK, INBRK, INBRK, INBRK, DIBRK, DIBRK, INBRK, INBRK, DIBRK, DIBRK, PRBRK, CIBRK, PRBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, INBRK, DIBRK }, // PO
|
|||
new[] { INBRK, PRBRK, PRBRK, INBRK, INBRK, INBRK, PRBRK, PRBRK, PRBRK, INBRK, INBRK, INBRK, INBRK, INBRK, DIBRK, INBRK, INBRK, INBRK, DIBRK, DIBRK, PRBRK, CIBRK, PRBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, INBRK, DIBRK }, // NU
|
|||
new[] { INBRK, PRBRK, PRBRK, INBRK, INBRK, INBRK, PRBRK, PRBRK, PRBRK, INBRK, INBRK, INBRK, INBRK, INBRK, DIBRK, INBRK, INBRK, INBRK, DIBRK, DIBRK, PRBRK, CIBRK, PRBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, INBRK, DIBRK }, // AL
|
|||
new[] { INBRK, PRBRK, PRBRK, INBRK, INBRK, INBRK, PRBRK, PRBRK, PRBRK, INBRK, INBRK, INBRK, INBRK, INBRK, DIBRK, INBRK, INBRK, INBRK, DIBRK, DIBRK, PRBRK, CIBRK, PRBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, INBRK, DIBRK }, // HL
|
|||
new[] { DIBRK, PRBRK, PRBRK, INBRK, INBRK, INBRK, PRBRK, PRBRK, PRBRK, DIBRK, INBRK, DIBRK, DIBRK, DIBRK, DIBRK, INBRK, INBRK, INBRK, DIBRK, DIBRK, PRBRK, CIBRK, PRBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, INBRK, DIBRK }, // ID
|
|||
new[] { DIBRK, PRBRK, PRBRK, INBRK, INBRK, INBRK, PRBRK, PRBRK, PRBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, INBRK, INBRK, INBRK, DIBRK, DIBRK, PRBRK, CIBRK, PRBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, INBRK, DIBRK }, // IN
|
|||
new[] { DIBRK, PRBRK, PRBRK, INBRK, DIBRK, INBRK, PRBRK, PRBRK, PRBRK, DIBRK, DIBRK, INBRK, DIBRK, DIBRK, DIBRK, DIBRK, INBRK, INBRK, DIBRK, DIBRK, PRBRK, CIBRK, PRBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, INBRK, DIBRK }, // HY
|
|||
new[] { DIBRK, PRBRK, PRBRK, INBRK, DIBRK, INBRK, PRBRK, PRBRK, PRBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, INBRK, INBRK, DIBRK, DIBRK, PRBRK, CIBRK, PRBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, INBRK, DIBRK }, // BA
|
|||
new[] { INBRK, PRBRK, PRBRK, INBRK, INBRK, INBRK, PRBRK, PRBRK, PRBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK, PRBRK, CIBRK, PRBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK, DIBRK }, // BB
|
|||
new[] { DIBRK, PRBRK, PRBRK, INBRK, INBRK, INBRK, PRBRK, PRBRK, PRBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, INBRK, INBRK, DIBRK, PRBRK, PRBRK, CIBRK, PRBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, INBRK, DIBRK }, // B2
|
|||
new[] { DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, PRBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK }, // ZW
|
|||
new[] { INBRK, PRBRK, PRBRK, INBRK, INBRK, INBRK, PRBRK, PRBRK, PRBRK, INBRK, INBRK, INBRK, INBRK, INBRK, DIBRK, INBRK, INBRK, INBRK, DIBRK, DIBRK, PRBRK, CIBRK, PRBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, INBRK, DIBRK }, // CM
|
|||
new[] { INBRK, PRBRK, PRBRK, INBRK, INBRK, INBRK, PRBRK, PRBRK, PRBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK, PRBRK, CIBRK, PRBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK, INBRK }, // WJ
|
|||
new[] { DIBRK, PRBRK, PRBRK, INBRK, INBRK, INBRK, PRBRK, PRBRK, PRBRK, DIBRK, INBRK, DIBRK, DIBRK, DIBRK, DIBRK, INBRK, INBRK, INBRK, DIBRK, DIBRK, PRBRK, CIBRK, PRBRK, DIBRK, DIBRK, DIBRK, INBRK, INBRK, DIBRK, DIBRK, DIBRK, INBRK, DIBRK }, // H2
|
|||
new[] { DIBRK, PRBRK, PRBRK, INBRK, INBRK, INBRK, PRBRK, PRBRK, PRBRK, DIBRK, INBRK, DIBRK, DIBRK, DIBRK, DIBRK, INBRK, INBRK, INBRK, DIBRK, DIBRK, PRBRK, CIBRK, PRBRK, DIBRK, DIBRK, DIBRK, DIBRK, INBRK, DIBRK, DIBRK, DIBRK, INBRK, DIBRK }, // H3
|
|||
new[] { DIBRK, PRBRK, PRBRK, INBRK, INBRK, INBRK, PRBRK, PRBRK, PRBRK, DIBRK, INBRK, DIBRK, DIBRK, DIBRK, DIBRK, INBRK, INBRK, INBRK, DIBRK, DIBRK, PRBRK, CIBRK, PRBRK, INBRK, INBRK, INBRK, INBRK, DIBRK, DIBRK, DIBRK, DIBRK, INBRK, DIBRK }, // JL
|
|||
new[] { DIBRK, PRBRK, PRBRK, INBRK, INBRK, INBRK, PRBRK, PRBRK, PRBRK, DIBRK, INBRK, DIBRK, DIBRK, DIBRK, DIBRK, INBRK, INBRK, INBRK, DIBRK, DIBRK, PRBRK, CIBRK, PRBRK, DIBRK, DIBRK, DIBRK, INBRK, INBRK, DIBRK, DIBRK, DIBRK, INBRK, DIBRK }, // JV
|
|||
new[] { DIBRK, PRBRK, PRBRK, INBRK, INBRK, INBRK, PRBRK, PRBRK, PRBRK, DIBRK, INBRK, DIBRK, DIBRK, DIBRK, DIBRK, INBRK, INBRK, INBRK, DIBRK, DIBRK, PRBRK, CIBRK, PRBRK, DIBRK, DIBRK, DIBRK, DIBRK, INBRK, DIBRK, DIBRK, DIBRK, INBRK, DIBRK }, // JT
|
|||
new[] { DIBRK, PRBRK, PRBRK, INBRK, INBRK, INBRK, PRBRK, PRBRK, PRBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, INBRK, INBRK, DIBRK, DIBRK, PRBRK, CIBRK, PRBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, INBRK, DIBRK, DIBRK, INBRK, DIBRK }, // RI
|
|||
new[] { DIBRK, PRBRK, PRBRK, INBRK, INBRK, INBRK, PRBRK, PRBRK, PRBRK, DIBRK, INBRK, DIBRK, DIBRK, DIBRK, DIBRK, INBRK, INBRK, INBRK, DIBRK, DIBRK, PRBRK, CIBRK, PRBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, INBRK, INBRK, DIBRK }, // EB
|
|||
new[] { DIBRK, PRBRK, PRBRK, INBRK, INBRK, INBRK, PRBRK, PRBRK, PRBRK, DIBRK, INBRK, DIBRK, DIBRK, DIBRK, DIBRK, INBRK, INBRK, INBRK, DIBRK, DIBRK, PRBRK, CIBRK, PRBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, INBRK, DIBRK }, // EM
|
|||
new[] { INBRK, PRBRK, PRBRK, INBRK, INBRK, INBRK, PRBRK, PRBRK, PRBRK, INBRK, INBRK, INBRK, INBRK, INBRK, DIBRK, INBRK, INBRK, INBRK, DIBRK, DIBRK, PRBRK, CIBRK, PRBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, INBRK, DIBRK }, // ZWJ
|
|||
new[] { DIBRK, PRBRK, PRBRK, INBRK, INBRK, DIBRK, PRBRK, PRBRK, PRBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, PRBRK, CIBRK, PRBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, DIBRK, INBRK, DIBRK } // CB
|
|||
}; |
|||
} |
|||
} |
|||
@ -0,0 +1,262 @@ |
|||
using System; |
|||
using System.Collections.Generic; |
|||
using Avalonia.Media.TextFormatting.Unicode; |
|||
using Xunit; |
|||
|
|||
namespace Avalonia.Base.UnitTests.Media.TextFormatting; |
|||
|
|||
/// <summary>
|
|||
/// Direct coverage for <see cref="Codepoint"/> — surrogate-pair decoding via
|
|||
/// <see cref="Codepoint.ReadAt(ReadOnlySpan{char}, int, out int)"/>, the small
|
|||
/// helper properties / methods, and the bitmask-based <c>IsWhiteSpace</c> path
|
|||
/// that depends on every used <see cref="GeneralCategory"/> value fitting in 64
|
|||
/// bits.
|
|||
/// </summary>
|
|||
public class CodepointTests |
|||
{ |
|||
[Theory] |
|||
[InlineData("a", 0, (uint)'a', 1)] |
|||
[InlineData("abc", 1, (uint)'b', 1)] |
|||
[InlineData("abc", 2, (uint)'c', 1)] |
|||
public void ReadAt_BmpScalar_ReturnsCharAndAdvancesByOne(string text, int index, uint expectedValue, int expectedCount) |
|||
{ |
|||
var cp = Codepoint.ReadAt(text.AsSpan(), index, out var count); |
|||
|
|||
Assert.Equal(expectedValue, cp.Value); |
|||
Assert.Equal(expectedCount, count); |
|||
} |
|||
|
|||
[Fact] |
|||
public void ReadAt_HighSurrogate_AtStart_DecodesPair() |
|||
{ |
|||
// U+1F600 GRINNING FACE — high surrogate at index 0.
|
|||
const string text = "😀"; |
|||
|
|||
var cp = Codepoint.ReadAt(text.AsSpan(), 0, out var count); |
|||
|
|||
Assert.Equal(0x1F600u, cp.Value); |
|||
Assert.Equal(2, count); |
|||
} |
|||
|
|||
[Fact] |
|||
public void ReadAt_LowSurrogate_ScansBackToHighSurrogate() |
|||
{ |
|||
// Reading at the low surrogate position should still return the full
|
|||
// supplementary codepoint by looking one index back.
|
|||
const string text = "😀"; |
|||
|
|||
var cp = Codepoint.ReadAt(text.AsSpan(), 1, out var count); |
|||
|
|||
Assert.Equal(0x1F600u, cp.Value); |
|||
Assert.Equal(2, count); |
|||
} |
|||
|
|||
[Fact] |
|||
public void ReadAt_HighSurrogate_WithoutFollowingLow_ReturnsReplacement() |
|||
{ |
|||
// Lone high surrogate at end of string.
|
|||
const string text = "a\uD83D"; |
|||
|
|||
var cp = Codepoint.ReadAt(text.AsSpan(), 1, out var count); |
|||
|
|||
Assert.Equal(Codepoint.ReplacementCodepoint.Value, cp.Value); |
|||
Assert.Equal(1, count); |
|||
} |
|||
|
|||
[Fact] |
|||
public void ReadAt_HighSurrogate_FollowedByNonLow_ReturnsReplacement() |
|||
{ |
|||
// High surrogate followed by a regular BMP character (invalid pair).
|
|||
const string text = "\uD83Da"; |
|||
|
|||
var cp = Codepoint.ReadAt(text.AsSpan(), 0, out var count); |
|||
|
|||
Assert.Equal(Codepoint.ReplacementCodepoint.Value, cp.Value); |
|||
Assert.Equal(1, count); |
|||
} |
|||
|
|||
[Fact] |
|||
public void ReadAt_LoneLowSurrogate_AtStart_ReturnsReplacement() |
|||
{ |
|||
// Lone low surrogate with nothing before it.
|
|||
const string text = "\uDE00b"; |
|||
|
|||
var cp = Codepoint.ReadAt(text.AsSpan(), 0, out var count); |
|||
|
|||
Assert.Equal(Codepoint.ReplacementCodepoint.Value, cp.Value); |
|||
Assert.Equal(1, count); |
|||
} |
|||
|
|||
[Fact] |
|||
public void ReadAt_LowSurrogate_NotPrecededByHigh_ReturnsReplacement() |
|||
{ |
|||
// Low surrogate at index 1, but index 0 is a regular char (not a high surrogate).
|
|||
const string text = "a\uDE00"; |
|||
|
|||
var cp = Codepoint.ReadAt(text.AsSpan(), 1, out var count); |
|||
|
|||
Assert.Equal(Codepoint.ReplacementCodepoint.Value, cp.Value); |
|||
Assert.Equal(1, count); |
|||
} |
|||
|
|||
[Fact] |
|||
public void ReadAt_IndexPastLength_ReturnsReplacement() |
|||
{ |
|||
const string text = "abc"; |
|||
|
|||
var cp = Codepoint.ReadAt(text.AsSpan(), 5, out var count); |
|||
|
|||
Assert.Equal(Codepoint.ReplacementCodepoint.Value, cp.Value); |
|||
Assert.Equal(1, count); |
|||
} |
|||
|
|||
[Fact] |
|||
public void ReadAt_IndexAtLength_ReturnsReplacement() |
|||
{ |
|||
const string text = "abc"; |
|||
|
|||
var cp = Codepoint.ReadAt(text.AsSpan(), 3, out var count); |
|||
|
|||
Assert.Equal(Codepoint.ReplacementCodepoint.Value, cp.Value); |
|||
Assert.Equal(1, count); |
|||
} |
|||
|
|||
[Fact] |
|||
public void ReadAt_EmptySpan_ReturnsReplacement() |
|||
{ |
|||
var cp = Codepoint.ReadAt(ReadOnlySpan<char>.Empty, 0, out var count); |
|||
|
|||
Assert.Equal(Codepoint.ReplacementCodepoint.Value, cp.Value); |
|||
Assert.Equal(1, count); |
|||
} |
|||
|
|||
[Fact] |
|||
public void CodepointEnumerator_DecodesMixedBmpAndSupplementaryText() |
|||
{ |
|||
// 'a' + 😀 (U+1F600) + 'b' + ✓ (U+2713) + 🚀 (U+1F680).
|
|||
const string text = "a😀b✓🚀"; |
|||
|
|||
var expected = new uint[] { 'a', 0x1F600, 'b', 0x2713, 0x1F680 }; |
|||
|
|||
var enumerator = new CodepointEnumerator(text.AsSpan()); |
|||
var actual = new List<uint>(); |
|||
while (enumerator.MoveNext(out var cp)) |
|||
{ |
|||
actual.Add(cp.Value); |
|||
} |
|||
|
|||
Assert.Equal(expected, actual); |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// <see cref="Codepoint.IsWhiteSpace"/> uses a bitmask trick that assumes every
|
|||
/// <see cref="GeneralCategory"/> value used in the mask fits in 64 bits.
|
|||
/// If <c>Control</c>, <c>Format</c>, or <c>SpaceSeparator</c> ever moves past
|
|||
/// position 63 in the enum, the mask silently produces wrong results. This
|
|||
/// guards against that.
|
|||
/// </summary>
|
|||
[Fact] |
|||
public void IsWhiteSpace_AllMaskedGeneralCategoriesFitInBitmask() |
|||
{ |
|||
Assert.True((int)GeneralCategory.Control < 64); |
|||
Assert.True((int)GeneralCategory.Format < 64); |
|||
Assert.True((int)GeneralCategory.SpaceSeparator < 64); |
|||
} |
|||
|
|||
[Theory] |
|||
[InlineData(0x0020u, true)] // SPACE
|
|||
[InlineData(0x0009u, true)] // TAB (Control)
|
|||
[InlineData(0x000Au, true)] // LF (Control)
|
|||
[InlineData(0x000Du, true)] // CR (Control)
|
|||
[InlineData(0x00A0u, true)] // NBSP (SpaceSeparator)
|
|||
[InlineData(0x200Bu, true)] // ZWSP (Format)
|
|||
[InlineData(0x0061u, false)] // 'a'
|
|||
[InlineData(0x0030u, false)] // '0'
|
|||
[InlineData(0x002Eu, false)] // '.'
|
|||
public void IsWhiteSpace_KnownCodepoints(uint value, bool expected) |
|||
{ |
|||
Assert.Equal(expected, new Codepoint(value).IsWhiteSpace); |
|||
} |
|||
|
|||
[Theory] |
|||
[InlineData(0x000Au, true)] // LF
|
|||
[InlineData(0x000Bu, true)] // VT
|
|||
[InlineData(0x000Cu, true)] // FF
|
|||
[InlineData(0x000Du, true)] // CR
|
|||
[InlineData(0x0085u, true)] // NEL
|
|||
[InlineData(0x2028u, true)] // LINE SEPARATOR
|
|||
[InlineData(0x2029u, true)] // PARAGRAPH SEPARATOR
|
|||
[InlineData(0x0020u, false)] |
|||
[InlineData(0x0061u, false)] |
|||
[InlineData(0x0009u, false)] // TAB is not a "break" char in Avalonia's sense
|
|||
public void IsBreakChar_KnownCodepoints(uint value, bool expected) |
|||
{ |
|||
Assert.Equal(expected, new Codepoint(value).IsBreakChar); |
|||
} |
|||
|
|||
[Theory] |
|||
[InlineData(0x0061u, false)] // 'a'
|
|||
[InlineData(0x4E2Du, true)] // '中' Wide
|
|||
[InlineData(0xFF21u, true)] // 'A' Fullwidth
|
|||
[InlineData(0xFF71u, true)] // 'ア' Halfwidth
|
|||
[InlineData(0x03B1u, false)] // 'α' Ambiguous (not east asian per IsEastAsian)
|
|||
[InlineData(0x0020u, false)] // ' ' Narrow
|
|||
public void IsEastAsian_KnownCodepoints(uint value, bool expected) |
|||
{ |
|||
Assert.Equal(expected, new Codepoint(value).IsEastAsian); |
|||
} |
|||
|
|||
[Theory] |
|||
[InlineData(0x3008u, 0x2329u)] // 〈 → ⟨
|
|||
[InlineData(0x3009u, 0x232Au)] // 〉 → ⟩
|
|||
[InlineData(0x0061u, 0x0061u)] // 'a' → 'a' (unchanged)
|
|||
[InlineData(0x0028u, 0x0028u)] // '(' → '(' (unchanged)
|
|||
public void GetCanonicalType_MapsKnownCodepoints(uint input, uint expected) |
|||
{ |
|||
// GetCanonicalType is internal; reachable here via InternalsVisibleTo.
|
|||
var actual = Codepoint.GetCanonicalType(new Codepoint(input)); |
|||
|
|||
Assert.Equal(expected, actual.Value); |
|||
} |
|||
|
|||
[Theory] |
|||
[InlineData(0x0028u, true, 0x0029u)] // '(' → ')'
|
|||
[InlineData(0x0029u, true, 0x0028u)] // ')' → '('
|
|||
[InlineData(0x005Bu, true, 0x005Du)] // '[' → ']'
|
|||
[InlineData(0x005Du, true, 0x005Bu)] // ']' → '['
|
|||
[InlineData(0x0061u, false, 0u)] // 'a' has no pair
|
|||
[InlineData(0x0020u, false, 0u)] // ' ' has no pair
|
|||
public void TryGetPairedBracket_KnownCodepoints(uint codepoint, bool expectedSuccess, uint expectedPair) |
|||
{ |
|||
var result = new Codepoint(codepoint).TryGetPairedBracket(out var pair); |
|||
|
|||
Assert.Equal(expectedSuccess, result); |
|||
|
|||
if (expectedSuccess) |
|||
{ |
|||
Assert.Equal(expectedPair, pair.Value); |
|||
} |
|||
} |
|||
|
|||
[Fact] |
|||
public void ImplicitConversions_RoundTripValue() |
|||
{ |
|||
var cp = new Codepoint(0x1F600u); |
|||
|
|||
int asInt = cp; |
|||
uint asUint = cp; |
|||
|
|||
Assert.Equal(0x1F600, asInt); |
|||
Assert.Equal(0x1F600u, asUint); |
|||
} |
|||
|
|||
[Fact] |
|||
public void IsInRangeInclusive_BoundsAreInclusive() |
|||
{ |
|||
Assert.True(Codepoint.IsInRangeInclusive(new Codepoint(0x10u), 0x10u, 0x20u)); |
|||
Assert.True(Codepoint.IsInRangeInclusive(new Codepoint(0x20u), 0x10u, 0x20u)); |
|||
Assert.True(Codepoint.IsInRangeInclusive(new Codepoint(0x15u), 0x10u, 0x20u)); |
|||
Assert.False(Codepoint.IsInRangeInclusive(new Codepoint(0x0Fu), 0x10u, 0x20u)); |
|||
Assert.False(Codepoint.IsInRangeInclusive(new Codepoint(0x21u), 0x10u, 0x20u)); |
|||
} |
|||
} |
|||
@ -0,0 +1,94 @@ |
|||
using Avalonia.Media.TextFormatting.Unicode; |
|||
using Xunit; |
|||
|
|||
namespace Avalonia.Base.UnitTests.Media.TextFormatting; |
|||
|
|||
/// <summary>
|
|||
/// Lightweight round-trip checks for the generated <see cref="PropertyValueAliasHelper"/>.
|
|||
/// Catches regressions in the alias-helper writer (e.g. wrong typeName, missing
|
|||
/// entries, casing mismatches) without re-asserting the UCD aliases themselves.
|
|||
/// </summary>
|
|||
public class PropertyValueAliasHelperTests |
|||
{ |
|||
[Theory] |
|||
[InlineData("L", BidiClass.LeftToRight)] |
|||
[InlineData("R", BidiClass.RightToLeft)] |
|||
[InlineData("AL", BidiClass.ArabicLetter)] |
|||
[InlineData("EN", BidiClass.EuropeanNumber)] |
|||
public void GetBidiClass_KnownTags(string tag, BidiClass expected) |
|||
{ |
|||
Assert.Equal(expected, PropertyValueAliasHelper.GetBidiClass(tag)); |
|||
} |
|||
|
|||
[Fact] |
|||
public void GetBidiClass_UnknownTag_FallsBackToLeftToRight() |
|||
{ |
|||
// The generator emits LeftToRight as the fallback for unknown tags.
|
|||
Assert.Equal(BidiClass.LeftToRight, PropertyValueAliasHelper.GetBidiClass("not-a-real-tag")); |
|||
} |
|||
|
|||
[Theory] |
|||
[InlineData("Latn", Script.Latin)] |
|||
[InlineData("Cyrl", Script.Cyrillic)] |
|||
[InlineData("Hani", Script.Han)] |
|||
[InlineData("Hebr", Script.Hebrew)] |
|||
[InlineData("Arab", Script.Arabic)] |
|||
[InlineData("Zyyy", Script.Common)] |
|||
public void GetScript_KnownTags(string tag, Script expected) |
|||
{ |
|||
Assert.Equal(expected, PropertyValueAliasHelper.GetScript(tag)); |
|||
} |
|||
|
|||
[Fact] |
|||
public void GetTag_RoundTripsScriptThroughGetScript() |
|||
{ |
|||
// Tag -> enum -> tag should round-trip for every script the helper knows.
|
|||
// Pick a handful so we catch obvious typoes in the writer without needing
|
|||
// a full mirror of UCD here.
|
|||
foreach (var script in new[] { Script.Latin, Script.Cyrillic, Script.Han, Script.Hebrew, Script.Arabic }) |
|||
{ |
|||
var tag = PropertyValueAliasHelper.GetTag(script); |
|||
Assert.Equal(script, PropertyValueAliasHelper.GetScript(tag)); |
|||
} |
|||
} |
|||
|
|||
[Theory] |
|||
[InlineData("Lu", GeneralCategory.UppercaseLetter)] |
|||
[InlineData("Ll", GeneralCategory.LowercaseLetter)] |
|||
[InlineData("Nd", GeneralCategory.DecimalNumber)] |
|||
[InlineData("Zs", GeneralCategory.SpaceSeparator)] |
|||
[InlineData("Cc", GeneralCategory.Control)] |
|||
public void GetGeneralCategory_KnownTags(string tag, GeneralCategory expected) |
|||
{ |
|||
Assert.Equal(expected, PropertyValueAliasHelper.GetGeneralCategory(tag)); |
|||
} |
|||
|
|||
[Theory] |
|||
[InlineData("AL", LineBreakClass.Alphabetic)] |
|||
[InlineData("LF", LineBreakClass.LineFeed)] |
|||
[InlineData("CR", LineBreakClass.CarriageReturn)] |
|||
[InlineData("XX", LineBreakClass.Unknown)] |
|||
public void GetLineBreakClass_KnownTags(string tag, LineBreakClass expected) |
|||
{ |
|||
Assert.Equal(expected, PropertyValueAliasHelper.GetLineBreakClass(tag)); |
|||
} |
|||
|
|||
[Theory] |
|||
[InlineData("LE", WordBreakClass.ALetter)] |
|||
[InlineData("CR", WordBreakClass.CarriageReturn)] |
|||
[InlineData("LF", WordBreakClass.LineFeed)] |
|||
[InlineData("XX", WordBreakClass.Other)] |
|||
public void GetWordBreakClass_KnownTags(string tag, WordBreakClass expected) |
|||
{ |
|||
Assert.Equal(expected, PropertyValueAliasHelper.GetWordBreakClass(tag)); |
|||
} |
|||
|
|||
[Theory] |
|||
[InlineData("o", BidiPairedBracketType.Open)] |
|||
[InlineData("c", BidiPairedBracketType.Close)] |
|||
[InlineData("n", BidiPairedBracketType.None)] |
|||
public void GetBidiPairedBracketType_KnownTags(string tag, BidiPairedBracketType expected) |
|||
{ |
|||
Assert.Equal(expected, PropertyValueAliasHelper.GetBidiPairedBracketType(tag)); |
|||
} |
|||
} |
|||
@ -0,0 +1,176 @@ |
|||
using Avalonia.Media.TextFormatting.Unicode; |
|||
using Xunit; |
|||
|
|||
namespace Avalonia.Base.UnitTests.Media.TextFormatting; |
|||
|
|||
/// <summary>
|
|||
/// Spot-checks for <see cref="UnicodeData"/>. The trie generator already round-trips
|
|||
/// every assigned codepoint against its source dictionary, so these tests focus on
|
|||
/// pinning the public surface against drift: shift/mask layout, default values for
|
|||
/// unassigned codepoints, and a few well-known codepoints across the four tries.
|
|||
/// </summary>
|
|||
public class UnicodeDataTests |
|||
{ |
|||
[Theory] |
|||
[InlineData(0x0061u, GeneralCategory.LowercaseLetter)] // 'a'
|
|||
[InlineData(0x0041u, GeneralCategory.UppercaseLetter)] // 'A'
|
|||
[InlineData(0x0030u, GeneralCategory.DecimalNumber)] // '0'
|
|||
[InlineData(0x0020u, GeneralCategory.SpaceSeparator)] // ' '
|
|||
[InlineData(0x000Au, GeneralCategory.Control)] // '\n'
|
|||
[InlineData(0x0009u, GeneralCategory.Control)] // '\t'
|
|||
[InlineData(0x002Eu, GeneralCategory.OtherPunctuation)] // '.'
|
|||
[InlineData(0x0028u, GeneralCategory.OpenPunctuation)] // '('
|
|||
[InlineData(0x0029u, GeneralCategory.ClosePunctuation)] // ')'
|
|||
[InlineData(0x0024u, GeneralCategory.CurrencySymbol)] // '$'
|
|||
[InlineData(0x002Bu, GeneralCategory.MathSymbol)] // '+'
|
|||
[InlineData(0x200Bu, GeneralCategory.Format)] // ZWSP
|
|||
[InlineData(0xE000u, GeneralCategory.PrivateUse)] // BMP PUA start
|
|||
[InlineData(0xD800u, GeneralCategory.Surrogate)] // high surrogate start (LSCP path)
|
|||
[InlineData(0xDFFFu, GeneralCategory.Surrogate)] // low surrogate end (LSCP path)
|
|||
// Note: codepoints >= HighStart (currently 0x100000) all collapse to a single
|
|||
// fallback value because the trie compresses Plane 16 to save space. The
|
|||
// resulting GeneralCategory is not the per-codepoint UCD value. See
|
|||
// UnicodeTrieTests.Get_AtAndAboveHighStart_AllCodepointsShareFallback.
|
|||
public void GetGeneralCategory_KnownCodepoints(uint codepoint, GeneralCategory expected) |
|||
{ |
|||
Assert.Equal(expected, UnicodeData.GetGeneralCategory(codepoint)); |
|||
} |
|||
|
|||
[Theory] |
|||
[InlineData(0x0061u, Script.Latin)] // 'a'
|
|||
[InlineData(0x0041u, Script.Latin)] // 'A'
|
|||
[InlineData(0x044Fu, Script.Cyrillic)] // 'я'
|
|||
[InlineData(0x4E2Du, Script.Han)] // '中'
|
|||
[InlineData(0x05D0u, Script.Hebrew)] // 'א'
|
|||
[InlineData(0x0627u, Script.Arabic)] // 'ا'
|
|||
[InlineData(0x0020u, Script.Common)] // ' '
|
|||
[InlineData(0x0030u, Script.Common)] // '0'
|
|||
[InlineData(0x1F600u, Script.Common)] // 😀 (supplementary, common)
|
|||
[InlineData(0x0300u, Script.Inherited)] // combining grave (inherited)
|
|||
public void GetScript_KnownCodepoints(uint codepoint, Script expected) |
|||
{ |
|||
Assert.Equal(expected, UnicodeData.GetScript(codepoint)); |
|||
} |
|||
|
|||
[Theory] |
|||
[InlineData(0x0061u, BidiClass.LeftToRight)] // 'a'
|
|||
[InlineData(0x0041u, BidiClass.LeftToRight)] // 'A'
|
|||
[InlineData(0x0039u, BidiClass.EuropeanNumber)] // '9'
|
|||
[InlineData(0x0024u, BidiClass.EuropeanTerminator)] // '$'
|
|||
[InlineData(0x002Cu, BidiClass.CommonSeparator)] // ','
|
|||
[InlineData(0x0020u, BidiClass.WhiteSpace)] // ' '
|
|||
[InlineData(0x0009u, BidiClass.SegmentSeparator)] // '\t'
|
|||
[InlineData(0x000Au, BidiClass.ParagraphSeparator)] // '\n'
|
|||
[InlineData(0x05D0u, BidiClass.RightToLeft)] // 'א'
|
|||
[InlineData(0x0627u, BidiClass.ArabicLetter)] // 'ا'
|
|||
public void GetBiDiClass_KnownCodepoints(uint codepoint, BidiClass expected) |
|||
{ |
|||
Assert.Equal(expected, UnicodeData.GetBiDiClass(codepoint)); |
|||
} |
|||
|
|||
[Theory] |
|||
[InlineData(0x0028u, BidiPairedBracketType.Open, 0x0029u)] // '(' → ')'
|
|||
[InlineData(0x0029u, BidiPairedBracketType.Close, 0x0028u)] // ')' → '('
|
|||
[InlineData(0x005Bu, BidiPairedBracketType.Open, 0x005Du)] // '[' → ']'
|
|||
[InlineData(0x005Du, BidiPairedBracketType.Close, 0x005Bu)] // ']' → '['
|
|||
[InlineData(0x007Bu, BidiPairedBracketType.Open, 0x007Du)] // '{' → '}'
|
|||
public void GetBiDiPairedBracket_RoundTripsKnownPairs(uint codepoint, BidiPairedBracketType expectedType, uint expectedPair) |
|||
{ |
|||
Assert.Equal(expectedType, UnicodeData.GetBiDiPairedBracketType(codepoint)); |
|||
Assert.Equal(expectedPair, UnicodeData.GetBiDiPairedBracket(codepoint).Value); |
|||
} |
|||
|
|||
[Fact] |
|||
public void GetBiDiPairedBracketType_NonBracket_IsNone() |
|||
{ |
|||
Assert.Equal(BidiPairedBracketType.None, UnicodeData.GetBiDiPairedBracketType(0x0061u)); |
|||
Assert.Equal(BidiPairedBracketType.None, UnicodeData.GetBiDiPairedBracketType(0x0020u)); |
|||
} |
|||
|
|||
[Theory] |
|||
[InlineData(0x0061u, LineBreakClass.Alphabetic)] // 'a'
|
|||
[InlineData(0x000Au, LineBreakClass.LineFeed)] // '\n'
|
|||
[InlineData(0x000Du, LineBreakClass.CarriageReturn)] // '\r'
|
|||
[InlineData(0x0020u, LineBreakClass.Space)] // ' '
|
|||
[InlineData(0x0009u, LineBreakClass.BreakAfter)] // '\t'
|
|||
[InlineData(0x002Du, LineBreakClass.Hyphen)] // '-'
|
|||
[InlineData(0x0028u, LineBreakClass.OpenPunctuation)] // '('
|
|||
[InlineData(0x0029u, LineBreakClass.CloseParenthesis)]// ')'
|
|||
[InlineData(0x0030u, LineBreakClass.Numeric)] // '0'
|
|||
[InlineData(0x4E2Du, LineBreakClass.Ideographic)] // '中'
|
|||
[InlineData(0x2028u, LineBreakClass.MandatoryBreak)] // LINE SEPARATOR
|
|||
[InlineData(0x2029u, LineBreakClass.MandatoryBreak)] // PARAGRAPH SEPARATOR
|
|||
public void GetLineBreakClass_KnownCodepoints(uint codepoint, LineBreakClass expected) |
|||
{ |
|||
Assert.Equal(expected, UnicodeData.GetLineBreakClass(codepoint)); |
|||
} |
|||
|
|||
[Theory] |
|||
[InlineData(0x0061u, WordBreakClass.ALetter)] // 'a'
|
|||
[InlineData(0x000Du, WordBreakClass.CarriageReturn)] // '\r'
|
|||
[InlineData(0x000Au, WordBreakClass.LineFeed)] // '\n'
|
|||
[InlineData(0x0020u, WordBreakClass.WSegSpace)] // ' '
|
|||
[InlineData(0x0030u, WordBreakClass.Numeric)] // '0'
|
|||
[InlineData(0x200Du, WordBreakClass.ZWJ)] // ZWJ
|
|||
[InlineData(0x05D0u, WordBreakClass.HebrewLetter)] // 'א'
|
|||
[InlineData(0x4E2Du, WordBreakClass.Other)] // '中' (CJK is WB=Other)
|
|||
public void GetWordBreakClass_KnownCodepoints(uint codepoint, WordBreakClass expected) |
|||
{ |
|||
Assert.Equal(expected, UnicodeData.GetWordBreakClass(codepoint)); |
|||
} |
|||
|
|||
[Theory] |
|||
[InlineData(0x000Du, GraphemeBreakClass.CR)] // '\r'
|
|||
[InlineData(0x000Au, GraphemeBreakClass.LF)] // '\n'
|
|||
[InlineData(0x200Du, GraphemeBreakClass.ZWJ)] // ZWJ
|
|||
[InlineData(0x1F600u, GraphemeBreakClass.ExtendedPictographic)] // 😀 (overridden by emoji-data.txt)
|
|||
[InlineData(0x1100u, GraphemeBreakClass.L)] // HANGUL CHOSEONG KIYEOK
|
|||
[InlineData(0x1161u, GraphemeBreakClass.V)] // HANGUL JUNGSEONG A
|
|||
[InlineData(0x11A8u, GraphemeBreakClass.T)] // HANGUL JONGSEONG KIYEOK
|
|||
[InlineData(0x0061u, GraphemeBreakClass.Other)] // 'a'
|
|||
[InlineData(0x0030u, GraphemeBreakClass.Other)] // '0'
|
|||
public void GetGraphemeClusterBreak_KnownCodepoints(uint codepoint, GraphemeBreakClass expected) |
|||
{ |
|||
Assert.Equal(expected, UnicodeData.GetGraphemeClusterBreak(codepoint)); |
|||
} |
|||
|
|||
[Theory] |
|||
[InlineData(0x0061u, EastAsianWidthClass.Narrow)] // 'a'
|
|||
[InlineData(0x0020u, EastAsianWidthClass.Narrow)] // ' '
|
|||
[InlineData(0x4E2Du, EastAsianWidthClass.Wide)] // '中'
|
|||
[InlineData(0xFF21u, EastAsianWidthClass.Fullwidth)] // 'A' FULLWIDTH LATIN CAPITAL A
|
|||
[InlineData(0xFF71u, EastAsianWidthClass.Halfwidth)] // 'ア' HALFWIDTH KATAKANA A
|
|||
[InlineData(0x03B1u, EastAsianWidthClass.Ambiguous)] // 'α'
|
|||
[InlineData(0x200Bu, EastAsianWidthClass.Neutral)] // ZWSP
|
|||
public void GetEastAsianWidthClass_KnownCodepoints(uint codepoint, EastAsianWidthClass expected) |
|||
{ |
|||
Assert.Equal(expected, UnicodeData.GetEastAsianWidthClass(codepoint)); |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Regression test for the BiDi / GraphemeBreak / UnicodeData trie builders'
|
|||
/// reliance on the seeded default class sitting at int position 0 (caught at
|
|||
/// generation time by the ABI validator, but only this asserts the runtime
|
|||
/// behavior of an unassigned codepoint).
|
|||
/// </summary>
|
|||
[Fact] |
|||
public void UnassignedCodepoint_FallsBackToSeededDefaults() |
|||
{ |
|||
// U+0378 is an unassigned BMP code point (and has been for decades — stable choice).
|
|||
const uint unassigned = 0x0378u; |
|||
|
|||
// Default Bidi class for unassigned codepoints is LeftToRight (seeded at position 0).
|
|||
Assert.Equal(BidiClass.LeftToRight, UnicodeData.GetBiDiClass(unassigned)); |
|||
|
|||
// Default grapheme break class is Other (seeded at position 0).
|
|||
Assert.Equal(GraphemeBreakClass.Other, UnicodeData.GetGraphemeClusterBreak(unassigned)); |
|||
|
|||
// Default line break class is Unknown — set explicitly via initialValue in the
|
|||
// UnicodeData trie builder, not via seed position 0.
|
|||
Assert.Equal(LineBreakClass.Unknown, UnicodeData.GetLineBreakClass(unassigned)); |
|||
|
|||
// Default word break class is Other — also set explicitly in the generator's
|
|||
// post-pass that maps unset WordBreakClass to Other.
|
|||
Assert.Equal(WordBreakClass.Other, UnicodeData.GetWordBreakClass(unassigned)); |
|||
} |
|||
} |
|||
@ -0,0 +1,179 @@ |
|||
using Avalonia.Media.TextFormatting.Unicode; |
|||
using Xunit; |
|||
|
|||
namespace Avalonia.Base.UnitTests.Media.TextFormatting; |
|||
|
|||
/// <summary>
|
|||
/// Direct coverage for <see cref="UnicodeTrie"/> and <see cref="UnicodeTrieBuilder"/>.
|
|||
/// The production tries (UnicodeData, BiDi, GraphemeBreak, EastAsianWidth) are
|
|||
/// used to exercise the four branches of <see cref="UnicodeTrie.Get"/>; a small
|
|||
/// synthetic trie covers the error-value path (which is otherwise unreachable
|
|||
/// because the committed tries are generated with <c>errorValue == 0</c>) and the
|
|||
/// builder round-trip.
|
|||
/// </summary>
|
|||
public class UnicodeTrieTests |
|||
{ |
|||
[Theory] |
|||
[InlineData(0x0020u)] // ASCII space
|
|||
[InlineData(0x0061u)] // 'a'
|
|||
[InlineData(0x4E2Du)] // '中' (BMP CJK)
|
|||
[InlineData(0xFFFFu)] // last BMP non-surrogate
|
|||
public void Get_BmpNonSurrogate_ReturnsValueMatchingUnicodeDataWrapper(uint codepoint) |
|||
{ |
|||
// Walking the trie directly and re-applying the published shift/mask must
|
|||
// produce the same answer as the public UnicodeData wrapper. This catches
|
|||
// packing-layout drift between generator (writes packed bits) and
|
|||
// UnicodeData.Get* (reads packed bits).
|
|||
var packed = UnicodeDataTrie.Trie.Get(codepoint); |
|||
|
|||
var categoryFromTrie = (GeneralCategory)(packed & UnicodeData.CATEGORY_MASK); |
|||
var scriptFromTrie = (Script)((packed >> UnicodeData.SCRIPT_SHIFT) & UnicodeData.SCRIPT_MASK); |
|||
|
|||
Assert.Equal(UnicodeData.GetGeneralCategory(codepoint), categoryFromTrie); |
|||
Assert.Equal(UnicodeData.GetScript(codepoint), scriptFromTrie); |
|||
} |
|||
|
|||
[Theory] |
|||
[InlineData(0xD800u)] // first high surrogate
|
|||
[InlineData(0xDB00u)] // mid high surrogate
|
|||
[InlineData(0xDBFFu)] // last high surrogate
|
|||
[InlineData(0xDC00u)] // first low surrogate
|
|||
[InlineData(0xDFFFu)] // last low surrogate
|
|||
public void Get_SurrogateRange_ResolvesViaLscpIndex(uint codepoint) |
|||
{ |
|||
// Surrogates have a dedicated index region (LSCP_INDEX_2_OFFSET) in the
|
|||
// trie. The general category for every codepoint in this range is
|
|||
// Surrogate; this asserts the LSCP branch returns the correct row.
|
|||
Assert.Equal(GeneralCategory.Surrogate, UnicodeData.GetGeneralCategory(codepoint)); |
|||
} |
|||
|
|||
[Theory] |
|||
[InlineData(0x10000u)] // first supplementary
|
|||
[InlineData(0x1F600u)] // 😀
|
|||
[InlineData(0x2F800u)] // CJK compatibility supplement
|
|||
public void Get_Supplementary_BelowHighStart_ResolvesViaTwoLevelLookup(uint codepoint) |
|||
{ |
|||
// Just walking the trie and reapplying the published mask must match the
|
|||
// wrapper — same guarantee as the BMP test, but exercises the two-level
|
|||
// supplementary lookup branch.
|
|||
var packed = BiDiTrie.Trie.Get(codepoint); |
|||
var bidiFromTrie = (BidiClass)((packed >> UnicodeData.BIDICLASS_SHIFT) & UnicodeData.BIDICLASS_MASK); |
|||
|
|||
Assert.Equal(UnicodeData.GetBiDiClass(codepoint), bidiFromTrie); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Get_AtAndAboveHighStart_AllCodepointsShareFallback() |
|||
{ |
|||
// Every codepoint >= HighStart short-circuits to the trie's last data
|
|||
// block. The committed tries set HighStart at 0x100000, so all of Plane
|
|||
// 16 collapses to one fallback value — this is a compression artifact
|
|||
// of the trie format. The test verifies the SHAPE of that contract (one
|
|||
// value for the whole high range) rather than asserting any specific
|
|||
// per-codepoint property: callers querying Plane 16 should not rely on
|
|||
// PUA / Unassigned distinctions surviving the trie.
|
|||
var v100000 = UnicodeDataTrie.Trie.Get(0x100000u); |
|||
Assert.Equal(v100000, UnicodeDataTrie.Trie.Get(0x100001u)); |
|||
Assert.Equal(v100000, UnicodeDataTrie.Trie.Get(0x10FFFDu)); |
|||
Assert.Equal(v100000, UnicodeDataTrie.Trie.Get(0x10FFFFu)); |
|||
|
|||
var bidi100000 = BiDiTrie.Trie.Get(0x100000u); |
|||
Assert.Equal(bidi100000, BiDiTrie.Trie.Get(0x10FFFFu)); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Get_BeyondMaxCodepoint_IsHandledGracefullyByProductionTries() |
|||
{ |
|||
// The committed tries are generated with errorValue == 0, so codepoints
|
|||
// past 0x10FFFF return 0 (Other category, LeftToRight bidi, etc.). This
|
|||
// documents that contract; the synthetic-trie test below covers the
|
|||
// case where errorValue is non-zero.
|
|||
const uint beyondRange = 0x110000u; |
|||
|
|||
Assert.Equal(0u, UnicodeDataTrie.Trie.Get(beyondRange)); |
|||
Assert.Equal(0u, BiDiTrie.Trie.Get(beyondRange)); |
|||
Assert.Equal(0u, GraphemeBreakTrie.Trie.Get(beyondRange)); |
|||
Assert.Equal(0u, EastAsianWidthTrie.Trie.Get(beyondRange)); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Builder_RoundTripsSetValues() |
|||
{ |
|||
var builder = new UnicodeTrieBuilder(initialValue: 7u); |
|||
builder.Set(0x0061, 0xAA); |
|||
builder.Set(0x4E2D, 0xBB); |
|||
builder.Set(0x1F600, 0xCC); |
|||
|
|||
var trie = builder.Freeze(); |
|||
|
|||
Assert.Equal(0xAAu, trie.Get(0x0061)); |
|||
Assert.Equal(0xBBu, trie.Get(0x4E2D)); |
|||
Assert.Equal(0xCCu, trie.Get(0x1F600)); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Builder_SetRange_AppliesValueToEveryCodepointInRange() |
|||
{ |
|||
var builder = new UnicodeTrieBuilder(); |
|||
builder.SetRange(0x2000, 0x2010, 0x42); |
|||
|
|||
var trie = builder.Freeze(); |
|||
|
|||
for (uint cp = 0x2000; cp <= 0x2010; cp++) |
|||
{ |
|||
Assert.Equal(0x42u, trie.Get(cp)); |
|||
} |
|||
|
|||
// Just outside the range stays at the initial value (0 by default).
|
|||
Assert.Equal(0u, trie.Get(0x1FFF)); |
|||
Assert.Equal(0u, trie.Get(0x2011)); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Builder_UnassignedCodepoints_GetInitialValue() |
|||
{ |
|||
var builder = new UnicodeTrieBuilder(initialValue: 0xDEAD); |
|||
builder.Set(0x0061, 0xBEEF); |
|||
|
|||
var trie = builder.Freeze(); |
|||
|
|||
Assert.Equal(0xBEEFu, trie.Get(0x0061)); |
|||
Assert.Equal(0xDEADu, trie.Get(0x0062)); |
|||
Assert.Equal(0xDEADu, trie.Get(0x4E2D)); |
|||
Assert.Equal(0xDEADu, trie.Get(0x1F600)); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Get_OutOfRange_ReturnsConfiguredErrorValue() |
|||
{ |
|||
// Build with a non-zero errorValue so the "> 0x10FFFF" branch produces a
|
|||
// distinguishable result. This is the only feasible test of that branch —
|
|||
// the committed tries all use errorValue == 0 which collides with the
|
|||
// happy-path zero value.
|
|||
var builder = new UnicodeTrieBuilder(initialValue: 0u, errorValue: 0xFFFFu); |
|||
builder.Set(0x0061, 0x11); |
|||
|
|||
var trie = builder.Freeze(); |
|||
|
|||
Assert.Equal(0xFFFFu, trie.Get(0x110000u)); |
|||
Assert.Equal(0xFFFFu, trie.Get(0xFFFFFFFFu)); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Get_AtAndAboveHighStart_OnSyntheticTrie_UsesHighFallback() |
|||
{ |
|||
// SetRange across a huge supplementary span forces the builder to allocate
|
|||
// a high block. Codepoints at and above the resulting HighStart should
|
|||
// return the value that covers the high range.
|
|||
var builder = new UnicodeTrieBuilder(initialValue: 0u); |
|||
builder.SetRange(0x80000, 0x10FFFF, 0x55); |
|||
|
|||
var trie = builder.Freeze(); |
|||
|
|||
Assert.Equal(0x55u, trie.Get(0x100000u)); |
|||
Assert.Equal(0x55u, trie.Get(0x10FFFFu)); |
|||
|
|||
// Below the high range — still the initial value.
|
|||
Assert.Equal(0u, trie.Get(0x1000u)); |
|||
} |
|||
} |
|||
@ -0,0 +1,223 @@ |
|||
using System; |
|||
using System.Text; |
|||
using Avalonia.Media.TextFormatting.Unicode; |
|||
using BenchmarkDotNet.Attributes; |
|||
|
|||
namespace Avalonia.Benchmarks.Text; |
|||
|
|||
/// <summary>
|
|||
/// End-to-end benchmarks for the <see cref="Codepoint"/> hot path: surrogate
|
|||
/// decoding via <see cref="Codepoint.ReadAt"/>, property accessors that
|
|||
/// perform Unicode data lookups via generated static <see cref="UnicodeTrie"/>
|
|||
/// instances, and a representative "all properties" pass that approximates
|
|||
/// what a layout pipeline traversal pays per codepoint.
|
|||
/// </summary>
|
|||
[MemoryDiagnoser] |
|||
[MinIterationTime(150)] |
|||
[MaxWarmupCount(15)] |
|||
public class CodepointBenchmark |
|||
{ |
|||
public enum TextDistribution |
|||
{ |
|||
/// <summary>Pure ASCII (no surrogate decoding cost).</summary>
|
|||
Ascii, |
|||
|
|||
/// <summary>BMP mix of Latin, Cyrillic, Greek, CJK (one UTF-16 unit per scalar).</summary>
|
|||
Bmp, |
|||
|
|||
/// <summary>Mix of supplementary plane scalars (math, emoji, CJK Ext B) — every other
|
|||
/// scalar requires surrogate-pair decoding.</summary>
|
|||
Supplementary, |
|||
} |
|||
|
|||
private const int ScalarCount = 1024; |
|||
|
|||
private string _text = string.Empty; |
|||
|
|||
[Params(TextDistribution.Ascii, TextDistribution.Bmp, TextDistribution.Supplementary)] |
|||
public TextDistribution Distribution { get; set; } |
|||
|
|||
[GlobalSetup] |
|||
public void Setup() |
|||
{ |
|||
var rng = new Random(42); |
|||
var sb = new StringBuilder(ScalarCount * 2); |
|||
|
|||
for (var i = 0; i < ScalarCount; i++) |
|||
{ |
|||
var scalar = Distribution switch |
|||
{ |
|||
TextDistribution.Ascii => (uint)rng.Next(0x20, 0x7F), |
|||
TextDistribution.Bmp => SampleBmpScalar(rng), |
|||
TextDistribution.Supplementary => SampleSupplementaryScalar(rng), |
|||
_ => (uint)'a', |
|||
}; |
|||
|
|||
sb.Append(char.ConvertFromUtf32((int)scalar)); |
|||
} |
|||
|
|||
_text = sb.ToString(); |
|||
} |
|||
|
|||
private static uint SampleBmpScalar(Random rng) |
|||
{ |
|||
// Pick from a spread of common BMP ranges; skip the surrogate region.
|
|||
var bucket = rng.Next(4); |
|||
return bucket switch |
|||
{ |
|||
0 => (uint)rng.Next(0x0020, 0x007F), // Latin
|
|||
1 => (uint)rng.Next(0x0400, 0x0500), // Cyrillic
|
|||
2 => (uint)rng.Next(0x0370, 0x0400), // Greek
|
|||
_ => (uint)rng.Next(0x4E00, 0x9FFF), // CJK Unified Ideographs
|
|||
}; |
|||
} |
|||
|
|||
private static uint SampleSupplementaryScalar(Random rng) |
|||
{ |
|||
// Alternate BMP and supplementary so the benchmark exercises both
|
|||
// branches of Codepoint.ReadAt — pure-supplementary text isn't
|
|||
// representative of any real layout workload.
|
|||
if ((rng.Next() & 1) == 0) |
|||
{ |
|||
return SampleBmpScalar(rng); |
|||
} |
|||
|
|||
var bucket = rng.Next(3); |
|||
return bucket switch |
|||
{ |
|||
0 => (uint)rng.Next(0x1F300, 0x1F600), // emoji
|
|||
1 => (uint)rng.Next(0x20000, 0x2A6DF), // CJK Ext B
|
|||
_ => (uint)rng.Next(0x1D400, 0x1D800), // math alphanumerics
|
|||
}; |
|||
} |
|||
|
|||
[Benchmark] |
|||
public uint ReadAt_Sequence() |
|||
{ |
|||
var span = _text.AsSpan(); |
|||
var sum = 0u; |
|||
|
|||
var i = 0; |
|||
while (i < span.Length) |
|||
{ |
|||
sum += Codepoint.ReadAt(span, i, out var count).Value; |
|||
i += count; |
|||
} |
|||
|
|||
return sum; |
|||
} |
|||
|
|||
[Benchmark] |
|||
public uint CodepointEnumerator_Sequence() |
|||
{ |
|||
var enumerator = new CodepointEnumerator(_text.AsSpan()); |
|||
var sum = 0u; |
|||
|
|||
while (enumerator.MoveNext(out var cp)) |
|||
{ |
|||
sum += cp.Value; |
|||
} |
|||
|
|||
return sum; |
|||
} |
|||
|
|||
[Benchmark] |
|||
public int Sequence_GeneralCategory() |
|||
{ |
|||
var span = _text.AsSpan(); |
|||
var sum = 0; |
|||
|
|||
var i = 0; |
|||
while (i < span.Length) |
|||
{ |
|||
var cp = Codepoint.ReadAt(span, i, out var count); |
|||
sum += (int)cp.GeneralCategory; |
|||
i += count; |
|||
} |
|||
|
|||
return sum; |
|||
} |
|||
|
|||
[Benchmark] |
|||
public int Sequence_Script() |
|||
{ |
|||
var span = _text.AsSpan(); |
|||
var sum = 0; |
|||
|
|||
var i = 0; |
|||
while (i < span.Length) |
|||
{ |
|||
var cp = Codepoint.ReadAt(span, i, out var count); |
|||
sum += (int)cp.Script; |
|||
i += count; |
|||
} |
|||
|
|||
return sum; |
|||
} |
|||
|
|||
[Benchmark] |
|||
public int Sequence_BiDiClass() |
|||
{ |
|||
var span = _text.AsSpan(); |
|||
var sum = 0; |
|||
|
|||
var i = 0; |
|||
while (i < span.Length) |
|||
{ |
|||
var cp = Codepoint.ReadAt(span, i, out var count); |
|||
sum += (int)cp.BiDiClass; |
|||
i += count; |
|||
} |
|||
|
|||
return sum; |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Worst-case representative of a layout pipeline pass that needs every
|
|||
/// property per codepoint. Seven trie lookups per scalar (Category +
|
|||
/// Script + BiDi + LineBreak + WordBreak + GraphemeBreak + EastAsianWidth).
|
|||
/// This is the headline number for the "should we merge tries?" question.
|
|||
/// </summary>
|
|||
[Benchmark] |
|||
public int Sequence_AllProperties() |
|||
{ |
|||
var span = _text.AsSpan(); |
|||
var sum = 0; |
|||
|
|||
var i = 0; |
|||
while (i < span.Length) |
|||
{ |
|||
var cp = Codepoint.ReadAt(span, i, out var count); |
|||
sum += (int)cp.GeneralCategory; |
|||
sum += (int)cp.Script; |
|||
sum += (int)cp.BiDiClass; |
|||
sum += (int)cp.LineBreakClass; |
|||
sum += (int)cp.WordBreakClass; |
|||
sum += (int)cp.GraphemeBreakClass; |
|||
sum += (int)cp.EastAsianWidthClass; |
|||
i += count; |
|||
} |
|||
|
|||
return sum; |
|||
} |
|||
|
|||
[Benchmark] |
|||
public int TryGetPairedBracket_Sequence() |
|||
{ |
|||
var span = _text.AsSpan(); |
|||
var paired = 0; |
|||
|
|||
var i = 0; |
|||
while (i < span.Length) |
|||
{ |
|||
var cp = Codepoint.ReadAt(span, i, out var count); |
|||
if (cp.TryGetPairedBracket(out _)) |
|||
{ |
|||
paired++; |
|||
} |
|||
i += count; |
|||
} |
|||
|
|||
return paired; |
|||
} |
|||
} |
|||
@ -0,0 +1,149 @@ |
|||
#nullable enable |
|||
using System; |
|||
using System.Linq; |
|||
using Avalonia.Media; |
|||
using Avalonia.Media.TextFormatting; |
|||
using Avalonia.UnitTests; |
|||
using BenchmarkDotNet.Attributes; |
|||
|
|||
namespace Avalonia.Benchmarks.Text; |
|||
|
|||
/// <summary>
|
|||
/// Micro-benchmark for the <see cref="ShapedBuffer"/> cluster-cache hot paths
|
|||
/// (<see cref="ShapedBuffer.TotalGlyphAdvance"/>, <see cref="ShapedBuffer.MeasureCharactersThatFit"/>,
|
|||
/// 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
|
|||
/// different code paths. Pair with <c>--memory</c> to see allocation impact of
|
|||
/// the <see cref="System.Buffers.ArrayPool{T}"/>-backed cluster cache.
|
|||
/// </summary>
|
|||
[MemoryDiagnoser] |
|||
[MinIterationTime(150)] |
|||
[MaxWarmupCount(10)] |
|||
public class ShapedBufferOps : IDisposable |
|||
{ |
|||
private readonly IDisposable _app; |
|||
private readonly TextShaperOptions _options; |
|||
private string _text = string.Empty; |
|||
private ShapedBuffer? _primed; |
|||
|
|||
public ShapedBufferOps() |
|||
{ |
|||
_app = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface); |
|||
_options = new TextShaperOptions(Typeface.Default.GlyphTypeface); |
|||
} |
|||
|
|||
[Params(8, 32, 128, 512, 2048)] |
|||
public int GlyphCount { get; set; } |
|||
|
|||
[GlobalSetup] |
|||
public void Setup() |
|||
{ |
|||
// ASCII text → simple-mode fast path (one glyph == one cluster == one char).
|
|||
var rng = new Random(GlyphCount); |
|||
const string alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 "; |
|||
_text = new string(Enumerable.Range(0, GlyphCount).Select(_ => alphabet[rng.Next(alphabet.Length)]).ToArray()); |
|||
|
|||
_primed?.Dispose(); |
|||
_primed = TextShaper.Current.ShapeText(_text, _options); |
|||
// Prime the cluster cache once so the per-call benchmarks measure only the lookup cost.
|
|||
_ = _primed.TotalGlyphAdvance; |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Cost of shaping + building the cluster cache from scratch. Captures both
|
|||
/// the HarfBuzz call and the prefix-sum allocation/initialisation.
|
|||
/// </summary>
|
|||
[Benchmark] |
|||
public double ShapeAndPrime() |
|||
{ |
|||
using var buffer = TextShaper.Current.ShapeText(_text, _options); |
|||
return buffer.TotalGlyphAdvance; |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Repeated <see cref="ShapedBuffer.TotalGlyphAdvance"/> on a primed buffer.
|
|||
/// Should be ~O(1) per call regardless of glyph count.
|
|||
/// </summary>
|
|||
[Benchmark] |
|||
public double TotalAdvance_Cached() |
|||
{ |
|||
var sum = 0d; |
|||
for (var i = 0; i < 64; i++) |
|||
{ |
|||
sum += _primed!.TotalGlyphAdvance; |
|||
} |
|||
return sum; |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Repeated <see cref="ShapedBuffer.MeasureCharactersThatFit"/> targeting
|
|||
/// half the buffer's total width. Exercises the binary search across the
|
|||
/// prefix table.
|
|||
/// </summary>
|
|||
[Benchmark] |
|||
public int MeasureFit_Cached() |
|||
{ |
|||
var halfWidth = _primed!.TotalGlyphAdvance * 0.5; |
|||
var sum = 0; |
|||
for (var i = 0; i < 64; i++) |
|||
{ |
|||
sum += _primed.MeasureCharactersThatFit(halfWidth, out _); |
|||
} |
|||
return sum; |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Splits the primed buffer at three positions and queries the resulting
|
|||
/// children — the workload the shared cluster cache is designed for. Each
|
|||
/// child should reuse the parent cache in O(1)/O(log) instead of rebuilding.
|
|||
/// </summary>
|
|||
[Benchmark] |
|||
public double SplitChain() |
|||
{ |
|||
var quarter = _primed!.Text.Length / 4; |
|||
|
|||
ShapedBuffer? first = null; |
|||
ShapedBuffer? second = null; |
|||
ShapedBuffer? firstFirst = null; |
|||
ShapedBuffer? firstSecond = null; |
|||
ShapedBuffer? secondFirst = null; |
|||
ShapedBuffer? secondSecond = null; |
|||
|
|||
try |
|||
{ |
|||
var halves = _primed.Split(quarter * 2); |
|||
first = halves.First!; |
|||
second = halves.Second!; |
|||
var firstSplit = first.Split(quarter); |
|||
var secondSplit = second.Split(quarter); |
|||
|
|||
firstFirst = firstSplit.First; |
|||
firstSecond = firstSplit.Second; |
|||
secondFirst = secondSplit.First; |
|||
secondSecond = secondSplit.Second; |
|||
|
|||
var total = (firstFirst?.TotalGlyphAdvance ?? 0) |
|||
+ (firstSecond?.TotalGlyphAdvance ?? 0) |
|||
+ (secondFirst?.TotalGlyphAdvance ?? 0) |
|||
+ (secondSecond?.TotalGlyphAdvance ?? 0); |
|||
|
|||
return total; |
|||
} |
|||
finally |
|||
{ |
|||
firstFirst?.Dispose(); |
|||
firstSecond?.Dispose(); |
|||
secondFirst?.Dispose(); |
|||
secondSecond?.Dispose(); |
|||
first?.Dispose(); |
|||
second?.Dispose(); |
|||
} |
|||
} |
|||
|
|||
public void Dispose() |
|||
{ |
|||
_primed?.Dispose(); |
|||
_app.Dispose(); |
|||
} |
|||
} |
|||
@ -0,0 +1,52 @@ |
|||
using System; |
|||
using Avalonia.Media; |
|||
using Avalonia.Media.TextFormatting; |
|||
using Avalonia.UnitTests; |
|||
using BenchmarkDotNet.Attributes; |
|||
|
|||
namespace Avalonia.Benchmarks.Text; |
|||
|
|||
/// <summary>
|
|||
/// Single-variant benchmark used as a profiling target. Pins the most expensive
|
|||
/// realistic case from <see cref="HugeTextLayout"/> (Wrap=true, Trim=false on
|
|||
/// the emoji block) so BDN's EventPipe profiler captures only this one run.
|
|||
/// Not part of the regular perf coverage — leave excluded from normal sweeps
|
|||
/// or run with an explicit filter.
|
|||
/// </summary>
|
|||
[MemoryDiagnoser] |
|||
[MinIterationTime(150)] |
|||
[MaxWarmupCount(15)] |
|||
public class TextLayoutProfile : IDisposable |
|||
{ |
|||
private readonly IDisposable _app; |
|||
// Real Avalonia consumers (e.g. TextBlock) share a TextRunCache across
|
|||
// re-layouts of the same string, so shaping cost amortises. The wrap-path
|
|||
// perf doc relies on this benchmark to mirror that scenario; without the
|
|||
// cache shaping dominates and obscures wrap-loop changes.
|
|||
private readonly TextRunCache _runCache = new(); |
|||
|
|||
public TextLayoutProfile() |
|||
{ |
|||
_app = UnitTestApplication.Start(TestServices.StyledWindow); |
|||
} |
|||
|
|||
private const string Emojis = HugeTextLayout.EmojisText; |
|||
|
|||
[Benchmark] |
|||
public TextLayout BuildEmojisWrapped() |
|||
{ |
|||
var layout = new TextLayout( |
|||
Emojis, |
|||
Typeface.Default, |
|||
12d, |
|||
Brushes.Black, |
|||
maxWidth: 120, |
|||
textTrimming: TextTrimming.None, |
|||
textWrapping: TextWrapping.WrapWithOverflow, |
|||
textRunCache: _runCache); |
|||
layout.Dispose(); |
|||
return layout; |
|||
} |
|||
|
|||
public void Dispose() => _app?.Dispose(); |
|||
} |
|||
@ -0,0 +1,132 @@ |
|||
using System; |
|||
using System.Text; |
|||
using Avalonia.Media.TextFormatting.Unicode; |
|||
using BenchmarkDotNet.Attributes; |
|||
|
|||
namespace Avalonia.Benchmarks.Text; |
|||
|
|||
/// <summary>
|
|||
/// End-to-end iteration cost for the three Unicode break enumerators. Their
|
|||
/// inner loops call into the trie-backed property getters, so this benchmark
|
|||
/// captures both the trie lookup cost and the per-segment algorithmic overhead
|
|||
/// in a shape that mirrors what text layout pays per string.
|
|||
/// </summary>
|
|||
[MemoryDiagnoser] |
|||
[MinIterationTime(150)] |
|||
[MaxWarmupCount(15)] |
|||
public class UnicodeBreakEnumeratorBenchmark |
|||
{ |
|||
public enum TextDistribution |
|||
{ |
|||
/// <summary>Pure ASCII — no surrogate decoding cost.</summary>
|
|||
Ascii, |
|||
|
|||
/// <summary>BMP mix of Latin, Cyrillic, Greek, CJK.</summary>
|
|||
Bmp, |
|||
|
|||
/// <summary>Mix of BMP and supplementary plane scalars.</summary>
|
|||
Supplementary, |
|||
} |
|||
|
|||
private const int ScalarCount = 1024; |
|||
|
|||
private string _text = string.Empty; |
|||
|
|||
[Params(TextDistribution.Ascii, TextDistribution.Bmp, TextDistribution.Supplementary)] |
|||
public TextDistribution Distribution { get; set; } |
|||
|
|||
[GlobalSetup] |
|||
public void Setup() |
|||
{ |
|||
// The fixture intentionally mirrors CodepointBenchmark's distributions so
|
|||
// the two benchmark suites are comparable: any added cost above the raw
|
|||
// codepoint sequence is the break-algorithm overhead.
|
|||
var rng = new Random(42); |
|||
var sb = new StringBuilder(ScalarCount * 2); |
|||
|
|||
for (var i = 0; i < ScalarCount; i++) |
|||
{ |
|||
var scalar = Distribution switch |
|||
{ |
|||
TextDistribution.Ascii => (uint)rng.Next(0x20, 0x7F), |
|||
TextDistribution.Bmp => SampleBmpScalar(rng), |
|||
TextDistribution.Supplementary => SampleSupplementaryScalar(rng), |
|||
_ => (uint)'a', |
|||
}; |
|||
|
|||
sb.Append(char.ConvertFromUtf32((int)scalar)); |
|||
} |
|||
|
|||
_text = sb.ToString(); |
|||
} |
|||
|
|||
private static uint SampleBmpScalar(Random rng) |
|||
{ |
|||
var bucket = rng.Next(4); |
|||
return bucket switch |
|||
{ |
|||
0 => (uint)rng.Next(0x0020, 0x007F), // Latin
|
|||
1 => (uint)rng.Next(0x0400, 0x0500), // Cyrillic
|
|||
2 => (uint)rng.Next(0x0370, 0x0400), // Greek
|
|||
_ => (uint)rng.Next(0x4E00, 0x9FFF), // CJK Unified Ideographs
|
|||
}; |
|||
} |
|||
|
|||
private static uint SampleSupplementaryScalar(Random rng) |
|||
{ |
|||
if ((rng.Next() & 1) == 0) |
|||
{ |
|||
return SampleBmpScalar(rng); |
|||
} |
|||
|
|||
var bucket = rng.Next(3); |
|||
return bucket switch |
|||
{ |
|||
0 => (uint)rng.Next(0x1F300, 0x1F600), // emoji
|
|||
1 => (uint)rng.Next(0x20000, 0x2A6DF), // CJK Ext B
|
|||
_ => (uint)rng.Next(0x1D400, 0x1D800), // math alphanumerics
|
|||
}; |
|||
} |
|||
|
|||
[Benchmark] |
|||
public int LineBreakEnumerator_Sequence() |
|||
{ |
|||
var enumerator = new LineBreakEnumerator(_text.AsSpan()); |
|||
var count = 0; |
|||
|
|||
while (enumerator.MoveNext(out _)) |
|||
{ |
|||
count++; |
|||
} |
|||
|
|||
return count; |
|||
} |
|||
|
|||
[Benchmark] |
|||
public int WordBreakEnumerator_Sequence() |
|||
{ |
|||
var enumerator = new WordBreakEnumerator(_text.AsSpan()); |
|||
var count = 0; |
|||
|
|||
while (enumerator.MoveNext(out _)) |
|||
{ |
|||
count++; |
|||
} |
|||
|
|||
return count; |
|||
} |
|||
|
|||
[Benchmark] |
|||
public int GraphemeEnumerator_Sequence() |
|||
{ |
|||
var enumerator = new GraphemeEnumerator(_text.AsSpan()); |
|||
var count = 0; |
|||
|
|||
while (enumerator.MoveNext(out _)) |
|||
{ |
|||
count++; |
|||
} |
|||
|
|||
return count; |
|||
} |
|||
} |
|||
@ -0,0 +1,153 @@ |
|||
using System; |
|||
using Avalonia.Media.TextFormatting.Unicode; |
|||
using BenchmarkDotNet.Attributes; |
|||
|
|||
namespace Avalonia.Benchmarks.Text; |
|||
|
|||
/// <summary>
|
|||
/// Raw <see cref="UnicodeTrie.Get"/> throughput across the four committed tries
|
|||
/// (UnicodeData, BiDi, GraphemeBreak, EastAsianWidth) and three codepoint
|
|||
/// distributions (ASCII, BMP, supplementary). Bypasses surrogate decoding so
|
|||
/// the numbers reflect only the trie walk cost — useful as a regression target
|
|||
/// when changing trie packing, branch ordering, or builder layout.
|
|||
/// </summary>
|
|||
[MemoryDiagnoser] |
|||
[MinIterationTime(150)] |
|||
[MaxWarmupCount(15)] |
|||
public class UnicodeTrieBenchmark |
|||
{ |
|||
public enum CodepointDistribution |
|||
{ |
|||
/// <summary>Printable ASCII (0x20..0x7E) — common BMP-non-surrogate path.</summary>
|
|||
Ascii, |
|||
|
|||
/// <summary>Full BMP excluding the surrogate range (0..0xD7FF, 0xE000..0xFFFF).</summary>
|
|||
Bmp, |
|||
|
|||
/// <summary>Supplementary plane (0x10000..0x10FFFF) — exercises two-level lookup.</summary>
|
|||
Supplementary, |
|||
} |
|||
|
|||
private const int CodepointCount = 4096; |
|||
|
|||
private uint[] _codepoints = []; |
|||
|
|||
[Params(CodepointDistribution.Ascii, CodepointDistribution.Bmp, CodepointDistribution.Supplementary)] |
|||
public CodepointDistribution Distribution { get; set; } |
|||
|
|||
[GlobalSetup] |
|||
public void Setup() |
|||
{ |
|||
// Fixed seed for reproducibility — numbers across CI runs should be
|
|||
// comparable in the absence of generator / trie-walk changes.
|
|||
var rng = new Random(42); |
|||
_codepoints = new uint[CodepointCount]; |
|||
|
|||
for (var i = 0; i < CodepointCount; i++) |
|||
{ |
|||
_codepoints[i] = Distribution switch |
|||
{ |
|||
CodepointDistribution.Ascii => (uint)rng.Next(0x20, 0x7F), |
|||
CodepointDistribution.Bmp => SampleBmp(rng), |
|||
CodepointDistribution.Supplementary => (uint)rng.Next(0x10000, 0x110000), |
|||
_ => 0, |
|||
}; |
|||
} |
|||
} |
|||
|
|||
private static uint SampleBmp(Random rng) |
|||
{ |
|||
// Avoid the surrogate range — supplementary callers don't hit it in
|
|||
// practice and we cover it via the dedicated codepoint fixture below.
|
|||
var v = (uint)rng.Next(0, 0xFFFE); |
|||
return v is >= 0xD800 and <= 0xDFFF ? 0xE000 : v; |
|||
} |
|||
|
|||
[Benchmark] |
|||
public uint Get_UnicodeData() |
|||
{ |
|||
var trie = UnicodeDataTrie.Trie; |
|||
var codepoints = _codepoints; |
|||
var sum = 0u; |
|||
|
|||
for (var i = 0; i < codepoints.Length; i++) |
|||
{ |
|||
sum += trie.Get(codepoints[i]); |
|||
} |
|||
|
|||
return sum; |
|||
} |
|||
|
|||
[Benchmark] |
|||
public uint Get_BiDi() |
|||
{ |
|||
var trie = BiDiTrie.Trie; |
|||
var codepoints = _codepoints; |
|||
var sum = 0u; |
|||
|
|||
for (var i = 0; i < codepoints.Length; i++) |
|||
{ |
|||
sum += trie.Get(codepoints[i]); |
|||
} |
|||
|
|||
return sum; |
|||
} |
|||
|
|||
[Benchmark] |
|||
public uint Get_GraphemeBreak() |
|||
{ |
|||
var trie = GraphemeBreakTrie.Trie; |
|||
var codepoints = _codepoints; |
|||
var sum = 0u; |
|||
|
|||
for (var i = 0; i < codepoints.Length; i++) |
|||
{ |
|||
sum += trie.Get(codepoints[i]); |
|||
} |
|||
|
|||
return sum; |
|||
} |
|||
|
|||
[Benchmark] |
|||
public uint Get_EastAsianWidth() |
|||
{ |
|||
var trie = EastAsianWidthTrie.Trie; |
|||
var codepoints = _codepoints; |
|||
var sum = 0u; |
|||
|
|||
for (var i = 0; i < codepoints.Length; i++) |
|||
{ |
|||
sum += trie.Get(codepoints[i]); |
|||
} |
|||
|
|||
return sum; |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Worst-case "every property" pass — one <see cref="UnicodeTrie.Get"/> call
|
|||
/// per trie per codepoint. Matches what a layout pipeline pass that needs
|
|||
/// every property would pay if the four tries are kept separate. Establishes
|
|||
/// a baseline for any future "merge packed properties" experiment.
|
|||
/// </summary>
|
|||
[Benchmark] |
|||
public uint Get_AllTriesPerCodepoint() |
|||
{ |
|||
var unicodeData = UnicodeDataTrie.Trie; |
|||
var biDi = BiDiTrie.Trie; |
|||
var grapheme = GraphemeBreakTrie.Trie; |
|||
var eaw = EastAsianWidthTrie.Trie; |
|||
var codepoints = _codepoints; |
|||
var sum = 0u; |
|||
|
|||
for (var i = 0; i < codepoints.Length; i++) |
|||
{ |
|||
var cp = codepoints[i]; |
|||
sum += unicodeData.Get(cp); |
|||
sum += biDi.Get(cp); |
|||
sum += grapheme.Get(cp); |
|||
sum += eaw.Get(cp); |
|||
} |
|||
|
|||
return sum; |
|||
} |
|||
} |
|||
@ -0,0 +1,297 @@ |
|||
using System; |
|||
using System.Globalization; |
|||
using Avalonia.Media; |
|||
using Avalonia.Media.TextFormatting; |
|||
using Avalonia.Skia; |
|||
using Avalonia.UnitTests; |
|||
using Xunit; |
|||
|
|||
namespace Avalonia.Skia.UnitTests.Media.TextFormatting |
|||
{ |
|||
/// <summary>
|
|||
/// Exercises the ref-counted shared storage and the per-write generation
|
|||
/// counter that <see cref="ShapedBuffer.Split"/> and
|
|||
/// <see cref="ShapedBuffer.WithBidiLevel"/> rely on. Each test starts from
|
|||
/// a freshly shaped buffer (so the glyph and cluster arrays are pool-rented
|
|||
/// and ref-counted) and checks that:
|
|||
/// <list type="bullet">
|
|||
/// <item>aliases keep working after their source is disposed,</item>
|
|||
/// <item><see cref="ShapedBuffer.Dispose"/> is idempotent,</item>
|
|||
/// <item>indexer mutations propagate to siblings via the generation bump
|
|||
/// (i.e. nobody is left with a stale cluster cache).</item>
|
|||
/// </list>
|
|||
/// </summary>
|
|||
public class ShapedBufferSharedStorageTests |
|||
{ |
|||
private const string AsciiText = "The quick brown fox"; |
|||
|
|||
[Fact] |
|||
public void Dispose_Is_Idempotent() |
|||
{ |
|||
using (Start()) |
|||
{ |
|||
var buffer = ShapeAscii(AsciiText); |
|||
_ = buffer.TotalGlyphAdvance; // prime cluster cache so refs are non-null.
|
|||
|
|||
buffer.Dispose(); |
|||
buffer.Dispose(); |
|||
buffer.Dispose(); |
|||
} |
|||
} |
|||
|
|||
[Fact] |
|||
public void Split_Children_Survive_Parent_Disposal() |
|||
{ |
|||
using (Start()) |
|||
{ |
|||
var parent = ShapeAscii(AsciiText); |
|||
var totalBefore = parent.TotalGlyphAdvance; |
|||
|
|||
var split = parent.Split(parent.Text.Length / 2); |
|||
var first = split.First!; |
|||
var second = split.Second!; |
|||
|
|||
// Prime sibling caches via the parent's cluster-cache reference
|
|||
// (children inherit the parent's prefix sums by ref).
|
|||
_ = first.TotalGlyphAdvance; |
|||
_ = second.TotalGlyphAdvance; |
|||
|
|||
// Release the parent's references first; the ref-counted holders
|
|||
// must keep the pool arrays alive for the surviving children.
|
|||
parent.Dispose(); |
|||
|
|||
Assert.Equal(totalBefore, first.TotalGlyphAdvance + second.TotalGlyphAdvance, 3); |
|||
Assert.Equal(parent.Text.Length / 2, first.Text.Length); |
|||
Assert.True(first.Length > 0); |
|||
Assert.True(second.Length > 0); |
|||
|
|||
first.Dispose(); |
|||
second.Dispose(); |
|||
} |
|||
} |
|||
|
|||
[Fact] |
|||
public void Split_Parent_Survives_Children_Disposal() |
|||
{ |
|||
using (Start()) |
|||
{ |
|||
using var parent = ShapeAscii(AsciiText); |
|||
var totalBefore = parent.TotalGlyphAdvance; |
|||
|
|||
var split = parent.Split(parent.Text.Length / 2); |
|||
split.First!.Dispose(); |
|||
split.Second!.Dispose(); |
|||
|
|||
// Parent's own refs must still hold the pool arrays alive.
|
|||
Assert.Equal(totalBefore, parent.TotalGlyphAdvance, 6); |
|||
} |
|||
} |
|||
|
|||
[Fact] |
|||
public void WithBidiLevel_Alias_Survives_Original_Disposal() |
|||
{ |
|||
using (Start()) |
|||
{ |
|||
var original = ShapeAscii(AsciiText); |
|||
var totalBefore = original.TotalGlyphAdvance; |
|||
Assert.Equal(0, original.BidiLevel); |
|||
|
|||
using var alias = original.WithBidiLevel(2); |
|||
|
|||
original.Dispose(); |
|||
|
|||
Assert.Equal(totalBefore, alias.TotalGlyphAdvance, 6); |
|||
Assert.Equal(2, alias.BidiLevel); |
|||
Assert.Equal(original.Text.Length, alias.Text.Length); |
|||
} |
|||
} |
|||
|
|||
[Fact] |
|||
public void WithBidiLevel_Returns_Same_Instance_When_Level_Matches() |
|||
{ |
|||
using (Start()) |
|||
{ |
|||
using var original = ShapeAscii(AsciiText); |
|||
var alias = original.WithBidiLevel(original.BidiLevel); |
|||
Assert.Same(original, alias); |
|||
} |
|||
} |
|||
|
|||
[Fact] |
|||
public void IndexerMutation_Invalidates_Own_Cluster_Cache() |
|||
{ |
|||
using (Start()) |
|||
{ |
|||
using var buffer = ShapeAscii(AsciiText); |
|||
|
|||
var advanceBefore = buffer.TotalGlyphAdvance; |
|||
var original = buffer[0]; |
|||
|
|||
// Mutate the leading glyph's advance via the indexer setter.
|
|||
const double delta = 50d; |
|||
buffer[0] = new GlyphInfo( |
|||
original.GlyphIndex, original.GlyphCluster, |
|||
original.GlyphAdvance + delta, original.GlyphOffset); |
|||
|
|||
// The cache must be rebuilt against the new glyph data.
|
|||
Assert.Equal(advanceBefore + delta, buffer.TotalGlyphAdvance, 3); |
|||
} |
|||
} |
|||
|
|||
[Fact] |
|||
public void IndexerMutation_On_Parent_Invalidates_Sibling_Caches_After_Split() |
|||
{ |
|||
using (Start()) |
|||
{ |
|||
using var parent = ShapeAscii(AsciiText); |
|||
|
|||
var splitIndex = parent.Text.Length / 2; |
|||
var split = parent.Split(splitIndex); |
|||
using var first = split.First!; |
|||
using var second = split.Second!; |
|||
|
|||
// Prime both children's views of the inherited cluster cache.
|
|||
var firstBefore = first.TotalGlyphAdvance; |
|||
var secondBefore = second.TotalGlyphAdvance; |
|||
|
|||
// Mutate parent[0]: this lives in the first child's slice but
|
|||
// bumps the generation counter on the shared glyph holder so
|
|||
// the second child also sees the change (and rebuilds if needed).
|
|||
const double delta = 25d; |
|||
var first0 = parent[0]; |
|||
parent[0] = new GlyphInfo( |
|||
first0.GlyphIndex, first0.GlyphCluster, |
|||
first0.GlyphAdvance + delta, first0.GlyphOffset); |
|||
|
|||
Assert.Equal(firstBefore + delta, first.TotalGlyphAdvance, 3); |
|||
// Second child's range doesn't include glyph 0 so its advance is unchanged,
|
|||
// but its cache must still have been invalidated/rebuilt without error.
|
|||
Assert.Equal(secondBefore, second.TotalGlyphAdvance, 3); |
|||
} |
|||
} |
|||
|
|||
[Fact] |
|||
public void IndexerMutation_On_Child_Invalidates_Parent_And_Sibling() |
|||
{ |
|||
using (Start()) |
|||
{ |
|||
using var parent = ShapeAscii(AsciiText); |
|||
|
|||
var splitIndex = parent.Text.Length / 2; |
|||
var split = parent.Split(splitIndex); |
|||
using var first = split.First!; |
|||
using var second = split.Second!; |
|||
|
|||
var parentBefore = parent.TotalGlyphAdvance; |
|||
_ = first.TotalGlyphAdvance; |
|||
_ = second.TotalGlyphAdvance; |
|||
|
|||
const double delta = 17d; |
|||
var glyph = first[0]; |
|||
first[0] = new GlyphInfo( |
|||
glyph.GlyphIndex, glyph.GlyphCluster, |
|||
glyph.GlyphAdvance + delta, glyph.GlyphOffset); |
|||
|
|||
Assert.Equal(parentBefore + delta, parent.TotalGlyphAdvance, 3); |
|||
} |
|||
} |
|||
|
|||
[Fact] |
|||
public void IndexerMutation_Invalidates_WithBidiLevel_Alias_Cache() |
|||
{ |
|||
using (Start()) |
|||
{ |
|||
using var original = ShapeAscii(AsciiText); |
|||
using var alias = original.WithBidiLevel(2); |
|||
|
|||
var aliasBefore = alias.TotalGlyphAdvance; |
|||
|
|||
const double delta = 33d; |
|||
var glyph = original[0]; |
|||
original[0] = new GlyphInfo( |
|||
glyph.GlyphIndex, glyph.GlyphCluster, |
|||
glyph.GlyphAdvance + delta, glyph.GlyphOffset); |
|||
|
|||
Assert.Equal(aliasBefore + delta, alias.TotalGlyphAdvance, 3); |
|||
} |
|||
} |
|||
|
|||
// Regression: when a buffer is mutated *before* it is Split / aliased,
|
|||
// the parent's _cacheGeneration is already > 0 by the time the alias
|
|||
// inherits the cluster cache. The alias constructor must copy that
|
|||
// generation onto the child; otherwise the child's stamp stays at 0,
|
|||
// EnsureClusterCache sees a mismatch on first access and rebuilds a
|
|||
// fresh cache instead of reusing the parent's pooled arrays — silently
|
|||
// defeating the cached-split fast path.
|
|||
[Fact] |
|||
public void Split_Children_Share_Parent_Cluster_Cache_When_Parent_Was_Mutated_Before_Split() |
|||
{ |
|||
using (Start()) |
|||
{ |
|||
using var parent = ShapeAscii(AsciiText); |
|||
|
|||
// Mutate first so the shared glyph holder's generation is
|
|||
// non-zero before the cluster cache is built.
|
|||
var first0 = parent[0]; |
|||
parent[0] = new GlyphInfo( |
|||
first0.GlyphIndex, first0.GlyphCluster, |
|||
first0.GlyphAdvance + 10d, first0.GlyphOffset); |
|||
|
|||
// Prime the parent's cluster cache against the bumped generation.
|
|||
_ = parent.TotalGlyphAdvance; |
|||
var parentPrefix = parent.ClusterPrefix; |
|||
Assert.NotNull(parentPrefix); |
|||
|
|||
var split = parent.Split(parent.Text.Length / 2); |
|||
using var first = split.First!; |
|||
using var second = split.Second!; |
|||
|
|||
// Touching the child's metrics must reuse the parent's prefix
|
|||
// array, not rebuild a fresh one.
|
|||
_ = first.TotalGlyphAdvance; |
|||
_ = second.TotalGlyphAdvance; |
|||
|
|||
Assert.Same(parentPrefix, first.ClusterPrefix); |
|||
Assert.Same(parentPrefix, second.ClusterPrefix); |
|||
} |
|||
} |
|||
|
|||
[Fact] |
|||
public void WithBidiLevel_Alias_Shares_Cluster_Cache_When_Original_Was_Mutated_Before_Alias() |
|||
{ |
|||
using (Start()) |
|||
{ |
|||
using var original = ShapeAscii(AsciiText); |
|||
|
|||
var first0 = original[0]; |
|||
original[0] = new GlyphInfo( |
|||
first0.GlyphIndex, first0.GlyphCluster, |
|||
first0.GlyphAdvance + 7d, first0.GlyphOffset); |
|||
|
|||
_ = original.TotalGlyphAdvance; |
|||
var originalPrefix = original.ClusterPrefix; |
|||
Assert.NotNull(originalPrefix); |
|||
|
|||
using var alias = original.WithBidiLevel(2); |
|||
|
|||
_ = alias.TotalGlyphAdvance; |
|||
|
|||
Assert.Same(originalPrefix, alias.ClusterPrefix); |
|||
} |
|||
} |
|||
|
|||
private static ShapedBuffer ShapeAscii(string text) |
|||
{ |
|||
var options = new TextShaperOptions( |
|||
Typeface.Default.GlyphTypeface, 12, 0, CultureInfo.CurrentCulture); |
|||
return TextShaper.Current.ShapeText(text, options); |
|||
} |
|||
|
|||
private static IDisposable Start() |
|||
{ |
|||
return UnitTestApplication.Start(TestServices.MockPlatformRenderInterface |
|||
.With(renderInterface: new PlatformRenderInterface(null), |
|||
fontManagerImpl: new CustomFontManagerImpl())); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,382 @@ |
|||
#nullable enable |
|||
|
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.Linq; |
|||
using Avalonia.Media; |
|||
using Avalonia.Media.TextFormatting; |
|||
using Avalonia.Utilities; |
|||
using Xunit; |
|||
using static Avalonia.Media.TextFormatting.FormattingObjectPool; |
|||
|
|||
namespace Avalonia.Skia.UnitTests.Media.TextFormatting |
|||
{ |
|||
/// <summary>
|
|||
/// Direct tests for <c>TextFormatterImpl.SplitTextRuns</c>. Calls the
|
|||
/// internal method via <c>InternalsVisibleTo</c> so each branch can be
|
|||
/// exercised in isolation with synthetic <see cref="TextRun"/> stubs —
|
|||
/// independent of the wrap algorithm that's its main caller. Many of
|
|||
/// these scenarios are unreachable through the wrap path on its own
|
|||
/// (the wrap loop carefully avoids requesting splits inside non-splittable
|
|||
/// runs), but the method is also called by <c>TextCollapsingProperties</c>
|
|||
/// and the ellipsis types, which have weaker invariants.
|
|||
///
|
|||
/// Key invariants under test (must hold regardless of split position):
|
|||
/// * Sum of run lengths is preserved (no content lost or duplicated).
|
|||
/// * Concatenated text (across all runs in first ++ second) equals input.
|
|||
/// * Reported <c>firstLength</c> equals the sum of lengths in first.
|
|||
/// </summary>
|
|||
public class SplitTextRunsTests |
|||
{ |
|||
[Fact] |
|||
public void Split_Inside_NonShaped_Run_Does_Not_Drop_Run() |
|||
{ |
|||
// Bug repro: a DrawableTextRun-like atomic run with length > 1, asked
|
|||
// to split at length=1 (strictly inside). Before the fix, the current
|
|||
// implementation dropped the run from both halves. After the fix it
|
|||
// must appear in either first or second.
|
|||
var pool = FormattingObjectPool.Instance; |
|||
var runs = new TextRun[] { new TestStubRun("drawable", length: 3) }; |
|||
|
|||
var (first, second) = TextFormatterImpl.SplitTextRuns(runs, length: 1, pool, out var firstLength); |
|||
|
|||
try |
|||
{ |
|||
AssertContentPreserved(runs, first, second, firstLength); |
|||
} |
|||
finally |
|||
{ |
|||
pool.TextRunLists.Return(ref first); |
|||
pool.TextRunLists.Return(ref second); |
|||
} |
|||
} |
|||
|
|||
[Fact] |
|||
public void Split_Length_Zero_Returns_Null_First_And_All_In_Second() |
|||
{ |
|||
var pool = FormattingObjectPool.Instance; |
|||
var runs = new TextRun[] |
|||
{ |
|||
new TestStubRun("a", length: 2), |
|||
new TestStubRun("b", length: 3), |
|||
}; |
|||
|
|||
var (first, second) = TextFormatterImpl.SplitTextRuns(runs, length: 0, pool, out var firstLength); |
|||
|
|||
try |
|||
{ |
|||
Assert.Null(first); |
|||
Assert.NotNull(second); |
|||
Assert.Equal(2, second!.Count); |
|||
Assert.Equal(0, firstLength); |
|||
Assert.Equal(5, second.Sum(r => r.Length)); |
|||
} |
|||
finally |
|||
{ |
|||
pool.TextRunLists.Return(ref first); |
|||
pool.TextRunLists.Return(ref second); |
|||
} |
|||
} |
|||
|
|||
[Fact] |
|||
public void Split_Length_Equals_Total_Puts_All_In_First() |
|||
{ |
|||
var pool = FormattingObjectPool.Instance; |
|||
var runs = new TextRun[] |
|||
{ |
|||
new TestStubRun("a", length: 2), |
|||
new TestStubRun("b", length: 3), |
|||
}; |
|||
|
|||
var (first, second) = TextFormatterImpl.SplitTextRuns(runs, length: 5, pool, out var firstLength); |
|||
|
|||
try |
|||
{ |
|||
Assert.NotNull(first); |
|||
Assert.Equal(2, first!.Count); |
|||
Assert.Null(second); |
|||
Assert.Equal(5, firstLength); |
|||
AssertContentPreserved(runs, first, second, firstLength); |
|||
} |
|||
finally |
|||
{ |
|||
pool.TextRunLists.Return(ref first); |
|||
pool.TextRunLists.Return(ref second); |
|||
} |
|||
} |
|||
|
|||
[Fact] |
|||
public void Split_Length_Past_Total_Puts_All_In_First() |
|||
{ |
|||
var pool = FormattingObjectPool.Instance; |
|||
var runs = new TextRun[] { new TestStubRun("a", length: 2) }; |
|||
|
|||
var (first, second) = TextFormatterImpl.SplitTextRuns(runs, length: 99, pool, out var firstLength); |
|||
|
|||
try |
|||
{ |
|||
Assert.NotNull(first); |
|||
Assert.Equal(1, first!.Count); |
|||
Assert.Null(second); |
|||
Assert.Equal(2, firstLength); |
|||
} |
|||
finally |
|||
{ |
|||
pool.TextRunLists.Return(ref first); |
|||
pool.TextRunLists.Return(ref second); |
|||
} |
|||
} |
|||
|
|||
[Fact] |
|||
public void Split_At_Boundary_Between_Two_Runs_Goes_To_First_Or_Second_Cleanly() |
|||
{ |
|||
var pool = FormattingObjectPool.Instance; |
|||
var runs = new TextRun[] |
|||
{ |
|||
new TestStubRun("a", length: 2), |
|||
new TestStubRun("b", length: 3), |
|||
}; |
|||
|
|||
// length=2 means "everything up to and including the first run on first".
|
|||
var (first, second) = TextFormatterImpl.SplitTextRuns(runs, length: 2, pool, out var firstLength); |
|||
|
|||
try |
|||
{ |
|||
Assert.NotNull(first); |
|||
Assert.Equal(1, first!.Count); |
|||
Assert.Same(runs[0], first[0]); |
|||
Assert.NotNull(second); |
|||
Assert.Equal(1, second!.Count); |
|||
Assert.Same(runs[1], second[0]); |
|||
Assert.Equal(2, firstLength); |
|||
AssertContentPreserved(runs, first, second, firstLength); |
|||
} |
|||
finally |
|||
{ |
|||
pool.TextRunLists.Return(ref first); |
|||
pool.TextRunLists.Return(ref second); |
|||
} |
|||
} |
|||
|
|||
[Fact] |
|||
public void Split_Before_Drawable_That_Does_Not_Fit_Puts_Drawable_In_Second() |
|||
{ |
|||
// [text(2), drawable(1), text(2)] split at length=2.
|
|||
// Wrap normally chooses currentLength==length here ("drawable doesn't fit
|
|||
// on this line, push to next"). The == branch in SplitTextRuns already
|
|||
// handles this correctly today — assert that it stays correct.
|
|||
var pool = FormattingObjectPool.Instance; |
|||
var runs = new TextRun[] |
|||
{ |
|||
new TestStubRun("ab", length: 2), |
|||
new TestStubRun("X", length: 1), |
|||
new TestStubRun("cd", length: 2), |
|||
}; |
|||
|
|||
var (first, second) = TextFormatterImpl.SplitTextRuns(runs, length: 2, pool, out var firstLength); |
|||
|
|||
try |
|||
{ |
|||
Assert.Equal(2, firstLength); |
|||
AssertContentPreserved(runs, first, second, firstLength); |
|||
Assert.Equal(1, first!.Count); |
|||
Assert.Equal(2, second!.Count); |
|||
Assert.Same(runs[1], second[0]); // drawable at start of second
|
|||
} |
|||
finally |
|||
{ |
|||
pool.TextRunLists.Return(ref first); |
|||
pool.TextRunLists.Return(ref second); |
|||
} |
|||
} |
|||
|
|||
[Fact] |
|||
public void Split_Strictly_Inside_NonShaped_Run_Snaps_Before_It() |
|||
{ |
|||
// [text(2), drawable(3), text(2)] split at length=3 — strictly inside
|
|||
// the drawable. The drawable is atomic, so the split must snap to a
|
|||
// boundary. The current contract: snap BEFORE the drawable, so
|
|||
// firstLength is shorter than requested but content is preserved.
|
|||
var pool = FormattingObjectPool.Instance; |
|||
var runs = new TextRun[] |
|||
{ |
|||
new TestStubRun("ab", length: 2), |
|||
new TestStubRun("XXX", length: 3), |
|||
new TestStubRun("cd", length: 2), |
|||
}; |
|||
|
|||
var (first, second) = TextFormatterImpl.SplitTextRuns(runs, length: 3, pool, out var firstLength); |
|||
|
|||
try |
|||
{ |
|||
AssertContentPreserved(runs, first, second, firstLength); |
|||
Assert.True(firstLength is 2 or 5, |
|||
$"Expected firstLength to snap to 2 (before drawable) or 5 (after drawable); got {firstLength}."); |
|||
} |
|||
finally |
|||
{ |
|||
pool.TextRunLists.Return(ref first); |
|||
pool.TextRunLists.Return(ref second); |
|||
} |
|||
} |
|||
|
|||
[Fact] |
|||
public void Split_Strictly_Inside_NonShaped_Run_At_Start_Of_List_Overflows() |
|||
{ |
|||
// [drawable(5)] split at length=2 — the drawable is the first run, has
|
|||
// no content before it, and is bigger than the requested length. If we
|
|||
// snapped before, first would be empty and the caller would loop
|
|||
// forever. The contract here is to overflow the drawable into first
|
|||
// (the same "include at least one cluster" rule the wrap loop has for
|
|||
// ShapedTextRuns at the start of a line).
|
|||
var pool = FormattingObjectPool.Instance; |
|||
var runs = new TextRun[] { new TestStubRun("XXXXX", length: 5) }; |
|||
|
|||
var (first, second) = TextFormatterImpl.SplitTextRuns(runs, length: 2, pool, out var firstLength); |
|||
|
|||
try |
|||
{ |
|||
AssertContentPreserved(runs, first, second, firstLength); |
|||
Assert.Equal(5, firstLength); // overflow
|
|||
Assert.NotNull(first); |
|||
Assert.Equal(1, first!.Count); |
|||
Assert.Same(runs[0], first[0]); |
|||
} |
|||
finally |
|||
{ |
|||
pool.TextRunLists.Return(ref first); |
|||
pool.TextRunLists.Return(ref second); |
|||
} |
|||
} |
|||
|
|||
[Fact] |
|||
public void Split_At_Boundary_Before_Drawable_Mid_List() |
|||
{ |
|||
// [shape(3), drawable(2), shape(3)] split at length=3 — boundary at
|
|||
// end of first shape. == branch.
|
|||
var pool = FormattingObjectPool.Instance; |
|||
var runs = new TextRun[] |
|||
{ |
|||
new TestStubRun("AAA", length: 3), |
|||
new TestStubRun("XX", length: 2), |
|||
new TestStubRun("BBB", length: 3), |
|||
}; |
|||
|
|||
var (first, second) = TextFormatterImpl.SplitTextRuns(runs, length: 3, pool, out var firstLength); |
|||
|
|||
try |
|||
{ |
|||
Assert.Equal(3, firstLength); |
|||
AssertContentPreserved(runs, first, second, firstLength); |
|||
} |
|||
finally |
|||
{ |
|||
pool.TextRunLists.Return(ref first); |
|||
pool.TextRunLists.Return(ref second); |
|||
} |
|||
} |
|||
|
|||
[Fact] |
|||
public void Split_At_Boundary_After_Drawable_Mid_List() |
|||
{ |
|||
// Boundary at end of drawable. == branch.
|
|||
var pool = FormattingObjectPool.Instance; |
|||
var runs = new TextRun[] |
|||
{ |
|||
new TestStubRun("AAA", length: 3), |
|||
new TestStubRun("XX", length: 2), |
|||
new TestStubRun("BBB", length: 3), |
|||
}; |
|||
|
|||
var (first, second) = TextFormatterImpl.SplitTextRuns(runs, length: 5, pool, out var firstLength); |
|||
|
|||
try |
|||
{ |
|||
Assert.Equal(5, firstLength); |
|||
AssertContentPreserved(runs, first, second, firstLength); |
|||
Assert.Equal(2, first!.Count); |
|||
Assert.Equal(1, second!.Count); |
|||
} |
|||
finally |
|||
{ |
|||
pool.TextRunLists.Return(ref first); |
|||
pool.TextRunLists.Return(ref second); |
|||
} |
|||
} |
|||
|
|||
[Fact] |
|||
public void Split_With_Zero_Length_Run_Inside_Does_Not_Drop_It() |
|||
{ |
|||
// Zero-length runs (e.g. TextEndOfParagraph variants) appear after
|
|||
// shaped runs. Splitting at the boundary should keep the zero-length
|
|||
// run somewhere — not silently discard it.
|
|||
var pool = FormattingObjectPool.Instance; |
|||
var runs = new TextRun[] |
|||
{ |
|||
new TestStubRun("ab", length: 2), |
|||
new TestStubRun("zero", length: 0), |
|||
new TestStubRun("cd", length: 2), |
|||
}; |
|||
|
|||
var (first, second) = TextFormatterImpl.SplitTextRuns(runs, length: 2, pool, out var firstLength); |
|||
|
|||
try |
|||
{ |
|||
Assert.Equal(2, firstLength); |
|||
// The zero-length run still has identity — assert it's in exactly one half.
|
|||
var allRuns = (first?.Cast<TextRun>() ?? Enumerable.Empty<TextRun>()) |
|||
.Concat(second?.Cast<TextRun>() ?? Enumerable.Empty<TextRun>()) |
|||
.ToList(); |
|||
Assert.Equal(3, allRuns.Count); |
|||
Assert.Contains(runs[1], allRuns); |
|||
} |
|||
finally |
|||
{ |
|||
pool.TextRunLists.Return(ref first); |
|||
pool.TextRunLists.Return(ref second); |
|||
} |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Asserts the central invariant: sum of (first|second) run lengths
|
|||
/// equals the input total, and <paramref name="firstLength"/> matches the
|
|||
/// sum of first's run lengths. Any test where this fails means content
|
|||
/// was lost or duplicated.
|
|||
/// </summary>
|
|||
private static void AssertContentPreserved( |
|||
IReadOnlyList<TextRun> input, |
|||
RentedList<TextRun>? first, |
|||
RentedList<TextRun>? second, |
|||
int firstLength) |
|||
{ |
|||
var inputTotal = input.Sum(r => r.Length); |
|||
var firstTotal = first?.Sum(r => r.Length) ?? 0; |
|||
var secondTotal = second?.Sum(r => r.Length) ?? 0; |
|||
|
|||
Assert.Equal(inputTotal, firstTotal + secondTotal); |
|||
Assert.Equal(firstTotal, firstLength); |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Minimal concrete <see cref="TextRun"/> for tests — neither a
|
|||
/// <c>ShapedTextRun</c> nor a <c>DrawableTextRun</c> from the consumer
|
|||
/// perspective. Behaves like an atomic, non-splittable run with a
|
|||
/// configurable length, which is exactly the class of input that
|
|||
/// triggers the <c>SplitTextRuns</c> drop-current-run bug.
|
|||
/// </summary>
|
|||
private sealed class TestStubRun : TextRun |
|||
{ |
|||
private readonly string _name; |
|||
|
|||
public TestStubRun(string name, int length) |
|||
{ |
|||
_name = name; |
|||
Length = length; |
|||
} |
|||
|
|||
public override int Length { get; } |
|||
|
|||
public override string ToString() => $"TestStubRun({_name}, len={Length})"; |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,291 @@ |
|||
#nullable enable |
|||
|
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.Linq; |
|||
using Avalonia.Media; |
|||
using Avalonia.Media.TextFormatting; |
|||
using Avalonia.Media.TextFormatting.Unicode; |
|||
using Xunit; |
|||
|
|||
namespace Avalonia.Skia.UnitTests.Media.TextFormatting |
|||
{ |
|||
/// <summary>
|
|||
/// Characterization tests for <c>TextFormatterImpl.PerformTextWrapping</c>
|
|||
/// and its helpers (<c>MeasureLength</c>, <c>SplitTextRuns</c>,
|
|||
/// <c>ResetTrailingWhitespaceBidiLevels</c>).
|
|||
/// </summary>
|
|||
public class TextFormatterWrapCharacterizationTests |
|||
{ |
|||
[Fact] |
|||
public void Wrap_With_Infinite_Width_Yields_Single_Line_With_All_Runs() |
|||
{ |
|||
using (TextFormatterTests.Start()) |
|||
{ |
|||
var line = WrapSingleLine("Hello world", paragraphWidth: double.PositiveInfinity); |
|||
|
|||
Assert.Equal("Hello world".Length, line.Length); |
|||
Assert.True(line.WidthIncludingTrailingWhitespace > 0); |
|||
} |
|||
} |
|||
|
|||
[Fact] |
|||
public void Wrap_With_Zero_Width_Forces_Minimum_Cluster() |
|||
{ |
|||
// Width too small to fit any cluster — the implementation falls
|
|||
// back to one grapheme. This is the documented WrapWithOverflow
|
|||
// contract that lines 882-902 of TextFormatterImpl encode.
|
|||
using (TextFormatterTests.Start()) |
|||
{ |
|||
var line = WrapSingleLine("Hello", paragraphWidth: 0.001); |
|||
|
|||
Assert.True(line.Length >= 1, |
|||
"Wrap should always advance at least one grapheme even at zero width."); |
|||
} |
|||
} |
|||
|
|||
[Theory] |
|||
[InlineData("AAAA BBBB CCCC DDDD", 40)] |
|||
[InlineData("AAAA BBBB CCCC DDDD", 80)] |
|||
[InlineData("AAAA BBBB CCCC DDDD", 120)] |
|||
public void Wrap_Sum_Of_Line_Lengths_Equals_Input_Length(string text, double paragraphWidth) |
|||
{ |
|||
using (TextFormatterTests.Start()) |
|||
{ |
|||
var lines = WrapAllLines(text, paragraphWidth); |
|||
var totalLength = lines.Sum(l => l.Length); |
|||
Assert.Equal(text.Length, totalLength); |
|||
} |
|||
} |
|||
|
|||
[Theory] |
|||
[InlineData("AAAA BBBB CCCC DDDD", 40)] |
|||
[InlineData("AAAA BBBB CCCC DDDD", 80)] |
|||
public void Wrap_Each_Line_Width_Within_Paragraph_Width(string text, double paragraphWidth) |
|||
{ |
|||
using (TextFormatterTests.Start()) |
|||
{ |
|||
var lines = WrapAllLines(text, paragraphWidth); |
|||
foreach (var line in lines) |
|||
{ |
|||
// Width (excluding trailing whitespace) should fit the
|
|||
// paragraph. The +1.0 tolerance handles the documented
|
|||
// "single cluster wider than paragraph" overflow case.
|
|||
Assert.True(line.Width <= paragraphWidth + 1.0, |
|||
$"Line width {line.Width} exceeds paragraph width {paragraphWidth} by more than 1px."); |
|||
} |
|||
} |
|||
} |
|||
|
|||
[Fact] |
|||
public void Wrap_Does_Not_Produce_Empty_Lines_For_NonEmpty_Input() |
|||
{ |
|||
using (TextFormatterTests.Start()) |
|||
{ |
|||
var lines = WrapAllLines("the quick brown fox jumps over the lazy dog", paragraphWidth: 50); |
|||
foreach (var line in lines) |
|||
{ |
|||
Assert.True(line.Length > 0, "Wrap should never emit a zero-length line for non-empty input."); |
|||
} |
|||
} |
|||
} |
|||
|
|||
[Fact] |
|||
public void Wrap_Points_Are_Grapheme_Boundaries() |
|||
{ |
|||
// Multi-codepoint graphemes (emoji ZWJ sequences) must never be
|
|||
// split by the wrap algorithm — the wrap point has to coincide
|
|||
// with a grapheme boundary.
|
|||
using (TextFormatterTests.Start()) |
|||
{ |
|||
const string text = "abc 😀😀😀😀 xyz"; |
|||
var lines = WrapAllLines(text, paragraphWidth: 30); |
|||
|
|||
var boundaries = new HashSet<int>(); |
|||
var graphemeEnumerator = new GraphemeEnumerator(text.AsSpan()); |
|||
boundaries.Add(0); |
|||
var pos = 0; |
|||
while (graphemeEnumerator.MoveNext(out var grapheme)) |
|||
{ |
|||
pos += grapheme.Length; |
|||
boundaries.Add(pos); |
|||
} |
|||
|
|||
var cumulative = 0; |
|||
foreach (var line in lines) |
|||
{ |
|||
cumulative += line.Length; |
|||
Assert.Contains(cumulative, boundaries); |
|||
} |
|||
} |
|||
} |
|||
|
|||
[Fact] |
|||
public void Wrap_Honours_Required_Break_Even_With_Available_Width() |
|||
{ |
|||
using (TextFormatterTests.Start()) |
|||
{ |
|||
var line = WrapSingleLine("ab\ncd", paragraphWidth: double.PositiveInfinity); |
|||
|
|||
// Hard break sits at index 2 (the '\n'); PositionWrap is 3
|
|||
// (consumes the '\n').
|
|||
Assert.Equal(3, line.Length); |
|||
} |
|||
} |
|||
|
|||
[Fact] |
|||
public void Wrap_Hard_Break_With_CRLF_Counts_Both_Characters() |
|||
{ |
|||
using (TextFormatterTests.Start()) |
|||
{ |
|||
var line = WrapSingleLine("ab\r\ncd", paragraphWidth: double.PositiveInfinity); |
|||
|
|||
// CRLF is a single break with PositionWrap = 4 (consumes both chars).
|
|||
Assert.Equal(4, line.Length); |
|||
} |
|||
} |
|||
|
|||
[Fact] |
|||
public void WrapWithOverflow_Long_Word_Followed_By_Space_Wraps_After_Space() |
|||
{ |
|||
// The word "supercalifragilistic" has no break inside it. At a
|
|||
// small paragraph width with WrapWithOverflow, the wrap algorithm
|
|||
// should let the word overflow as a whole, then wrap on the next
|
|||
// break (the trailing space).
|
|||
using (TextFormatterTests.Start()) |
|||
{ |
|||
var line = WrapSingleLine("supercalifragilistic next", paragraphWidth: 30, |
|||
wrapping: TextWrapping.WrapWithOverflow); |
|||
|
|||
Assert.True(line.Length >= "supercalifragilistic".Length, |
|||
$"Expected first line to contain at least the whole long word; got length {line.Length}."); |
|||
Assert.True(line.Length <= "supercalifragilistic ".Length, |
|||
"First line should not extend past the trailing space after the long word."); |
|||
} |
|||
} |
|||
|
|||
[Fact] |
|||
public void Wrap_Strict_Long_Word_Splits_Inside_When_NoWrapPosition_Available() |
|||
{ |
|||
// Pure Wrap (not WrapWithOverflow) on an unbreakable word: the
|
|||
// implementation falls back to splitting inside the word at the
|
|||
// best available cluster boundary.
|
|||
using (TextFormatterTests.Start()) |
|||
{ |
|||
var line = WrapSingleLine("supercalifragilistic", paragraphWidth: 30, |
|||
wrapping: TextWrapping.Wrap); |
|||
|
|||
Assert.True(line.Length > 0); |
|||
Assert.True(line.Length < "supercalifragilistic".Length, |
|||
"Strict wrap should split inside the long word."); |
|||
} |
|||
} |
|||
|
|||
[Fact] |
|||
public void Wrap_Continues_From_Previous_LineBreak() |
|||
{ |
|||
using (TextFormatterTests.Start()) |
|||
{ |
|||
var text = "AAAA BBBB CCCC DDDD"; |
|||
var lines = WrapAllLines(text, paragraphWidth: 40); |
|||
|
|||
Assert.True(lines.Count >= 2, "Test setup must wrap onto at least two lines."); |
|||
|
|||
// Lines after the first reuse runs via WrappingTextLineBreak.
|
|||
// The contract: concatenated, they reproduce the original.
|
|||
var rebuilt = string.Concat(lines.Select(l => GetLineText(l))); |
|||
Assert.Equal(text, rebuilt); |
|||
} |
|||
} |
|||
|
|||
[Fact] |
|||
public void Wrap_With_LTR_Text_Does_Not_Touch_Trailing_Whitespace_Bidi() |
|||
{ |
|||
// ResetTrailingWhitespaceBidiLevels is a no-op when the run's
|
|||
// BidiLevel already matches the paragraph. The wrap result should
|
|||
// be identical to a non-wrapped layout of the same paragraph.
|
|||
using (TextFormatterTests.Start()) |
|||
{ |
|||
var text = "Hello world from Avalonia"; |
|||
var wrappedLines = WrapAllLines(text, paragraphWidth: 80); |
|||
var rebuilt = string.Concat(wrappedLines.Select(l => GetLineText(l))); |
|||
Assert.Equal(text, rebuilt); |
|||
} |
|||
} |
|||
|
|||
[Fact] |
|||
public void Wrap_Empty_Text_Yields_Null() |
|||
{ |
|||
using (TextFormatterTests.Start()) |
|||
{ |
|||
var defaultProperties = new GenericTextRunProperties(Typeface.Default, 12, |
|||
foregroundBrush: Brushes.Black); |
|||
var paragraphProperties = new GenericTextParagraphProperties( |
|||
defaultProperties, textWrapping: TextWrapping.Wrap); |
|||
var textSource = new SingleBufferTextSource("", defaultProperties); |
|||
var formatter = new TextFormatterImpl(); |
|||
|
|||
var line = formatter.FormatLine(textSource, 0, 100, paragraphProperties); |
|||
Assert.Null(line); |
|||
} |
|||
} |
|||
|
|||
private static TextLine WrapSingleLine(string text, double paragraphWidth, |
|||
TextWrapping wrapping = TextWrapping.Wrap) |
|||
{ |
|||
var defaultProperties = new GenericTextRunProperties(Typeface.Default, 12, |
|||
foregroundBrush: Brushes.Black); |
|||
var paragraphProperties = new GenericTextParagraphProperties(defaultProperties, |
|||
textWrapping: wrapping); |
|||
var textSource = new SingleBufferTextSource(text, defaultProperties); |
|||
var formatter = new TextFormatterImpl(); |
|||
|
|||
var line = formatter.FormatLine(textSource, 0, paragraphWidth, paragraphProperties); |
|||
Assert.NotNull(line); |
|||
return line!; |
|||
} |
|||
|
|||
private static List<TextLine> WrapAllLines(string text, double paragraphWidth, |
|||
TextWrapping wrapping = TextWrapping.Wrap) |
|||
{ |
|||
var defaultProperties = new GenericTextRunProperties(Typeface.Default, 12, |
|||
foregroundBrush: Brushes.Black); |
|||
var paragraphProperties = new GenericTextParagraphProperties(defaultProperties, |
|||
textWrapping: wrapping); |
|||
var textSource = new SingleBufferTextSource(text, defaultProperties); |
|||
var formatter = new TextFormatterImpl(); |
|||
|
|||
var lines = new List<TextLine>(); |
|||
var pos = 0; |
|||
TextLineBreak? previousLineBreak = null; |
|||
while (pos < text.Length) |
|||
{ |
|||
var line = formatter.FormatLine(textSource, pos, paragraphWidth, |
|||
paragraphProperties, previousLineBreak); |
|||
if (line == null) |
|||
{ |
|||
break; |
|||
} |
|||
lines.Add(line); |
|||
previousLineBreak = line.TextLineBreak; |
|||
pos += line.Length; |
|||
|
|||
if (pos > 0 && lines.Count > 200) |
|||
{ |
|||
Assert.Fail("Wrap appears to be looping; bailing out."); |
|||
} |
|||
} |
|||
return lines; |
|||
} |
|||
|
|||
private static string GetLineText(TextLine line) |
|||
{ |
|||
var sb = new System.Text.StringBuilder(); |
|||
foreach (var run in line.TextRuns) |
|||
{ |
|||
sb.Append(run.Text.Span); |
|||
} |
|||
return sb.ToString(); |
|||
} |
|||
} |
|||
} |
|||
Loading…
Reference in new issue