Browse Source

[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
pull/22071/head
Benedikt Stebner 1 month ago
committed by GitHub
parent
commit
58a9cf31cd
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 164
      src/Avalonia.Base/Media/TextFormatting/InterWordJustification.cs
  2. 47
      tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLayoutTests.cs

164
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<int>();
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;
}
}
/// <summary>
/// Collects the break opportunities of the line, in ascending text source position order.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
private static void CollectBreakOpportunities(TextLineImpl lineImpl, Queue<int> 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<char>.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<char>.Shared.Return(buffer);
}
currentPosition = segmentStart + segmentLength;
runIndex = segmentEnd;
}
}
}
}

47
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

Loading…
Cancel
Save