Browse Source

[Text] InterWordJustification fixes (#21701)

* Fix inter-word justification distribution and safety

TextAlignment.Justify was incorrect or a no-op in several common cases.
This corrects glyph targeting, buffer safety, run partitioning, the fill
target, and which lines get justified.

- Target the glyph ending each break boundary; step zero-width breaks
  (CJK/Korean ideograph boundaries, hyphens) back one so the widened gap
  sits on the break and the last visible glyph is never stretched.
- Copy-on-write the shaped buffer before adjusting advances and swap in a
  fresh run, so justification never mutates glyph storage shared with a
  TextRunCache entry, a Split sibling or a WithBidiLevel alias. Adds
  ShapedBuffer.CloneWritable and TextLineImpl.ReplaceTextRun, which also
  repoints the bidi-reordered runs used by draw and hit-test.
- Consume break opportunities per run instead of draining the whole queue
  against the first run.
- Fill the visible Width to the paragraph width rather than the width
  including trailing whitespace, so wrapped lines with trailing whitespace
  justify instead of being left un-justified.
- Target MaxWidth for wrapped text rather than the widest produced line.
- Skip the last line, newline-terminated lines and hard-break lines.

Adds justification test coverage: CJK, mixed Latin+CJK, Arabic buffer
integrity, Latin wrapping, trailing whitespace and multi-run lines.

* Add failing test for trailing-whitespace overcount across runs

- TextLineImpl.CreateLineMetrics walks a line's runs backward to
  compute trailing whitespace, stopping only when a run has none of
  its own - not when a run is only partly whitespace.
- On a multi-run line where an interior run also ends in a space
  (common at a script boundary, since each script run tends to end at
  the following space), that space gets wrongly counted as trailing
  too, even though visible content follows it later in the line.
- This shrinks Width, which InterWordJustification uses to decide how
  much space a line still needs to reach the target width.

* Fix trailing-whitespace overcount across runs

- Only continue accumulating trailing whitespace from an earlier run
  when the current run is entirely whitespace; otherwise the current
  run's own visible content is the line's true end, and an earlier
  run's trailing space is interior, not trailing.
- Width now correctly includes interior, non-trailing whitespace, so
  wrapped multi-run lines (e.g. mixed-script text) no longer get
  stretched past the target width when justified.

* Ignore CLAUDE.md
pull/21713/head
Benedikt Stebner 3 months ago
committed by GitHub
parent
commit
20c1d6a2e2
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 1
      .gitignore
  2. 2
      src/Avalonia.Base/Media/TextFormatting/IndexedTextRun.cs
  3. 117
      src/Avalonia.Base/Media/TextFormatting/InterWordJustification.cs
  4. 17
      src/Avalonia.Base/Media/TextFormatting/ShapedBuffer.cs
  5. 24
      src/Avalonia.Base/Media/TextFormatting/TextLayout.cs
  6. 42
      src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs
  7. 567
      tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLayoutTests.cs

1
.gitignore

@ -228,3 +228,4 @@ src/Browser/Avalonia.Browser/staticwebassets
/planning
BenchmarkDotNet.Artifacts.*
.tokensave
/CLAUDE.md

2
src/Avalonia.Base/Media/TextFormatting/IndexedTextRun.cs

@ -5,6 +5,6 @@
public int TextSourceCharacterIndex { get; init; }
public int RunIndex { get; set; }
public int NextRunIndex { get; set; }
public TextRun? TextRun { get; init; }
public TextRun? TextRun { get; set; }
}
}

117
src/Avalonia.Base/Media/TextFormatting/InterWordJustification.cs

