Skip to content

[Text] InterWordJustification fixes - #21701

Merged
MrJul merged 5 commits into
AvaloniaUI:masterfrom
Gillibald:fix/interword-justification-cjk
Jul 6, 2026
Merged

MrJul merged 5 commits into
AvaloniaUI:masterfrom
Gillibald:fix/interword-justification-cjk

Conversation

@Gillibald

@Gillibald Gillibald commented Jul 3, 2026 •

Copy link
Copy Markdown
Contributor

What does the pull request do?

Fixes several bugs in TextAlignment.Justify (InterWordJustification) that made it distribute space incorrectly, and in one case unsafely, for wrapped and/or non-Latin text. Justification for simple, single-line, single-run Latin text was already correct; the bugs are in wrapping, multi-run lines, and CJK/Korean/mixed-script content.

What is the current behavior?

  • Zero-width break opportunities (CJK/Korean ideograph and syllable boundaries, hyphens) target the glyph after the break instead of the glyph the break actually falls between. On a CJK or Korean line this stretches the last visible glyph, adding a trailing gap after the final character, and leaves the first inter-character gap unwidened.
  • Justification mutates a run's ShapedBuffer glyph storage in place. That storage can be shared with a TextRunCache entry, a Split sibling, or a WithBidiLevel alias, so justifying a line can corrupt glyph data another consumer still relies on.
  • On a multi-run line (a script change, a style change, or a wrap-induced split), the break-opportunity queue is drained entirely against the first run. Every later run on the same line receives no justification spacing at all.
  • Wrapped text is justified to the width of the widest produced line, not to MaxWidth. Since a wrapped paragraph's widest line is frequently narrower than the column, TextAlignment.Justify is close to a no-op for wrapped text in the common case.
  • The last line of a justified paragraph, and any line ending in a hard break (\n), are stretched to the full paragraph width instead of staying start-aligned, contrary to standard typographic convention (WPF, Word, most typesetting).
  • TextLineImpl.CreateLineMetrics walks a line's runs backward to compute trailing whitespace, but only stops once it reaches a run with no trailing whitespace of its own, not once it reaches a run that has visible content before its own trailing whitespace. On a multi-run line where an interior run also happens to end in a space (common once a line contains a script boundary, since each script run tends to end at the following space), that interior space is wrongly counted as the line's trailing whitespace too, even though visible content follows it later in the line. This under-reports Width, which InterWordJustification uses to decide how much space a line still needs, so wrapped multi-run lines (e.g. mixed Latin+CJK text) get stretched past the target width and their trailing content is clipped by the containing control.

What is the updated/expected behavior with this PR?

  • TextAlignment.Justify combined with TextWrapping.Wrap fills every wrapped, non-last, non-hard-broken line to MaxWidth.
  • CJK and Korean lines distribute space between characters/syllables without stretching the trailing glyph; the leading gap participates like every other gap.
  • Mixed-script lines (e.g. Latin + CJK on one line) distribute space across both inter-word and inter-ideograph gaps.
  • The last line of a paragraph, and lines ending in an explicit line break, stay start-aligned; the wrapped lines around them still justify normally.
  • Justifying a line never mutates glyph storage shared with a TextRunCache entry or another run; a cursive, multi-glyph-cluster script (Arabic) exercises this safely too.
  • Multi-run lines (produced by a script change, a style change, or a wrap split) justify every run, not just the first.

