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()