@ -4,6 +4,21 @@ using Avalonia.Media.TextFormatting.Unicode;
namespace Avalonia.Media.TextFormatting
{
/// <summary>
/// Distributes the remaining line width across the break opportunities reported by the line
/// breaker - inter-word gaps for space-delimited scripts, and inter-character/inter-syllable
/// gaps for CJK and Korean (which the line breaker treats as break opportunities).
/// </summary>
/// <remarks>
/// Known limitations:
/// <list type="bullet">
/// <item>Thai, Lao, Khmer and Myanmar produce no line-break opportunities without dictionary or
/// ML based word segmentation (character-class line breaking cannot find their word boundaries),
/// so such lines are left un-justified (start-aligned) rather than spaced incorrectly.</item>
/// <item>Arabic and Hebrew are justified with inter-word spacing rather than the idiomatic
/// kashida (tatweel) elongation, which would require shaper-level support.</item>
/// </list>
/// </remarks>
internal class InterWordJustification : JustificationProperties
{
public InterWordJustification(double width)
@ -31,6 +46,12 @@ namespace Avalonia.Media.TextFormatting
var currentPosition = textLine.FirstTextSourceIndex;
// Note: trailing whitespace needs no special handling here. The LineBreakEnumerator does
// not emit a non-required break inside trailing whitespace (LB07 forbids breaking before
// a space, LB06 before a hard break), and the run-final break is excluded by
// PositionWrap != textRun.Length below - so no break opportunity ever targets a glyph in
// the trailing whitespace. Verified for ASCII and ideographic (U+3000) spaces by
// Justify_Does_Not_Space_Trailing_Whitespace.
for (var i = 0; i < lineImpl.TextRuns.Count; ++i)
{
var textRun = lineImpl.TextRuns[i];
@ -47,7 +68,25 @@ namespace Avalonia.Media.TextFormatting
{
if (!currentBreak.Required && currentBreak.PositionWrap != textRun.Length)
{
breakOportunities.Enqueue(currentPosition + currentBreak.PositionMeasure);
// The extra advance must land on the glyph that ENDS at the break
// boundary (the last glyph before the break), so the widened gap sits on
// the break itself. For whitespace breaks GetLineBreak has already pulled
// PositionMeasure back onto the trailing whitespace glyph
// (PositionMeasure < PositionWrap), so that position is the target as-is.
// For zero-width breaks - CJK/Korean ideograph boundaries, hyphens and
// other break-after punctuation - PositionMeasure == PositionWrap and
// points one glyph PAST the boundary; step back one so we widen the gap the
// break represents rather than the following gap. This also keeps the last
// visible glyph of a CJK/Korean line unstretched: its only inbound break is
// the run-final break, already excluded by PositionWrap != textRun.Length.
var target = currentPosition + currentBreak.PositionMeasure;
if (currentBreak.PositionMeasure == currentBreak.PositionWrap)
{
target -= 1;
}
breakOportunities.Enqueue(target);
}
}
@ -59,46 +98,72 @@ namespace Avalonia.Media.TextFormatting
return;
}
var remainingSpace = Math.Max(0, paragraphWidth - lineImpl.WidthIncludingTrailingWhitespace);
// Fill the visible content to the paragraph width, not the width including trailing
// whitespace. A wrapped line keeps the space at its wrap point as trailing whitespace,
// which can push WidthIncludingTrailingWhitespace to or past the paragraph width; using
// it here would leave remainingSpace at zero and the line unjustified. The distributed
// space only ever lands on visible glyphs (trailing whitespace gets no break), so the
// visible content reaches the margin and the trailing whitespace hangs past it.
var remainingSpace = Math.Max(0, paragraphWidth - lineImpl.Width);
var spacing = remainingSpace / breakOportunities.Count;
currentPosition = textLine.FirstTextSourceIndex;
foreach (var textRun in lineImpl.TextRuns)
for (var runIndex = 0; runIndex < lineImpl.TextRuns.Count; runIndex++)
{
var text = textRun.Text;
if (text.IsEmpty)
var textRun = lineImpl.TextRuns[runIndex];
var runLength = textRun.Length;
var runEnd = currentPosition + runLength;
var shapedText = textRun.Text.IsEmpty ? null : textRun as ShapedTextRun;
var glyphRun = shapedText?.GlyphRun;
ShapedBuffer? writableBuffer = null;
// Consume only the break opportunities that fall inside this run's range. The queue
// is in ascending position order, so once the front break is at or past runEnd it
// belongs to a later run and must be left for it, instead of draining the whole
// queue against this run.
while (breakOportunities.Count > 0)
{
continue;
}
var characterIndex = breakOportunities.Peek();
if (textRun is ShapedTextRun shapedText)
{
var glyphRun = shapedText.GlyphRun;
var shapedBuffer = shapedText.ShapedBuffer;
if (characterIndex >= runEnd)
{
break;
}
while (breakOportunities.Count > 0)
breakOportunities.Dequeue();
// Skip stale breaks and breaks in runs we cannot justify (non-shaped).
if (characterIndex < currentPosition || shapedText is null)
{
var characterIndex = breakOportunities.Dequeue();
continue;
}
if (characterIndex < currentPosition)
{
continue;
}
// Copy-on-write: the run's own ShapedBuffer may share its pooled glyph storage
// with a TextRunCache entry, a Split sibling or a WithBidiLevel alias, so
// mutating it in place would corrupt those. Adjust a private clone and swap in a
// fresh run below.
writableBuffer ??= shapedText.ShapedBuffer.CloneWritable();
var offset = Math.Max(0, currentPosition - glyphRun.Metrics.FirstCluster);
var glyphIndex = glyphRun.FindGlyphIndex(characterIndex - offset);
var glyphInfo = shapedBuffer[glyphIndex];
var offset = Math.Max(0, currentPosition - glyphRun!.Metrics.FirstCluster);
var glyphIndex = glyphRun.FindGlyphIndex(characterIndex - offset);
var glyphInfo = writableBuffer[glyphIndex];
shapedBuffer[glyphIndex] = new GlyphInfo(glyphInfo.GlyphIndex,
glyphInfo.GlyphCluster, glyphInfo.GlyphAdvance + spacing);
}
writableBuffer[glyphIndex] = new GlyphInfo(glyphInfo.GlyphIndex,
glyphInfo.GlyphCluster, glyphInfo.GlyphAdvance + spacing);
}
if (writableBuffer != null)
{
var justifiedRun = new ShapedTextRun(writableBuffer, shapedText!.Properties);
glyphRun.GlyphInfos = shapedBuffer;
lineImpl.ReplaceTextRun(runIndex, justifiedRun);
shapedText.Dispose();
}
currentPosition += textRun.Length;
currentPosition += runLength;
}
}
}

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

