Skip to content

Improve white space hanging and justification - #762

Merged
tomcur merged 11 commits into
linebender:mainfrom
tomcur:hanging-and-justification
Sep 7, 2026
Merged

Improve white space hanging and justification#762
tomcur merged 11 commits into
linebender:mainfrom
tomcur:hanging-and-justification

Conversation

@tomcur

@tomcur tomcur commented Sep 3, 2026

Copy link
Copy Markdown
Member

LLM Contributions: Investigation, added tests.

On top of #760.

White space across runs (e.g., because of a font size change) can now all hang, like Gecko and Blink. Following CSS Text 4 § 4.3.2 (and Gecko and Blink), non-breaking spaces no longer hang.

This moves the white space hanging logic and justification opportunity counting into finish_line, i.e., these are now performed when the line is known. This means we now perform only a single walk to count justification opportunities, where previously we walked forward once, and then had to walk backwards to subtract trailing spaces.

With more of the logic in one place, it should also be simpler to keep these in sync (previously, the justification count effectively re-implemented the logic of which white space was considered to be hanging).

WPT test results

Note the line-break: anywhere tests were accidentally passing before, as we don't implement that behavior.

Pass => Fail css/css-text/line-break/line-break-anywhere-overrides-uax-behavior-004.html
Pass => Fail css/css-text/line-break/line-break-anywhere-overrides-uax-behavior-006.html
Fail => Pass css/css-text/text-align/text-align-end-009.html
Fail => Pass css/css-text/text-justify/text-justify-and-trailing-spaces-001.html
Fail => Pass css/css-text/text-justify/text-justify-and-trailing-spaces-002.html
Fail => Pass css/css-text/text-justify/text-justify-and-trailing-spaces-003.html
Fail => Pass css/css-text/text-justify/text-justify-and-trailing-spaces-004.html
Fail => Pass css/css-text/white-space/white-space-nowrap-011.html
Fail => Pass css/css-text/white-space/white-space-pre-wrap-justify-003.html
Fail => Pass css/css-text/word-break/word-break-break-all-018.html
Fail => Pass css/css-text/word-break/word-break-break-all-021.html

Performance

This benches mostly neutral on my machine.

$ cargo bench --bench main -- compare ../target/benchmarks/main -t 8.

Default Style - arabic 20 characters               [   9.2 us ...   9.1 us ]      -0.37%
Default Style - latin 20 characters                [   4.4 us ...   4.4 us ]      +0.60%
Default Style - japanese 20 characters             [   7.9 us ...   7.8 us ]      -1.50%*
Default Style - arabic 1 paragraph                 [  50.0 us ...  49.9 us ]      -0.18%
Default Style - latin 1 paragraph                  [  17.9 us ...  17.9 us ]      -0.08%
Default Style - japanese 1 paragraph               [  64.6 us ...  64.1 us ]      -0.74%
Default Style - arabic 4 paragraph                 [ 212.5 us ... 214.5 us ]      +0.97%
Default Style - latin 4 paragraph                  [  67.9 us ...  68.1 us ]      +0.27%
Default Style - japanese 4 paragraph               [  91.2 us ...  90.5 us ]      -0.76%
Styled - arabic 20 characters                      [  10.4 us ...  10.4 us ]      -0.44%
Styled - latin 20 characters                       [   5.6 us ...   5.6 us ]      +0.68%
Styled - japanese 20 characters                    [   8.4 us ...   8.4 us ]      -0.85%
Styled - arabic 1 paragraph                        [  53.3 us ...  52.3 us ]      -1.91%*
Styled - latin 1 paragraph                         [  22.5 us ...  22.4 us ]      -0.43%
Styled - japanese 1 paragraph                      [  71.9 us ...  71.1 us ]      -1.01%*
Styled - arabic 4 paragraph                        [ 238.2 us ... 241.5 us ]      +1.40%*
Styled - latin 4 paragraph                         [  87.3 us ...  87.4 us ]      +0.16%
Styled - japanese 4 paragraph                      [ 101.5 us ... 100.5 us ]      -0.98%
Word + Letter Spacing - arabic 20 characters       [   9.2 us ...   9.2 us ]      -0.33%
Word + Letter Spacing - latin 20 characters        [   4.6 us ...   4.5 us ]      -1.22%*
Word + Letter Spacing - japanese 20 characters     [   8.0 us ...   7.8 us ]      -1.97%*
Word + Letter Spacing - arabic 1 paragraph         [  50.9 us ...  50.7 us ]      -0.37%
Word + Letter Spacing - latin 1 paragraph          [  18.7 us ...  18.2 us ]      -2.58%*
Word + Letter Spacing - japanese 1 paragraph       [  66.5 us ...  65.3 us ]      -1.79%*
Word + Letter Spacing - arabic 4 paragraph         [ 214.4 us ... 216.6 us ]      +1.03%*
Word + Letter Spacing - latin 4 paragraph          [  69.6 us ...  68.0 us ]      -2.23%*
Word + Letter Spacing - japanese 4 paragraph       [  93.3 us ...  91.8 us ]      -1.64%*
Repeated Justification - latin 4 paragraph         [ 781.3 ns ... 782.5 ns ]      +0.15%

