Repository navigation
[Text] InterWordJustification fixes - #21701
Merged
MrJul merged 5 commits intoJul 6, 2026
Merged
Conversation
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.
|
You can test this PR using the following package version. |
MrJul
added this pull request to the merge queue
Jul 6, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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?
ShapedBufferglyph storage in place. That storage can be shared with aTextRunCacheentry, aSplitsibling, or aWithBidiLevelalias, so justifying a line can corrupt glyph data another consumer still relies on.MaxWidth. Since a wrapped paragraph's widest line is frequently narrower than the column,TextAlignment.Justifyis close to a no-op for wrapped text in the common case.\n), are stretched to the full paragraph width instead of staying start-aligned, contrary to standard typographic convention (WPF, Word, most typesetting).TextLineImpl.CreateLineMetricswalks 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-reportsWidth, whichInterWordJustificationuses 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.Justifycombined withTextWrapping.Wrapfills every wrapped, non-last, non-hard-broken line toMaxWidth.TextRunCacheentry or another run; a cursive, multi-glyph-cluster script (Arabic) exercises this safely too.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'sShapedBufferis copied via a newShapedBuffer.CloneWritable()before any advance is modified, and the modified copy is spliced back in via a newTextLineImpl.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 isMaxWidth(guarded for the infinite case) instead ofWidthIncludingTrailingWhitespace/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:remainingSpaceis computed against the line's visibleWidth, notWidthIncludingTrailingWhitespace, 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 fromWidth.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
TextAlignment.Justifybehavior; no new public API or documented behavior is being introduced.Breaking changes
No public API changes. The rendered output of
TextAlignment.Justifychanges visibly wherever it was previously broken:Obsoletions / Deprecations
None.
Fixed issues
Fixes #15198
Fixes #19889