Skip to content

Improve spacing and justification model, stop mutating advances in-place - #738

Merged
tomcur merged 25 commits into
linebender:mainfrom
tomcur:improved-spacing
Aug 29, 2026
Merged

Improve spacing and justification model, stop mutating advances in-place#738
tomcur merged 25 commits into
linebender:mainfrom
tomcur:improved-spacing

Conversation

@tomcur

@tomcur tomcur commented Aug 7, 2026

Copy link
Copy Markdown
Member

LLM Contributions: Investigation, review, tests.

This replaces the mutating letter/word spacing and justification with a lazy calculation. Stopping mutating is important for reshaping.

The new spacing.rs module encodes the spacing logic. It's similar to the model that Blink and Gecko have, where spacing can be applied visually to the left or right of a shaped cluster. Currently, letter and word spacing are only ever applied visually to the right, following Blink; Gecko, on the other hand, applies these to the logically trailing side. With other CSS styles (like text-justify: inter-character), spacing would be applied on both sides, so I figured I'd just add it now. It's wired up and Should Just Work ™️.

(There's a CSS Working Group discussion considering, e.g., whether letter spacing should be applied on both sides: w3c/csswg-drafts#10193 (comment).)

We now decide whether to apply spacing based on atoms/graphemes, which gets us closer to Blink and Gecko.

For example, a combining acute accent attaches to a preceding space and forms a grapheme. We now space those correctly, even if the constituent characters shape into separate shaped clusters.

letter-spacing with multi-shaped-cluster grapheme word-spacing with multi-shaped-cluster grapheme
Parley before letter_spacing_and_multi_glyph_graphemes-0 word_spacing_and_multi_glyph_graphemes-0
Parley now spacing_and_multi_shaped_cluster_graphemes-letter spacing_and_multi_shaped_cluster_graphemes-word
Chromium letter-spacing word-spacing

To see this in your own browser: https://developer.mozilla.org/en-US/play?id=xhx5oFVuorhlPTrMH2xQcApXn32uuxO8hGXLfMMCrjXonfGpgZ13na9OawGhcV4CLixSnStRwgkRGREZ.

I've also fixed one space counting bug, where a hanging trailing space was not counted, but justification would later subtract it, meaning the last interior space would not get any justification. The following has word-spacing to make the issue a bit more obvious. Note the word "amet" on the first line is too far right in the "before" image. In the "now" image, the two interior spaces are of equal width.

Justification
Parley before justify_with_overflowing_trailing_space-0
Parley now justify_with_overflowing_trailing_space-0

This is mostly neutral to a small performance improvement. (Measured with the benches in #737.)

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

Default Style - arabic 20 characters               [   9.0 us ...   8.9 us ]      -1.22%*
Default Style - latin 20 characters                [   4.4 us ...   4.3 us ]      -2.35%*
Default Style - japanese 20 characters             [   8.7 us ...   8.8 us ]      +1.20%*
Default Style - arabic 1 paragraph                 [  49.3 us ...  48.8 us ]      -1.14%*
Default Style - latin 1 paragraph                  [  17.8 us ...  17.4 us ]      -2.15%*
Default Style - japanese 1 paragraph               [  73.3 us ...  73.3 us ]      -0.08%
Default Style - arabic 4 paragraph                 [ 207.2 us ... 205.2 us ]      -0.97%
Default Style - latin 4 paragraph                  [  68.0 us ...  66.6 us ]      -2.02%*
Default Style - japanese 4 paragraph               [ 103.9 us ... 103.5 us ]      -0.32%
Styled - arabic 20 characters                      [  10.1 us ...  10.0 us ]      -0.27%
Styled - latin 20 characters                       [   5.6 us ...   5.5 us ]      -1.33%*
Styled - japanese 20 characters                    [   9.3 us ...   9.4 us ]      +1.25%*
Styled - arabic 1 paragraph                        [  51.3 us ...  51.2 us ]      -0.17%
Styled - latin 1 paragraph                         [  22.0 us ...  22.0 us ]      -0.23%
Styled - japanese 1 paragraph                      [  79.4 us ...  79.9 us ]      +0.61%
Styled - arabic 4 paragraph                        [ 228.4 us ... 226.1 us ]      -1.00%*
Styled - latin 4 paragraph                         [  86.0 us ...  86.0 us ]      +0.08%
Styled - japanese 4 paragraph                      [ 112.4 us ... 112.8 us ]      +0.29%
Word + Letter Spacing - arabic 20 characters       [   9.0 us ...   8.9 us ]      -0.88%
Word + Letter Spacing - latin 20 characters        [   4.4 us ...   4.4 us ]      -1.14%*
Word + Letter Spacing - japanese 20 characters     [   8.7 us ...   8.8 us ]      +0.84%
Word + Letter Spacing - arabic 1 paragraph         [  49.0 us ...  48.8 us ]      -0.37%
Word + Letter Spacing - latin 1 paragraph          [  17.7 us ...  17.8 us ]      +0.14%
Word + Letter Spacing - japanese 1 paragraph       [  73.7 us ...  74.2 us ]      +0.72%
Word + Letter Spacing - arabic 4 paragraph         [ 207.8 us ... 205.8 us ]      -0.97%
Word + Letter Spacing - latin 4 paragraph          [  66.0 us ...  66.2 us ]      +0.30%
Word + Letter Spacing - japanese 4 paragraph       [ 104.1 us ... 104.6 us ]      +0.47%

Repeated justification is of course quite a bit faster.

$ cargo bench --bench main -- compare ../target/benchmarks/main -t 8. --filter "Repeated*"

Repeated Justification - latin 4 paragraph         [   1.1 us ... 726.3 ns ]     -31.92%*

Changelog

Fixed

Parley

  • Layout::width no longer under-reports widths when there are explicit newlines present and the style has non-zero letter spacing. Previously, an explicit newline would erroneously subtract the configured letter spacing from its line.
  • Justification of lines with overflowing trailing spaces now correctly distributes free space over all interior spaces.
  • Letter and word spacing no longer tear multi-glyph graphemes apart.

@tomcur
tomcur force-pushed the improved-spacing branch from 1f0e42a to 50feabe Compare August 7, 2026 19:22

@tomcur tomcur Aug 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.

The "amet" on the second line moves about a pixel to the left here, which I believe is now correct. This is due to the hanging trailing space I mention in the PR description. There are a few more of these diffs.

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.

It isn't immediately clear to me why this doesn't also apply to elit, on the next line.

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.

Perhaps I was a bit loose with my wording. I specifically mean overflowing trailing whitespace (not just hanging). The trailing space of the second line overflows the layout width, but as it is hung it doesn't need to wrap. We weren't counting justification opportunities correctly in that case.

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.

This now correctly gets 2px of spacing to the right of the "2" on the first line, due to the letter-spacing. Compare: https://developer.mozilla.org/en-US/play?id=xUzPsQvQPN%2BH8KQQW7EbahNXi3IY8eaVqbAFvHLg3VTBnrMs808%2B%2B3QRSyJZqxVXKxSQLgAquwN2IvsJ.

@@ -1519,13 +1538,15 @@ fn commit_line<B: Brush>(
.is_some_and(|atom| atom.characters()[0].info.whitespace().is_space_or_nbsp())
{
num_spaces = num_spaces.saturating_sub(1);

@tomcur tomcur Aug 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 think this should actually continue walking until the first non-trailing space atom (though it doesn't regress here).

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.

I think that Canva's Parley fork does actually make that change, so we'd welcome it being made here.

Comment on lines +148 to +154
if is_word_separator(whitespace) {
gaps.after += self.spacing.word;

if atom.shaped_clusters_range().end != self.justification.line_end_cluster {
gaps.after += self.justification.amount_per_opportunity;
}
}

@tomcur tomcur Aug 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.

The is_word_separator for applying word spacing might need a bit of tuning. This is getting into real edge-case territory. In the following text, there's a grapheme directly following "aa" made of [U+0D4E|space]. Browsers apply word spacing after that grapheme, even though it start with a space.

aaൎ bb cc dd

https://developer.mozilla.org/en-US/play?id=nDJJGrVMnfXMXjUOYUNkEbg2qCFxd6prgYQuDt93o2FUU1UsKT54SKBjyQRdMUqspkv3UN45pEGsJ5a5

With this PR, we render it as follows.

Image

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.

And for posterity, this PR rendering that with letter-spacing instead.

spacing_and_prepend_characters-word

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.

even though it start with a space

Doesn't it not start with a space, or am I missing something?

@tomcur tomcur Aug 27, 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.

Yeah, you're right, I should've written "even though it doesn't start with a space." This branch checks the logically first character, and that'll require more tuning to fully match browsers.

Comment thread parley/src/layout/run.rs
Comment on lines +274 to +278
fn glyphs_with_spacing<'a, B: Brush>(
run: Run<'a, B>,
clusters: Range<u32>,
spacing: LineSpacing,
) -> impl Iterator<Item = Glyph> + Clone + use<'a, B> {

@tomcur tomcur Aug 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.

The iterator code in this function is... not very nice. Very open to suggestions.

@nicoburns

nicoburns commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

WPT results with Blitz against this look decent:

WPT Results

WPT: blitz PR#627 (parley@9c41a4d) vs parley#738 (50feabe)

Baseline: 10563 PASS / 14527 FAIL / 141 CRASH / 11908 SKIP
parley#738: 10596 PASS / 14494 FAIL / 141 CRASH / 11908 SKIP

Net +33 passes. Results verified deterministic (identical across two runs).

Fixed (FAIL -> PASS): 53

  • css/CSS2/css1/c534-bgre-000.xht
  • css/CSS2/css1/c534-bgre-001.xht
  • css/CSS2/css1/c534-bgreps-001.xht
  • css/CSS2/css1/c534-bgreps-002.xht
  • css/CSS2/css1/c534-bgreps-003.xht
  • css/CSS2/css1/c534-bgreps-004.xht
  • css/CSS2/css1/c534-bgreps-005.xht
  • css/CSS2/css1/c536-bgpos-000.xht
  • css/CSS2/css1/c536-bgpos-001.xht
  • css/CSS2/css1/c547-indent-000.xht
  • css/CSS2/css1/c5511-brdr-tw-001.xht
  • css/CSS2/css1/c5513-brdr-bw-001.xht
  • css/CSS2/css1/c5515-brdr-w-001.xht
  • css/CSS2/css1/c5525-fltwidth-003.xht
  • css/CSS2/css1/c61-rel-len-000.xht
  • css/CSS2/fonts/font-size-124.xht
  • css/CSS2/linebox/line-height-applies-to-001.xht
  • css/CSS2/linebox/line-height-applies-to-002.xht
  • css/CSS2/linebox/line-height-applies-to-003.xht
  • css/CSS2/linebox/line-height-applies-to-004.xht
  • css/CSS2/linebox/line-height-applies-to-007.xht
  • css/CSS2/linebox/line-height-applies-to-008.xht
  • css/CSS2/linebox/line-height-applies-to-009.xht
  • css/CSS2/linebox/line-height-applies-to-012.xht
  • css/CSS2/linebox/line-height-applies-to-013.xht
  • css/CSS2/linebox/line-height-applies-to-014.xht
  • css/CSS2/positioning/absolute-non-replaced-max-height-007.xht
  • css/CSS2/positioning/absolute-non-replaced-max-height-009.xht
  • css/CSS2/positioning/abspos-011.xht
  • css/CSS2/positioning/abspos-012.xht
  • css/CSS2/text/white-space-007.xht
  • css/css-contain/contain-layout-ink-overflow-013.html
  • css/css-contain/contain-layout-ink-overflow-015.html
  • css/css-fonts/font-size-zero-2.html
  • css/css-fonts/variations/font-weight-metrics.html
  • css/css-grid/grid-items/grid-layout-z-order-a.html
  • css/css-grid/grid-items/grid-layout-z-order-b.html
  • css/css-text/line-breaking/line-breaking-atomic-002.html
  • css/css-text/white-space/full-width-leading-spaces-002.html
  • css/css-text/white-space/full-width-leading-spaces-003.html
  • css/css-text/white-space/full-width-leading-spaces-005.html
  • css/css-text/white-space/pre-wrap-014.html
  • css/css-text/white-space/trailing-ideographic-space-007.html
  • css/css-text/white-space/trailing-ideographic-space-009.html
  • css/css-text/white-space/trailing-ideographic-space-012.html
  • css/css-text/white-space/trailing-ideographic-space-017.html
  • css/css-text/white-space/trailing-ideographic-space-019.html
  • css/css-text/white-space/trailing-ideographic-space-020.html
  • css/css-text/white-space/trailing-ideographic-space-022.html
  • css/css-text/white-space/trailing-ideographic-space-023.html
  • css/css-text/white-space/trailing-ideographic-space-025.html
  • css/css-values/lh-unit-001.html
  • css/css-values/lh-unit-002.html

Regressed (PASS -> FAIL): 20

  • css/CSS2/normal-flow/inline-block-zorder-001.xht
  • css/CSS2/normal-flow/inline-block-zorder-002.xht
  • css/CSS2/normal-flow/inline-block-zorder-004.xht
  • css/CSS2/normal-flow/inline-block-zorder-005.xht
  • css/CSS2/normal-flow/inline-table-zorder-001.xht
  • css/css-color/t41-html4-keywords-a.xht
  • css/css-fonts/size-adjust-unicode-range-system-fallback.html
  • css/css-fonts/small-caps-letter-spacing-002.html
  • css/css-grid/grid-definition/grid-layout-auto-tracks.html
  • css/css-highlight-api/painting/custom-highlight-painting-inheritance-001.html
  • css/css-highlight-api/painting/custom-highlight-painting-inheritance-002.html
  • css/css-inline/empty-span-size-002.html
  • css/css-ruby/empty-ruby-base-container.html
  • css/css-ruby/empty-ruby-text-container-abs.html
  • css/css-ruby/ruby-base-container-abs.html
  • css/css-text-decor/text-decoration-skip-spaces-001.html
  • css/css-text/hanging-punctuation/hanging-punctuation-first-002.html
  • css/css-text/text-align/text-align-match-parent-05.html
  • css/css-text/white-space/full-width-leading-spaces-004.html
  • css/css-text/white-space/hanging-whitespace-002.tentative.html

@nicoburns

Copy link
Copy Markdown
Collaborator

Am I right in thinking that this means that one would no longer be able to randomly access glyph positions (you'd have to iterate through the line)?

@tomcur

tomcur commented Aug 7, 2026

Copy link
Copy Markdown
Member Author

WPT results with Blitz against this look decent

Very cool! Thanks for running the tests. Will be fun to dive into exactly what those newly-failing tests are doing.

Am I right in thinking that this means that one would no longer be able to randomly access glyph positions (you'd have to iterate through the line)?

Yeah, that's right. It's similar to how the atoms and graphemes are cursor-based. If a consumer knows they'll be accessing the glyphs a lot, or want random access, I suppose they could materialize them into an allocation (or maybe we should think about letting parley cache them on first access or something like that). (Rendering-performance-wise, users likely store what they're interested in in some analogue of the Vellos' Scene.)

I think separating storage of shaped results from shaping-irrelevant styling is nice architecturally, as it will help to allow performant rejustification or animating spacing and such.

@tomcur tomcur changed the title Improve spacing and justication model, stop mutating advances in-place Improve spacing and justification model, stop mutating advances in-place Aug 7, 2026
@nicoburns

Copy link
Copy Markdown
Collaborator

Rendering-performance-wise, users likely store what they're interested in in some analogue of the Vellos' Scene

I was more thinking about selection performance. Presumably that will regress here? Although I guess only for justified text if we're smart. And possibly not sufficiently that it matters.

I think separating storage of shaped results from shaping-irrelevant styling is nice architecturally, as it will help to allow performant rejustification or animating spacing and such.

Agreed! But this is orthogonal to not storing the data at all, right?

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

I definitely like the direction. Some of the decisions inside are a little bit non-obvious to me, as expanded in the comments. But overall, this is a reasonable start, and I trust you to get it to the finish line/bring whatever needs further discussion to OH tomorrow.

Comment thread parley/src/layout/data.rs
pub(crate) word_spacing: f32,
/// Additional letter spacing.
pub(crate) letter_spacing: f32,
/// Additional spacing inserted between this run's atoms.

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.

It's a little bit surprising to me that this would be atoms, but I haven't reasoned through it.

I guess the point is that HarfRust will be told that we need spacing, so will try to minimise the size of atoms anyway?

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.

Grapheme boundaries are potential places to put spacing, but if graphemes are ligated such that a shaped cluster spans across the boundary, we cannot put any spacing there. Atom boundaries are exactly where grapheme and shaped cluster boundaries agree.

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.

Yeah, it makes sense. I just wonder if this is something whether that ligation decision in itself would have been valid (that is, is this like reshaping after breaking?)

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.

In case of nonzero letter-spacing, CSS indeed specifies optional ligatures should not form (and in #731 we follow that).

Comment on lines 111 to 122
// Justified alignment doesn't apply to the last line of a paragraph
// (`BreakReason::None`), (`BreakReason::Explicit`) or if there are no whitespace
// gaps to adjust. In that case, start-align, i.e., left-align for LTR text and
// right-align for RTL text.
if matches!(line.break_reason, BreakReason::None | BreakReason::Explicit)
|| line.num_spaces == 0
{
if is_rtl {
line.metrics.offset += free_space;
}
continue;
}

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.

Not needed now, but this is where https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/text-align-last would apply, I think.

Comment on lines +318 to +320
if atom.characters()[0].info.whitespace().is_space_or_nbsp() {
self.line.num_spaces += 1;
}

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.

I know I keep asking this same question again, but could you remind me why this is correct (over a version which says if any of the component grapheme clusters are a space).

Or really, I just you to claim that you yourself are convinced of that!

@tomcur tomcur Aug 28, 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 think there isn't necessarily a correct or incorrect. Gecko and Blink do different things, but after diving into it again I think perhaps we should change our logic a bit further. See also the prepend+space case in #738 (comment).

Of Gecko and Blink, the former seems most self-consistent, and its stretch opportunities are quite close to the predicate "the atom's last character is a space." Perhaps we should adopt that (in a follow-up PR).

Blink appears to be somewhat buggy: it looks like it counts justification opportunities in a different way from how it determines whether a space can be stretched. See https://developer.mozilla.org/en-US/play?id=4ZJ8MG9vK2jKwMlmZ4qH%2BqRp5lQ0QDLeZmJTAOP8sevrRi6i0fW4LgqhLm8BX1vfxM2gKnll7C17pBL4, where, using Chromium, the second space gets stretched based on whether the third space has a prepend or not. With Chromium, that renders as follows on my machine.

justification2

Other than that, Blink is quite close to the predicate "the atom's first character is a space," but it relies on HarfBuzz's grapheme clustering, which isn't quite the same as UAX #29 extended grapheme clusters.

Comment thread parley/src/layout/line_break.rs Outdated
Comment thread parley/src/layout/line_break.rs Outdated
Comment on lines +148 to +154
if is_word_separator(whitespace) {
gaps.after += self.spacing.word;

if atom.shaped_clusters_range().end != self.justification.line_end_cluster {
gaps.after += self.justification.amount_per_opportunity;
}
}

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.

Incidentally, are you aware of fonts with inked space (e.g. Marelle Lignes - https://marelle.forge.apps.education.fr/).
This is some very edge-case stuff, and browsers don't really put much effort into them in this kind of case.

@tomcur tomcur Aug 28, 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.

No, I didn't know of such fonts. I also just learned of the Ethiopic word space (U+1361) which CSS treats as a word separator that can get additional spacing and has a glyph (though Gecko and Blink don't add spacing to it).

It seems that font itself uses some overdraw to be at least somewhat correct in case of spacing.

/// The total advance of `slice`, i.e., the sum of shaped advances and gaps.
#[inline]
pub(crate) fn slice_advance(self, slice: ShapedSlice<'_>) -> f32 {
if self.is_zero() {

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.

It's not immediately obvious to me that all this if spacing.is_zero() { fast path } else {slightly slower path } is especially valuable.

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.

It isn't immediately clear to me why this doesn't also apply to elit, on the next line.

Comment on lines +148 to +149
if is_word_separator(whitespace) {
gaps.after += self.spacing.word;

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.

What happens with doubled spaces here? Is that correct?

(From a Canva perspective, either is fine so long as justification is right).

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.

Yeah, I think it is. I'll add a test separately.

Comment thread parley_tests/tests/basic.rs Outdated
// whether that interacts correctly with justification. In previous versions, that hanging space
// was miscounted, resulting in justification opportunities being undercounted.
//
// The large word spacing makes it more obvious whether gaps between the words are equal.

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.

I would have thought that a small word spacing would lead to that, as a higher proportion of the gap would be due to justification? But maybe I'm missing what this is actually testing.

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.

This is testing that an overflowing space is redistributed correctly over all interior spaces (it wasn't, previously). Having the word spacing helps ensure the space actually overflows, and the amount that's redistributed is specifically the portion of that overflowing space that's inside the line box (the rest was already hung).

I've updated the wording in the test somewhat (and also tweaked the numbers a bit).

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.

I think the proof which makes the most sense is to have the word which goes on the second line just be really long, but maybe that's a different question?

But I'll be honest, I still haven't grokked what you're saying. It's not critical though.

@tomcur
tomcur added this pull request to the merge queue Aug 29, 2026
Merged via the queue into linebender:main with commit 7420f4c Aug 29, 2026
24 checks passed
@tomcur
tomcur deleted the improved-spacing branch August 29, 2026 14:13
github-merge-queue Bot pushed a commit that referenced this pull request Sep 3, 2026
<!-- Please ensure that you have reviewed our LLM ("AI") policy at
https://linebender.org/wiki/llm-policy/.

If you did not use any LLM tools, please replace `Unspecified` with
`None`. -->
LLM Contributions: Mostly generated.

Follow-up to
#738 (comment).

This is also what Gecko and Blink do (with whitespace collapsing off).
Compare:
https://developer.mozilla.org/en-US/play?id=gilITaZ%2B0hCJVuNWyCoME9xqzkpiKGs0u2alPNKuvKXYzUEm2XX1efjm7j5qJPFZdiEZU%2FYl7eSQxZlN.

<!--
If our users need to know about this change, please describe that in the
quote block below.
What you write here will be edited by us later - it doesn't need to be
perfect.
If this change doesn't need a changelog entry, please replace the next
line with `**Changelog: None**`.
-->
**Changelog: None**
tomcur added a commit to tomcur/parley that referenced this pull request Sep 5, 2026
<!-- Please ensure that you have reviewed our LLM ("AI") policy at
https://linebender.org/wiki/llm-policy/.

If you did not use any LLM tools, please replace `Unspecified` with
`None`. -->
LLM Contributions: Investigation, test.

When justifying a line, this now correctly handles runs of consecutive
trailing spaces. This is follow-up to
linebender#738 (comment).

There's some more that we could/should do here (separately!). E.g., I
think we simply do not need to check the break reason, and that would
also get us to handle `BreakReason::Emergency` correctly for free. We
also don't currently handle consecutive trailing spaces correctly for
either justification or hanging when there's a formatting change inside
that run; something like the following.

```html
<div style="background: #efefef; width: 95px; white-space-collapse: preserve; text-align: justify;">AA BB CC  <span style="font-size: 2em;"> </span>DD</div>
```

<!--
If our users need to know about this change, please describe that in the
quote block below.
What you write here will be edited by us later - it doesn't need to be
perfect.
If this change doesn't need a changelog entry, please replace the next
line with `**Changelog: None**`.
-->
**Changelog**

> ### Fixed
>
> - Lines aligned with justification now correctly handle runs of
consecutive trailing spaces.
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