Changelog

Changed

  • Breaking change: LineMetrics::trailing_whitespace was renamed to LineMetrics::hanging_advance. Following CSS Text 4 § 4.3.2, non-breaking spaces no longer hang past line ends.

Fixed

  • Trailing white space consisting of separate runs (e.g., due to font size changes), now hangs past line ends.

@tomcur
tomcur force-pushed the hanging-and-justification branch from 5f2fcf7 to 1218f0d Compare September 3, 2026 10:35

@tomcur tomcur Sep 3, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Prior to this PR, this rendered as follows. Note the NBSP was erroneously hung.

Image

@tomcur tomcur Sep 3, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Prior to this PR, this rendered as follows. Note the NBSP was erroneously hung.

Image

@tomcur

tomcur commented Sep 3, 2026

Copy link
Copy Markdown
Member Author

Compare the rendering of the non-hanging NBSP with Blink. Both Parley and Blink don't justify it.

Parley Blink
trailing_nbsp_doesnt_hang_but_also_doesnt_justify_ltr-0 developer mozilla org_en-US_play_id=1oxEK2ubK6Rz8zUu4wQR%2FsY7iaXPjrOG9Z%2Bq8LIu8hb9JWRQqiASOB01k%2BoMN9N%2Fsv3SBzGTPgFAn%2FB%2F (1)

https://developer.mozilla.org/en-US/play?id=zr6f%2BIbFkT%2BYC8XcUxCer9is36%2FSibiKh7xLSiNnvBc555KFf9J7WDAsF6O25KcLqKLoJq%2Fa%2FBOXuxKL

For the RTL case, Blink seems somewhat inconsistent, as it then does justify the line-ending NBSP. I think that's just accidental, and may not necessarily be something we want to match. (Note in these screenshots there's some additional word-spacing, just so the interaction of that with hanging and justification is also tested; in the Parley image the justified interior spaces are noticeably larger than the NBSP.)

Parley Blink
trailing_nbsp_doesnt_hang_but_also_doesnt_justify_rtl-0 developer mozilla org_en-US_play_id=1oxEK2ubK6Rz8zUu4wQR%2FsY7iaXPjrOG9Z%2Bq8LIu8hb9JWRQqiASOB01k%2BoMN9N%2Fsv3SBzGTPgFAn%2FB%2F (3)

https://developer.mozilla.org/en-US/play?id=%2Bk4B7GygWON7rtij3YsedsaaHSSIkGz4tRFAeRuTJYAgm5exWNGCCLd7Q97H0tppVrVAHM1K9alKqMpa

@nicoburns nicoburns left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This definitely looks like an improvement to code clarity. Particular appreciate the variable renames.