@ -562,6 +562,23 @@ namespace Avalonia.Media.TextFormatting
_glyphRef, prefixRef, startsRef, startIdx, count, _cacheGeneration);
}
/// <summary>
/// Creates a deep copy backed by a fresh, non-pooled <see cref="GlyphInfo"/> array so the
/// copy's advances can be mutated (e.g. by justification) without touching glyph storage
/// shared with a <see cref="TextRunCache"/> entry, a <see cref="Split"/> sibling or a
/// <see cref="WithBidiLevel"/> alias. The copy owns no ref-counted pooled handles (its
/// <c>_glyphRef</c> is null) and builds its own cluster cache lazily.
/// </summary>
internal ShapedBuffer CloneWritable()
{
var span = _glyphInfos.Span;
var glyphs = new GlyphInfo[span.Length];
span.CopyTo(glyphs);
return new ShapedBuffer(Text, new ArraySlice<GlyphInfo>(glyphs), GlyphTypeface, FontRenderingEmSize, BidiLevel);
}
int IReadOnlyCollection<GlyphInfo>.Count => _glyphInfos.Length;
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();

24
src/Avalonia.Base/Media/TextFormatting/TextLayout.cs

@ -657,14 +657,14 @@ namespace Avalonia.Media.TextFormatting
if (_paragraphProperties.TextAlignment == TextAlignment.Justify)
{
// Justify fills each line to the column width, which is MaxWidth for both
// wrapped and non-wrapped text. Targeting the widest produced line instead
// (the previous behaviour for wrapping) leaves wrapped text short of the
// margin, since the full non-last lines already equal that width. When
// MaxWidth is infinite there is no column to fill, so skip.
var justificationWidth = MaxWidth;
if (_paragraphProperties.TextWrapping != TextWrapping.NoWrap)
{
justificationWidth = WidthIncludingTrailingWhitespace;
}
if (justificationWidth > 0)
if (!double.IsInfinity(justificationWidth) && justificationWidth > 0)
{
var justificationProperties = new InterWordJustification(justificationWidth);
@ -672,6 +672,18 @@ namespace Avalonia.Media.TextFormatting
{
var line = textLines[i];
// Only width-driven wrapped lines are stretched to the column. Skip
// the last line of the layout, any line ended by a newline
// (NewLineLength > 0), and any line ended by a source-provided required
// break (TextEndOfLine) - these stay start-aligned per standard
// typographic convention.
if (i == textLines.Count - 1
|| line.NewLineLength > 0
|| line.TextLineBreak?.TextEndOfLine != null)
{
continue;
}
line.Justify(justificationProperties);
}
}

42
src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs

@ -197,6 +197,39 @@ namespace Avalonia.Media.TextFormatting
_textLineMetrics = CreateLineMetrics();
}
/// <summary>
/// Replaces the run at <paramref name="index"/> in <see cref="_textRuns"/> and re-points the
/// matching bidi-reordered <see cref="IndexedTextRun"/> at the new run, so draw and hit-test
/// paths that resolve runs through <see cref="_indexedTextRuns"/> observe the replacement.
/// Used by justification to swap in a copy-on-write run without mutating shared glyph storage.
/// </summary>
internal void ReplaceTextRun(int index, TextRun textRun)
{
var oldRun = _textRuns[index];
if (ReferenceEquals(oldRun, textRun))
{
return;
}
_textRuns[index] = textRun;
if (_indexedTextRuns is null)
{
return;
}
for (var i = 0; i < _indexedTextRuns.Count; i++)
{
if (ReferenceEquals(_indexedTextRuns[i].TextRun, oldRun))
{
_indexedTextRuns[i].TextRun = textRun;
break;
}
}
}
/// <inheritdoc/>
public override CharacterHit GetCharacterHitFromDistance(double distance)
{
@ -1428,6 +1461,15 @@ namespace Avalonia.Media.TextFormatting
var whitespaceWidth = glyphRun.Bounds.Width - glyphRunMetrics.Width;
width -= whitespaceWidth;
if (glyphRunMetrics.TrailingWhitespaceLength != currentRun.Length)
{
// This run has visible content before its own trailing whitespace, so it -
// not an earlier run - is the true end of the line's visible content. An
// earlier run's own trailing whitespace is interior to the line (followed by
// this run's visible content) and must not be excluded from Width too.
break;
}
}
}

