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