A couple of things I think this needs:

  1. Update calculate_content_widths to match new hanging logic. This still only subtracts the single last atom (prev_atom), whereas line breaking now hangs a full run of trailing spaces. With white-space: pre-wrap the intrinsic sizes will disagree with the laid-out width. These ones bite Blitz which relies on content widths matching layout widths, so I'd consider this quite important (probably we should add tests for this).
  2. Ideally the "other whitespace separators" (but could be follow-up)
  3. [LLM] (relatively minor?) Bidi: trailing space in a non-base-level run now gets stretched.
    • commit_line previously unconditionally excluded the trailing space from num_spaces. Now, when bidi_level != base_level, hanging is set false and the trailing space falls into the else if is_word_separator branch, so it's counted as an opportunity and still occupies space.
    • Affects: e.g. an LTR paragraph whose line ends in an RTL run + space.
    • Suggestion: at least excluding the logically last atom from opportunities in that case (as before).

I'd consider 1 blocking, others can be deferred if preferred.

This means we now perform only a single walk to count justification opportunities, where previously we walked forward once, and then had to walk backwards to subtract trailing spaces.

In practice I think this is more work as we're doing 1 walk of the whole line (previously 0) in order to elide a walk of just the trailing whitespace. Although some LLM benchmarks seemed to show that the effect is small.