567
tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLayoutTests.cs

@ -1246,6 +1246,573 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
}
}
[Fact]
public void InterWordJustification_Does_Not_Stretch_Last_CJK_Glyph()
{
using (Start())
{
// Pure CJK (Han) has no inter-word spaces; UAX#14 LB31 yields a break opportunity
// between essentially every ideograph, so justification distributes space
// inter-character. Justifying to a width wider than the shaped line must widen
// interior glyphs (including the first) but leave the final visible glyph's advance
// untouched - otherwise the last ideograph is not flush to the line edge. Drives
// InterWordJustification directly with an explicit target width to avoid the
// widest-line / last-line behaviour of the full TextLayout pipeline.
const string text = "一二三四五";
var defaultProperties = new GenericTextRunProperties(Typeface.Default);
var textSource = new SingleBufferTextSource(text, defaultProperties);
var formatter = new TextFormatterImpl();
var textLine = formatter.FormatLine(textSource, 0, double.PositiveInfinity,
new GenericTextParagraphProperties(defaultProperties));
Assert.NotNull(textLine);
var naturalWidth = textLine.WidthIncludingTrailingWhitespace;
var before = GlyphAdvances(textLine);
Assert.True(before.Count >= 2);
var targetWidth = naturalWidth + 40;
textLine.Justify(new InterWordJustification(targetWidth));
var after = GlyphAdvances(textLine);
Assert.Equal(before.Count, after.Count);
// The line is stretched to the requested width.
Assert.Equal(targetWidth, textLine.WidthIncludingTrailingWhitespace, 3);
// The last visible glyph keeps its original advance (no trailing overshoot).
Assert.Equal(before[before.Count - 1], after[after.Count - 1], 3);
// The first glyph participates in justification (the leading gap is widened).
AssertGreaterThan(after[0], before[0], "The first glyph should be widened");
}
}
[Fact]
public void Should_Justify_Wrapped_CJK_Line_To_MaxWidth()
{
using (Start())
{
// With the MaxWidth justification target (not the widest produced line), a wrapped
// CJK paragraph fills each justified line to the paragraph margin, without stretching
// the last visible glyph of the line. Compared against a Left layout of the same
// text/width so the assertions do not depend on the fallback font's metrics.
const string text = "一二三四五六七八九十一二三四五六七八九十";
const double maxWidth = 100;
var foreground = Brushes.Black.ToImmutable();
var left = new TextLayout(text, Typeface.Default, 12.0f, foreground,
textAlignment: TextAlignment.Left, textWrapping: TextWrapping.Wrap, maxWidth: maxWidth);
var justified = new TextLayout(text, Typeface.Default, 12.0f, foreground,
textAlignment: TextAlignment.Justify, textWrapping: TextWrapping.Wrap, maxWidth: maxWidth);
// A wrapped, non-last line (the last line's treatment is a separate concern).
Assert.True(justified.TextLines.Count >= 2);
var leftLine = left.TextLines[0];
var justifiedLine = justified.TextLines[0];
// The line must have slack to distribute, otherwise the test proves nothing.
AssertGreaterThan(maxWidth, leftLine.WidthIncludingTrailingWhitespace,
"The unjustified line must be narrower than MaxWidth");
var before = GlyphAdvances(leftLine);
var after = GlyphAdvances(justifiedLine);
Assert.Equal(before.Count, after.Count);
// The wrapped non-last line fills to MaxWidth (not the widest produced line).
Assert.Equal(maxWidth, justifiedLine.WidthIncludingTrailingWhitespace, 3);
// The last visible glyph is unchanged; the first glyph is widened.
Assert.Equal(before[before.Count - 1], after[after.Count - 1], 3);
AssertGreaterThan(after[0], before[0], "The first glyph should be widened");
}
}
[Fact]
public void Does_Not_Justify_Last_Line_Of_Wrapped_Paragraph()
{
using (Start())
{
// The last line of a justified paragraph stays start-aligned (its natural width),
// while the preceding wrapped lines fill to the margin.
var text = new string('一', 40);
const double maxWidth = 80;
var foreground = Brushes.Black.ToImmutable();
var left = new TextLayout(text, Typeface.Default, 12.0f, foreground,
textAlignment: TextAlignment.Left, textWrapping: TextWrapping.Wrap, maxWidth: maxWidth);
var justified = new TextLayout(text, Typeface.Default, 12.0f, foreground,
textAlignment: TextAlignment.Justify, textWrapping: TextWrapping.Wrap, maxWidth: maxWidth);
var lineCount = justified.TextLines.Count;
Assert.True(lineCount >= 2);
var lastLeft = left.TextLines[lineCount - 1];
var lastJustified = justified.TextLines[lineCount - 1];
// Precondition: the last line is shorter than the margin, so stretching would show.
AssertGreaterThan(maxWidth, lastLeft.WidthIncludingTrailingWhitespace,
"The last line must be shorter than MaxWidth for the test to be meaningful");
// The last line is not stretched.
Assert.Equal(lastLeft.WidthIncludingTrailingWhitespace,
lastJustified.WidthIncludingTrailingWhitespace, 3);
// A preceding wrapped line is still justified to the margin.
Assert.Equal(maxWidth, justified.TextLines[0].WidthIncludingTrailingWhitespace, 3);
}
}
[Fact]
public void Does_Not_Justify_Line_Ending_In_Hard_Break()
{
using (Start())
{
// "一二三" ends in an explicit newline (a hard break); it stays start-aligned while
// the following width-wrapped, non-last line fills to the margin.
var text = "一二三\n" + new string('一', 40);
const double maxWidth = 80;
var foreground = Brushes.Black.ToImmutable();
var left = new TextLayout(text, Typeface.Default, 12.0f, foreground,
textAlignment: TextAlignment.Left, textWrapping: TextWrapping.Wrap, maxWidth: maxWidth);
var justified = new TextLayout(text, Typeface.Default, 12.0f, foreground,
textAlignment: TextAlignment.Justify, textWrapping: TextWrapping.Wrap, maxWidth: maxWidth);
// Line 0 ends in '\n', line 1 is a wrapped non-last line, line 2 is the last line.
Assert.True(justified.TextLines.Count >= 3);
var hardBreakLeft = left.TextLines[0];
var hardBreakJustified = justified.TextLines[0];
AssertGreaterThan(maxWidth, hardBreakLeft.WidthIncludingTrailingWhitespace,
"The hard-break line must be shorter than MaxWidth for the test to be meaningful");
// The hard-break line is not stretched.
Assert.Equal(hardBreakLeft.WidthIncludingTrailingWhitespace,
hardBreakJustified.WidthIncludingTrailingWhitespace, 3);
// The following width-wrapped, non-last line is justified to the margin.
Assert.Equal(maxWidth, justified.TextLines[1].WidthIncludingTrailingWhitespace, 3);
}
}
[Fact]
public void Justify_Does_Not_Mutate_Shared_ShapedBuffer()
{
using (Start())
{
// A TextRunCache keeps the same ShapedTextRun (and its pooled glyph storage) alive
// across layouts. Justification must copy-on-write rather than mutate that shared
// buffer in place. Simulate the cache's reference with AddRef and assert the
// original buffer is untouched while the line's run is replaced with a widened copy.
const string text = "一二三四五";
var defaultProperties = new GenericTextRunProperties(Typeface.Default);
var textSource = new SingleBufferTextSource(text, defaultProperties);
var formatter = new TextFormatterImpl();
var textLine = formatter.FormatLine(textSource, 0, double.PositiveInfinity,
new GenericTextParagraphProperties(defaultProperties));
Assert.NotNull(textLine);
var originalRun = textLine.TextRuns.OfType<ShapedTextRun>().First();
var originalBuffer = originalRun.ShapedBuffer;
var originalFirstAdvance = originalBuffer[0].GlyphAdvance;
// Second owner (stands in for the TextRunCache) so the buffer survives the run's
// disposal during justification.
originalRun.AddRef();
textLine.Justify(new InterWordJustification(originalRun.Size.Width + 40));
// The shared buffer is not mutated...
Assert.Equal(originalFirstAdvance, originalBuffer[0].GlyphAdvance, 5);
// ...and the line now holds a different run whose first glyph was widened.
var justifiedRun = textLine.TextRuns.OfType<ShapedTextRun>().First();
Assert.NotSame(originalRun, justifiedRun);
AssertGreaterThan(justifiedRun.ShapedBuffer[0].GlyphAdvance, originalFirstAdvance,
"The justified copy's first glyph should be widened");
originalRun.Dispose();
}
}
[Fact]
public void Justify_Repoints_Indexed_Runs_At_Replacement()
{
using (Start())
{
// Draw and hit-test resolve runs through the bidi-reordered IndexedTextRun list, so
// after justification replaces a run its IndexedTextRun must point at the
// replacement. GetTextBounds walks the indexed runs; its total must match the
// justified line width, not the pre-justification width.
var text = new string('一', 40);
const double maxWidth = 80;
var justified = new TextLayout(text, Typeface.Default, 12.0f, Brushes.Black.ToImmutable(),
textAlignment: TextAlignment.Justify, textWrapping: TextWrapping.Wrap, maxWidth: maxWidth);
Assert.True(justified.TextLines.Count >= 2);
var line = justified.TextLines[0];
var bounds = line.GetTextBounds(line.FirstTextSourceIndex, line.Length);
Assert.Equal(line.WidthIncludingTrailingWhitespace, bounds.Sum(b => b.Rectangle.Width), 2);
Assert.Equal(maxWidth, bounds.Sum(b => b.Rectangle.Width), 2);
}
}
[Fact]
public void Justify_Distributes_Across_Multiple_Runs()
{
using (Start())
{
// Two shaped runs on one line (split by a font-size change). Each must receive its
// own break opportunities: the pre-fix apply loop drained the whole queue against
// the first run, leaving later runs unjustified.
const string text = "一二三四五六";
var first = new GenericTextRunProperties(Typeface.Default, 20);
var second = new GenericTextRunProperties(Typeface.Default, 12);
var textSource = new SplitStyleTextSource(text, 3, first, second);
var formatter = new TextFormatterImpl();
var textLine = formatter.FormatLine(textSource, 0, double.PositiveInfinity,
new GenericTextParagraphProperties(first));
Assert.NotNull(textLine);
var runs = textLine.TextRuns.OfType<ShapedTextRun>().ToList();
// Confirm the line really is multi-run, otherwise the test proves nothing.
Assert.True(runs.Count >= 2);
var beforeWidths = runs.Select(r => r.Size.Width).ToList();
textLine.Justify(new InterWordJustification(textLine.WidthIncludingTrailingWhitespace + 60));
var afterRuns = textLine.TextRuns.OfType<ShapedTextRun>().ToList();
Assert.Equal(runs.Count, afterRuns.Count);
// Every shaped run participated in justification, not just the first.
for (var i = 0; i < afterRuns.Count; i++)
{
AssertGreaterThan(afterRuns[i].Size.Width, beforeWidths[i], $"Run {i} should be widened");
}
}
}
[Theory]
[InlineData("aa bb ")] // trailing ASCII spaces
[InlineData("一二   ")] // trailing ideographic (U+3000) spaces
public void Justify_Does_Not_Space_Trailing_Whitespace(string text)
{
using (Start())
{
// Trailing whitespace (which sits before a wrap point or hard break) must never
// receive justification space; the full distributed amount lands in the visible
// region instead. This is guaranteed by the LineBreakEnumerator (it emits no
// non-required break inside trailing whitespace), so no explicit guard is needed in
// InterWordJustification - this test locks that invariant. Width excludes trailing
// whitespace, so it must grow by the entire distributed space; if trailing
// whitespace absorbed part of it, Width would grow less.
const double extra = 40;
var defaultProperties = new GenericTextRunProperties(Typeface.Default);
var textSource = new SingleBufferTextSource(text, defaultProperties);
var formatter = new TextFormatterImpl();
var textLine = formatter.FormatLine(textSource, 0, double.PositiveInfinity,
new GenericTextParagraphProperties(defaultProperties));
Assert.NotNull(textLine);
// Confirm the line actually carries trailing whitespace, otherwise the test proves nothing.
Assert.True(textLine.TrailingWhitespaceLength >= 2);
AssertGreaterThan(textLine.WidthIncludingTrailingWhitespace, textLine.Width,
"The line must have measurable trailing whitespace");
var widthBefore = textLine.Width;
textLine.Justify(new InterWordJustification(textLine.Width + extra));
Assert.Equal(widthBefore + extra, textLine.Width, 2);
}
}
[Fact]
public void Should_Justify_Wrapped_Latin_Line()
{
// The classic inter-word case. A wrapped non-last Latin line fills to the margin; the
// last line stays start-aligned.
using (Start())
{
AssertWrappedNonLastLineFillsToMaxWidth(
"the quick brown fox jumps over the lazy dog and then runs away quite quickly today", 140);
}
}
// Korean Hangul justifies inter-syllable via the same code path as CJK (LB26/27 bind within
// a syllable, LB31 breaks between syllable blocks). A live Korean advance test is not
// possible here because the test harness's fallback fonts render Hangul with zero advance
// (no Korean font installed), so the CJK tests stand in for it.
[Fact]
public void Does_Not_Justify_Latin_Line_With_Trailing_Spaces_Before_Hard_Break()
{
using (Start())
{
// A line ending in trailing spaces + a hard break stays start-aligned (its trailing
// whitespace is not stretched), while the following wrapped lines still fill to the
// margin.
var text = "aa bb \n" + string.Join(" ", Enumerable.Repeat("cc", 40));
const double maxWidth = 120;
var foreground = Brushes.Black.ToImmutable();
var left = new TextLayout(text, Typeface.Default, 12.0f, foreground,
textAlignment: TextAlignment.Left, textWrapping: TextWrapping.Wrap, maxWidth: maxWidth);
var justified = new TextLayout(text, Typeface.Default, 12.0f, foreground,
textAlignment: TextAlignment.Justify, textWrapping: TextWrapping.Wrap, maxWidth: maxWidth);
Assert.True(justified.TextLines.Count >= 3);
var line0Left = left.TextLines[0];
var line0Justified = justified.TextLines[0];
AssertGreaterThan(maxWidth, line0Left.WidthIncludingTrailingWhitespace,
"The hard-break line must be shorter than MaxWidth for the test to be meaningful");
// The trailing-spaces + '\n' line is not stretched.
Assert.Equal(line0Left.WidthIncludingTrailingWhitespace,
line0Justified.WidthIncludingTrailingWhitespace, 2);
// A following width-wrapped, non-last line fills its visible content to the margin.
Assert.Equal(maxWidth, justified.TextLines[1].Width, 2);
}
}
[Fact]
public void Justify_Distributes_Across_Latin_And_CJK()
{
using (Start())
{
// A mixed Latin+CJK line distributes space across both the inter-word gap and the
// inter-ideograph gaps (the line reaches the target width) without stretching the
// last visible glyph.
const string text = "ab 日本語";
var defaultProperties = new GenericTextRunProperties(Typeface.Default);
var textSource = new SingleBufferTextSource(text, defaultProperties);
var formatter = new TextFormatterImpl();
var textLine = formatter.FormatLine(textSource, 0, double.PositiveInfinity,
new GenericTextParagraphProperties(defaultProperties));
Assert.NotNull(textLine);
var lastGlyphBefore = LastGlyphAdvance(textLine);
var target = textLine.WidthIncludingTrailingWhitespace + 40;
textLine.Justify(new InterWordJustification(target));
// Space was distributed across the mixed content (the line reaches the target)...
Assert.Equal(target, textLine.WidthIncludingTrailingWhitespace, 2);
// ...but the last visible glyph is not stretched.
Assert.Equal(lastGlyphBefore, LastGlyphAdvance(textLine), 3);
}
}
[Fact]
public void Justify_Arabic_Does_Not_Corrupt_Shared_Buffer()
{
using (Start())
{
// Arabic is cursive and right-to-left (ligated, not one-char-per-cluster), so
// justification exercises the copy-on-write path on a non-trivial run. It must run
// without corrupting a cache-shared buffer.
const string text = "مرحبا بالعالم";
var defaultProperties = new GenericTextRunProperties(Typeface.Default);
var textSource = new SingleBufferTextSource(text, defaultProperties);
var formatter = new TextFormatterImpl();
var textLine = formatter.FormatLine(textSource, 0, double.PositiveInfinity,
new GenericTextParagraphProperties(defaultProperties));
Assert.NotNull(textLine);
var originalRun = textLine.TextRuns.OfType<ShapedTextRun>().First();
var originalBuffer = originalRun.ShapedBuffer;
var originalAdvances = Enumerable.Range(0, originalBuffer.Length)
.Select(i => originalBuffer[i].GlyphAdvance).ToList();
// Second owner (stands in for a TextRunCache) so the buffer survives run disposal.
originalRun.AddRef();
var widthBefore = textLine.WidthIncludingTrailingWhitespace;
textLine.Justify(new InterWordJustification(widthBefore + 40));
// Justification happened (the inter-word gap was widened)...
AssertGreaterThan(textLine.WidthIncludingTrailingWhitespace, widthBefore,
"Justifying an Arabic line should widen it");
// ...and the shared buffer was not mutated in place.
for (var i = 0; i < originalBuffer.Length; i++)
{
Assert.Equal(originalAdvances[i], originalBuffer[i].GlyphAdvance, 5);
}
originalRun.Dispose();
}
}
private static void AssertWrappedNonLastLineFillsToMaxWidth(string text, double maxWidth)
{
var foreground = Brushes.Black.ToImmutable();
var left = new TextLayout(text, Typeface.Default, 12.0f, foreground,
textAlignment: TextAlignment.Left, textWrapping: TextWrapping.Wrap, maxWidth: maxWidth);
var justified = new TextLayout(text, Typeface.Default, 12.0f, foreground,
textAlignment: TextAlignment.Justify, textWrapping: TextWrapping.Wrap, maxWidth: maxWidth);
Assert.True(justified.TextLines.Count >= 2);
var leftLine = left.TextLines[0];
var justifiedLine = justified.TextLines[0];
AssertGreaterThan(maxWidth, leftLine.Width,
"The unjustified line's visible content must be narrower than MaxWidth");
// The non-last wrapped line's visible content fills to the margin. (Width excludes any
// trailing whitespace, which hangs past the margin.)
Assert.Equal(maxWidth, justifiedLine.Width, 2);
// The last line stays start-aligned.
var lastLeft = left.TextLines[left.TextLines.Count - 1];
var lastJustified = justified.TextLines[justified.TextLines.Count - 1];
Assert.Equal(lastLeft.WidthIncludingTrailingWhitespace,
lastJustified.WidthIncludingTrailingWhitespace, 2);
}
private static double LastGlyphAdvance(TextLine line)
{
var glyphs = line.TextRuns.OfType<ShapedTextRun>().Last().GlyphRun.GlyphInfos;
return glyphs[glyphs.Count - 1].GlyphAdvance;
}
private sealed class SplitStyleTextSource : ITextSource
{
private readonly string _text;
private readonly int _splitAt;
private readonly GenericTextRunProperties _first;
private readonly GenericTextRunProperties _second;
public SplitStyleTextSource(string text, int splitAt,
GenericTextRunProperties first, GenericTextRunProperties second)
{
_text = text;
_splitAt = splitAt;
_first = first;
_second = second;
}
public TextRun? GetTextRun(int textSourceIndex)
{
if (textSourceIndex >= _text.Length)
{
return null;
}
if (textSourceIndex < _splitAt)
{
return new TextCharacters(_text.AsMemory(textSourceIndex, _splitAt - textSourceIndex), _first);
}
return new TextCharacters(_text.AsMemory(textSourceIndex), _second);
}
}
private static List<double> GlyphAdvances(TextLine line)
{
var advances = new List<double>();
foreach (var run in line.TextRuns)
{
if (run is ShapedTextRun shaped)
{
foreach (var glyph in shaped.GlyphRun.GlyphInfos)
{
advances.Add(glyph.GlyphAdvance);
}
}
}
return advances;
}
[Fact]
public void Width_Excludes_Only_The_Lines_True_Trailing_Whitespace()
{
using (Start())
{
// Two runs split by a font-size change. The FIRST (interior) run also ends in a
// space of its own ("foo "), but that space is followed by more visible content
// ("bar") in the next run, so it is NOT trailing whitespace for the line as a
// whole - only the space at the true end of the line is. A reference line without
// any trailing space isolates exactly how much Width should differ.
var first = new GenericTextRunProperties(Typeface.Default, 20);
var second = new GenericTextRunProperties(Typeface.Default, 14);
var formatter = new TextFormatterImpl();
var reference = formatter.FormatLine(new SplitStyleTextSource("foo bar", 4, first, second), 0,
double.PositiveInfinity, new GenericTextParagraphProperties(first));
var withTrailingSpace = formatter.FormatLine(new SplitStyleTextSource("foo bar ", 4, first, second), 0,
double.PositiveInfinity, new GenericTextParagraphProperties(first));
Assert.NotNull(reference);
Assert.NotNull(withTrailingSpace);
// Confirm both lines are genuinely multi-run, and the reference truly has no
// trailing whitespace, otherwise the comparison proves nothing.
Assert.True(reference.TextRuns.OfType<ShapedTextRun>().Count() >= 2);
Assert.Equal(0, reference.TrailingWhitespaceLength);
// Only the one added trailing space is excluded - not that space AND "foo "'s own
// interior trailing space too.
Assert.Equal(1, withTrailingSpace.TrailingWhitespaceLength);
Assert.Equal(reference.Width, withTrailingSpace.Width, 3);
}
}
private static void AssertGreaterThan(double x, double y, string message) => Assert.True(x > y, $"{message}. {x} is not > {y}");
private static IDisposable Start()

Loading…
Cancel
Save