How was the solution implemented (if it's not obvious)?

  • InterWordJustification.Justify (enqueue pass): for a zero-width break (PositionMeasure == PositionWrap - CJK/Korean ideograph boundaries, hyphens, other break-after punctuation), the target position now steps back one glyph so the added advance lands on the glyph ending at the break boundary instead of the glyph one past it. Whitespace breaks are unchanged (the line breaker already pulls their measure position back onto the whitespace glyph). This also makes the last visible glyph of a CJK/Korean line naturally immune to stretching: its only inbound break is the run-final one, which is already excluded.
  • InterWordJustification.Justify (apply pass): each run's ShapedBuffer is copied via a new ShapedBuffer.CloneWritable() before any advance is modified, and the modified copy is spliced back in via a new TextLineImpl.ReplaceTextRun(index, textRun), which also re-points the line's bidi-reordered run list (draw and hit-testing resolve runs through that list, not just _textRuns) so they observe the replacement. The original run is disposed after the swap. The apply pass now also stops consuming the break queue at each run's own end (characterIndex >= runEnd) instead of draining it entirely against the first run.
  • TextLayout.CreateTextLines: the justification target is MaxWidth (guarded for the infinite case) instead of WidthIncludingTrailingWhitespace/widest-line for wrapped text, and the loop now skips a line when it is the layout's last line, ends in a newline (NewLineLength > 0), or ends in a source-provided required break (TextLineBreak.TextEndOfLine != null).
  • InterWordJustification.Justify: remainingSpace is computed against the line's visible Width, not WidthIncludingTrailingWhitespace, so a wrapped line's own trailing whitespace at the wrap point no longer suppresses justification for that line.
  • TextLineImpl.CreateLineMetrics: the backward trailing-whitespace walk now stops after a run whose trailing whitespace does not consume the run's entire length, since that run's visible content - not an earlier run - is the line's true end. An earlier run's own trailing whitespace is interior to the line in that case and must not be excluded from Width.
  • Added InterWordJustification's class-level XML doc describing the two scripts it does not attempt to handle: Thai/Lao/Khmer/Myanmar (no line-break opportunities without dictionary/ML word segmentation, so such lines stay un-justified rather than being spaced incorrectly) and Arabic/Hebrew (inter-word spacing rather than the idiomatic kashida/tatweel elongation, which needs shaper-level support).

Checklist

  • Added unit tests (if possible)?
  • Added XML documentation to any related classes?
  • Consider submitting a PR to https://github.com/AvaloniaUI/avalonia-docs with user documentation
    • Not applicable: this fixes existing, already-documented TextAlignment.Justify behavior; no new public API or documented behavior is being introduced.

Breaking changes

No public API changes. The rendered output of TextAlignment.Justify changes visibly wherever it was previously broken:

  • Wrapped paragraphs now actually stretch to the column width (they mostly did not before).
  • CJK, Korean, and mixed-script lines redistribute space between characters, not just at inter-word gaps, and no longer overshoot on the trailing glyph.
  • The last line of a justified paragraph, and lines ending in a hard break, are no longer stretched (now start-aligned, per standard typographic convention).
  • Latin lines with a hyphen or other break-after punctuation redistribute the space around that break slightly differently (the same glyph-targeting fix that corrects CJK also affects this shared code path).

Obsoletions / Deprecations

None.

Fixed issues

Fixes #15198
Fixes #19889

Gillibald added 5 commits July 3, 2026 12:47
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.
- 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.
- 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.
…tification-cjk

# Please enter a commit message to explain why this merge is necessary,
# especially if it merges an updated upstream into a topic branch.
#
# Lines starting with '#' will be ignored, and an empty message aborts
# the commit.
@Gillibald Gillibald added bug area-textprocessing backport-candidate-12.0.x Consider this PR for backporting to 12.0 branch labels Jul 3, 2026
@avaloniaui-bot

Copy link
Copy Markdown

You can test this PR using the following package version. 12.1.999-cibuild0067139-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

@MrJul MrJul mentioned this pull request Jul 3, 2026
2 of 3 tasks

@MrJul MrJul left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

@MrJul
MrJul added this pull request to the merge queue Jul 6, 2026
Merged via the queue into AvaloniaUI:master with commit 20c1d6a Jul 6, 2026
12 checks passed
@MrJul MrJul removed the backport-candidate-12.0.x Consider this PR for backporting to 12.0 branch label Jul 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

TextAlignment="Justify" in textblocks justifies also the last line. Don't justify the last line in a TextBlock

3 participants