Comment thread parley/src/layout/data.rs Outdated
Comment on lines +287 to +291
atom.filter(|(whitespace, _)| {
matches!(
whitespace,
Whitespace::Space | Whitespace::Tab | Whitespace::Newline
)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Could we (additionally) include all unicode category Z here? LLM suggest adding a new SpaceSeparator variant to Whitespace to capture this.

See: https://drafts.csswg.org/css-text/#white-space-rules

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Either way, could we make this a method on Whitespace?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

We could add Zs with a bit of a refactor of Whitespace.

However, from my testing, Gecko and Blink don't seem to hang Zs at the moment.

Firefox and Chromium on my machine don't hang the Em space in the following (try replacing with a bunch of &VeryThinkSpace; and you should see the same thing again).

https://developer.mozilla.org/en-US/play?id=LjApsOPvEPwlF%2FQ%2B6jTO9ZIqz0zTlp231UFnej4cRRe5EdEkhHWhaP2PwqEjnfSZXMbsTnpxTuhNOnJi

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Either way, could we make this a method on Whitespace?

Yeah, good idea. I've made it a private function in parley for now (instead of a public method in parley_engine), as by the above apparently the spec isn't strictly followed (yet?).

Comment thread parley/src/layout/line_break.rs Outdated
Comment thread parley/src/layout/data.rs Outdated
Comment thread parley/src/layout/spacing.rs Outdated
Comment thread parley/src/layout/line_break.rs Outdated
Comment thread parley/src/layout/line_break.rs Outdated
// extents) are *not* computed here: they were already accumulated into `self.state.line` as
// the line was built and are read from there below.
let mut needs_reorder = false;
let mut hanging = true;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The hanging variable could maybe do with some explanation as a comment. My understanding is that it's iteration state where we iterate back through the line from the logical end and need to track if we're still tracking hanging whitespace at the end or not.

In general, it would be helpful for these variables to distinguish iteration state from outputs (perhaps put them in separate blocks of code?)

@tomcur tomcur Sep 7, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I've moved the variable to be just before the loop and added a comment. It could be a separate code block, but it's already quite deeply nested, so then extracting to a separate function might be called for.

Comment thread parley/src/layout/line_break.rs Outdated
Comment on lines +1252 to +1257
let slice = self
.layout
.data
.shaped_text
.run_slice(line_item.index as u32)
.narrow(line_item.shaped_cluster_range.clone());

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I'm a bit confused about how we're skipping trailing whitespace here, but then still checking is cluster_hangs below. How does anything hang if we're skipping hanging clusters?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I think the code comment just above this code range was simply slightly confusing, as the code range your comment is placed on doesn't skip trailing whitespace. The comment was intended for the full loop.

I've moved the slice binding upward (which also deduplicates some code).

tomcur pushed a commit to tomcur/parley that referenced this pull request Sep 3, 2026
Adds long-single-line benchmarks to measure cases where performance is
`O(line length)`
These have been used to test
linebender#762

**LLM Contributions**: Generated with Fable 5.1 Low

**Changelog**: None
@tomcur
tomcur force-pushed the hanging-and-justification branch from 1218f0d to 42a816f Compare September 3, 2026 12:26
staging-devin-ai-integration Bot pushed a commit to DioxusLabs/blitz that referenced this pull request Sep 3, 2026
staging-devin-ai-integration Bot pushed a commit to DioxusLabs/parley that referenced this pull request Sep 3, 2026
NBSP handling in min/max content widths is covered by the upstream
hanging-whitespace rework (linebender#762), so leave
calculate_content_widths as on main.
staging-devin-ai-integration Bot pushed a commit to DioxusLabs/parley that referenced this pull request Sep 3, 2026
NBSP handling in min/max content widths is covered by the upstream
hanging-whitespace rework (linebender#762), so leave
calculate_content_widths as on main.
staging-devin-ai-integration Bot pushed a commit to DioxusLabs/parley that referenced this pull request Sep 3, 2026
staging-devin-ai-integration Bot pushed a commit to DioxusLabs/parley that referenced this pull request Sep 3, 2026
staging-devin-ai-integration Bot pushed a commit to DioxusLabs/parley that referenced this pull request Sep 3, 2026
staging-devin-ai-integration Bot pushed a commit to DioxusLabs/parley that referenced this pull request Sep 3, 2026
staging-devin-ai-integration Bot pushed a commit to DioxusLabs/parley that referenced this pull request Sep 3, 2026
staging-devin-ai-integration Bot pushed a commit to DioxusLabs/parley that referenced this pull request Sep 3, 2026
staging-devin-ai-integration Bot pushed a commit to DioxusLabs/parley that referenced this pull request Sep 3, 2026
staging-devin-ai-integration Bot pushed a commit to DioxusLabs/parley that referenced this pull request Sep 3, 2026
@tomcur
tomcur force-pushed the hanging-and-justification branch from 12d9be5 to 8c876c9 Compare September 4, 2026 14:14

@DJMcNab DJMcNab 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.

This looks correct to me. I don't have a strong opinion on whether the Z category should be here or a follow-up.

Comment thread parley/src/layout/line.rs Outdated
Comment on lines +142 to +143
// Note: only whitespace hangs at the moment. With something like hanging punctuation, content
// could also hang past the start edge.

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.

With something like hanging punctuation, content could also hang past the start edge.

That would be a different field, right? Just based on this being documented as the amount that hangs from the end edge.

This comment is a little bit confusing.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Yep, I've improved the comment's clarity somewhat (hopefully).

tomcur added a commit to tomcur/parley that referenced this pull request Sep 7, 2026
Measures `Layout::calculate_content_widths`. This function will need
some work to match the new line breaking (and linebender#762 in particular), so
let's start measuring its performance.
@tomcur
tomcur force-pushed the hanging-and-justification branch from 04d832a to 03d9e08 Compare September 7, 2026 09:09
@tomcur

tomcur commented Sep 7, 2026

Copy link
Copy Markdown
Member Author

Nico and I spoke out-of-band and thought to do the calculate_content_widths update as a follow-up instead (it'll be substantial enough to warrant another review, and in general that function has some more correctness issues).

@tomcur
tomcur enabled auto-merge September 7, 2026 09:50
@tomcur
tomcur added this pull request to the merge queue Sep 7, 2026
Merged via the queue into linebender:main with commit fe908ef Sep 7, 2026
24 checks passed
@tomcur
tomcur deleted the hanging-and-justification branch September 7, 2026 09:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants