From 58a9cf31cd99cab4b7bd9ad1a99f23ea76c57a0a Mon Sep 17 00:00:00 2001 From: Benedikt Stebner Date: Wed, 26 Aug 2026 08:59:54 +0000 Subject: [PATCH] [Text] Justify word gaps that fall on a run boundary (#22065) * Add failing test for a word gap at a run boundary Justification collects break opportunities run by run. The line breaker always reports a break at the end of the text it is given, and that artificial run-final break is filtered out - so a real word gap that happens to fall on a run boundary produces no opportunity at all and is never widened. A line whose word gap borders a fallback run is left partly or entirely unjustified. * Search break opportunities in the line text, not per run A run boundary is not a text boundary - a font fallback or a style change splits a run mid-sentence - but the line breaker reports a break at the end of whatever text it is given. Searching run by run invented a break at every run boundary, and dropping those artefacts also dropped every real word gap that fell on one. - collect the opportunities from each stretch of consecutive text runs, copied into a pooled buffer, and exclude only the stretch-final break - runs that carry no text (embedded objects, end of line) end a stretch, as do runs whose length doesn't match their text, since neither can be mapped back to text source positions by offset --- .../TextFormatting/InterWordJustification.cs | 164 ++++++++++++------ .../Media/TextFormatting/TextLayoutTests.cs | 47 +++++ 2 files changed, 162 insertions(+), 49 deletions(-) diff --git a/src/Avalonia.Base/Media/TextFormatting/InterWordJustification.cs b/src/Avalonia.Base/Media/TextFormatting/InterWordJustification.cs index 692ed09d08..047cf1c412 100644 --- a/src/Avalonia.Base/Media/TextFormatting/InterWordJustification.cs +++ b/src/Avalonia.Base/Media/TextFormatting/InterWordJustification.cs @@ -1,4 +1,5 @@ using System; +using System.Buffers; using System.Collections.Generic; using Avalonia.Media.TextFormatting.Unicode; @@ -44,54 +45,7 @@ namespace Avalonia.Media.TextFormatting var breakOportunities = new Queue(); - 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]; - var text = textRun.Text; - - if (text.IsEmpty) - { - continue; - } - - var lineBreakEnumerator = new LineBreakEnumerator(text.Span); - - while (lineBreakEnumerator.MoveNext(out var currentBreak)) - { - if (!currentBreak.Required && currentBreak.PositionWrap != textRun.Length) - { - // 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); - } - } - - currentPosition += textRun.Length; - } + CollectBreakOpportunities(lineImpl, breakOportunities); if (breakOportunities.Count == 0) { @@ -107,7 +61,7 @@ namespace Avalonia.Media.TextFormatting var remainingSpace = Math.Max(0, paragraphWidth - lineImpl.Width); var spacing = remainingSpace / breakOportunities.Count; - currentPosition = textLine.FirstTextSourceIndex; + var currentPosition = textLine.FirstTextSourceIndex; for (var runIndex = 0; runIndex < lineImpl.TextRuns.Count; runIndex++) { @@ -166,5 +120,117 @@ namespace Avalonia.Media.TextFormatting currentPosition += runLength; } } + + /// + /// Collects the break opportunities of the line, in ascending text source position order. + /// + /// + /// + /// The opportunities are searched in the line's text rather than in each run's text: a run + /// boundary is not a text boundary - a font fallback or a style change splits a run in the + /// middle of a sentence - while the line breaker reports a break at the end of whatever text + /// it is given. Searching run by run therefore invents a break at every run boundary, and + /// filtering those artefacts out drops the real break whenever a word gap falls there. + /// + /// + /// Trailing whitespace needs no special handling. 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 final break is excluded 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. + /// + /// + private static void CollectBreakOpportunities(TextLineImpl lineImpl, Queue breakOportunities) + { + var textRuns = lineImpl.TextRuns; + var currentPosition = lineImpl.FirstTextSourceIndex; + var runIndex = 0; + + while (runIndex < textRuns.Count) + { + // Runs that carry no text (an embedded object, an end of line) interrupt the text, so + // each stretch of text runs between them is searched on its own. + var segmentStart = currentPosition; + var segmentLength = 0; + var segmentEnd = runIndex; + + while (segmentEnd < textRuns.Count) + { + var textRun = textRuns[segmentEnd]; + + // A run whose length doesn't match its text can't be mapped back to text source + // positions by offset, so it ends the segment instead of shifting everything + // after it. + if (textRun.Text.IsEmpty || textRun.Text.Length != textRun.Length) + { + break; + } + + segmentLength += textRun.Length; + ++segmentEnd; + } + + if (segmentLength == 0) + { + currentPosition += textRuns[runIndex].Length; + ++runIndex; + + continue; + } + + var buffer = ArrayPool.Shared.Rent(segmentLength); + + try + { + var offset = 0; + + for (var i = runIndex; i < segmentEnd; i++) + { + var runText = textRuns[i].Text; + + runText.Span.CopyTo(buffer.AsSpan(offset, runText.Length)); + + offset += runText.Length; + } + + var lineBreakEnumerator = new LineBreakEnumerator(buffer.AsSpan(0, segmentLength)); + + while (lineBreakEnumerator.MoveNext(out var currentBreak)) + { + if (currentBreak.Required || currentBreak.PositionWrap == segmentLength) + { + continue; + } + + // 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 + // segment-final one, already excluded above. + var target = segmentStart + currentBreak.PositionMeasure; + + if (currentBreak.PositionMeasure == currentBreak.PositionWrap) + { + target -= 1; + } + + breakOportunities.Enqueue(target); + } + } + finally + { + ArrayPool.Shared.Return(buffer); + } + + currentPosition = segmentStart + segmentLength; + runIndex = segmentEnd; + } + } } } diff --git a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLayoutTests.cs b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLayoutTests.cs index e46fa69531..1d33defa83 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLayoutTests.cs +++ b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLayoutTests.cs @@ -1562,6 +1562,53 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting } } + [Fact] + public void Justify_Distributes_Across_A_Word_Gap_At_A_Run_Boundary() + { + using (Start()) + { + // The emoji needs a fallback font, so this line's word gaps sit next to a run + // boundary. Break opportunities are collected run by run, and the break the + // enumerator always reports at the end of the text it is given is discarded as an + // artifact - so a real word gap that coincides with a run boundary yields no + // opportunity at all and never widens. + const string text = "abc \U0001F600 def"; + + var defaultProperties = new GenericTextRunProperties(Typeface.Default); + var textSource = new SingleBufferTextSource(text, defaultProperties); + var formatter = new TextFormatterImpl(); + + TextLine Format() + => formatter.FormatLine(textSource, 0, double.PositiveInfinity, + new GenericTextParagraphProperties(defaultProperties))!; + + static double Gap(TextLine line, int spaceIndex) + => line.GetDistanceFromCharacterHit(new CharacterHit(spaceIndex + 1)) + - line.GetDistanceFromCharacterHit(new CharacterHit(spaceIndex)); + + var reference = Format(); + + var firstGapBefore = Gap(reference, 3); + var secondGapBefore = Gap(reference, 6); + + var textLine = Format(); + + // Confirm the line really is multi-run, otherwise the test proves nothing. + AssertGreaterThan(textLine.TextRuns.Count, 1, "The emoji should force a fallback run"); + + textLine.Justify(new InterWordJustification(textLine.WidthIncludingTrailingWhitespace + 40)); + + var firstGap = Gap(textLine, 3); + var secondGap = Gap(textLine, 6); + + AssertGreaterThan(firstGap, firstGapBefore, "The first word gap should be widened"); + AssertGreaterThan(secondGap, secondGapBefore, "The second word gap should be widened"); + + // Both gaps are break opportunities, so they take an equal share of the added width. + Assert.Equal(firstGap - firstGapBefore, secondGap - secondGapBefore, 3); + } + } + [Theory] [InlineData("aa bb ")] // trailing ASCII spaces [InlineData("一二   ")] // trailing ideographic (U+3000) spaces