From 20c1d6a2e2bc8a6d9502c4f2c2bc3011bb66da23 Mon Sep 17 00:00:00 2001 From: Benedikt Stebner Date: Mon, 6 Jul 2026 12:05:12 +0200 Subject: [PATCH] [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 --- .gitignore | 1 + .../Media/TextFormatting/IndexedTextRun.cs | 2 +- .../TextFormatting/InterWordJustification.cs | 117 +++- .../Media/TextFormatting/ShapedBuffer.cs | 17 + .../Media/TextFormatting/TextLayout.cs | 24 +- .../Media/TextFormatting/TextLineImpl.cs | 42 ++ .../Media/TextFormatting/TextLayoutTests.cs | 567 ++++++++++++++++++ 7 files changed, 737 insertions(+), 33 deletions(-) diff --git a/.gitignore b/.gitignore index 558b91f822..e2e3a8ef73 100644 --- a/.gitignore +++ b/.gitignore @@ -228,3 +228,4 @@ src/Browser/Avalonia.Browser/staticwebassets /planning BenchmarkDotNet.Artifacts.* .tokensave +/CLAUDE.md diff --git a/src/Avalonia.Base/Media/TextFormatting/IndexedTextRun.cs b/src/Avalonia.Base/Media/TextFormatting/IndexedTextRun.cs index 0eb98533d2..3c9d0894c7 100644 --- a/src/Avalonia.Base/Media/TextFormatting/IndexedTextRun.cs +++ b/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; } } } diff --git a/src/Avalonia.Base/Media/TextFormatting/InterWordJustification.cs b/src/Avalonia.Base/Media/TextFormatting/InterWordJustification.cs index 75249ff7e7..692ed09d08 100644 --- a/src/Avalonia.Base/Media/TextFormatting/InterWordJustification.cs +++ b/src/Avalonia.Base/Media/TextFormatting/InterWordJustification.cs @@ -4,6 +4,21 @@ using Avalonia.Media.TextFormatting.Unicode; namespace Avalonia.Media.TextFormatting { + /// + /// 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). + /// + /// + /// Known limitations: + /// + /// 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. + /// Arabic and Hebrew are justified with inter-word spacing rather than the idiomatic + /// kashida (tatweel) elongation, which would require shaper-level support. + /// + /// 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; } } } diff --git a/src/Avalonia.Base/Media/TextFormatting/ShapedBuffer.cs b/src/Avalonia.Base/Media/TextFormatting/ShapedBuffer.cs index 7be486717e..0a9f2f1e86 100644 --- a/src/Avalonia.Base/Media/TextFormatting/ShapedBuffer.cs +++ b/src/Avalonia.Base/Media/TextFormatting/ShapedBuffer.cs @@ -562,6 +562,23 @@ namespace Avalonia.Media.TextFormatting _glyphRef, prefixRef, startsRef, startIdx, count, _cacheGeneration); } + /// + /// Creates a deep copy backed by a fresh, non-pooled array so the + /// copy's advances can be mutated (e.g. by justification) without touching glyph storage + /// shared with a entry, a sibling or a + /// alias. The copy owns no ref-counted pooled handles (its + /// _glyphRef is null) and builds its own cluster cache lazily. + /// + internal ShapedBuffer CloneWritable() + { + var span = _glyphInfos.Span; + var glyphs = new GlyphInfo[span.Length]; + + span.CopyTo(glyphs); + + return new ShapedBuffer(Text, new ArraySlice(glyphs), GlyphTypeface, FontRenderingEmSize, BidiLevel); + } + int IReadOnlyCollection.Count => _glyphInfos.Length; IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); diff --git a/src/Avalonia.Base/Media/TextFormatting/TextLayout.cs b/src/Avalonia.Base/Media/TextFormatting/TextLayout.cs index 0e7a624d17..a72e9e875e 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextLayout.cs +++ b/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); } } diff --git a/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs b/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs index 6437d04298..2289f9508e 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs +++ b/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs @@ -197,6 +197,39 @@ namespace Avalonia.Media.TextFormatting _textLineMetrics = CreateLineMetrics(); } + /// + /// Replaces the run at in and re-points the + /// matching bidi-reordered at the new run, so draw and hit-test + /// paths that resolve runs through observe the replacement. + /// Used by justification to swap in a copy-on-write run without mutating shared glyph storage. + /// + 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; + } + } + } + /// 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; + } } } diff --git a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLayoutTests.cs b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLayoutTests.cs index 3c688d8e79..1c8ad72f0f 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLayoutTests.cs +++ b/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().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().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().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().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().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().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 GlyphAdvances(TextLine line) + { + var advances = new List(); + + 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().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()