Skip to content

Implement CSS's conditional hanging of whitespace - #790

Merged
tomcur merged 9 commits into
linebender:mainfrom
tomcur:hang-conditionally
Sep 17, 2026
Merged

tomcur merged 9 commits into
linebender:mainfrom
tomcur:hang-conditionally

Conversation

@tomcur

@tomcur tomcur commented Sep 9, 2026 •

Copy link
Copy Markdown
Member

LLM Contributions: Based on DioxusLabs#33, initial implementation, review, added tests.

This implements the "conditionally hanging" whitespace part of CSS Text 4 § 4.3.2, where whitespace with white-space-collapse: preserve hangs conditionally if it ends the layout or is followed by a newline. Whitespace that hangs conditionally, only hangs as far as it overflows the line.

WPT Results

WPT result using Blitz, compared against current main. 45 newly-passing, 4 newly failing. The newly-failing ones use styles we don't implement yet (see Nico's #820!).

Fail => Pass css/css-text/hyphens/hyphens-auto-002.html
Fail => Pass css/css-text/hyphens/hyphens-auto-003.html
Fail => Pass css/css-text/line-break/line-break-anywhere-and-white-space-006.html
Fail => Pass css/css-text/text-transform/text-transform-fullwidth-009.html
Pass => Fail css/css-text/white-space/break-spaces-001.html
Pass => Fail css/css-text/white-space/break-spaces-010.html
Pass => Fail css/css-text/white-space/break-spaces-with-ideographic-space-001.html
Pass => Fail css/css-text/white-space/break-spaces-with-ideographic-space-010.html
Fail => Pass css/css-text/white-space/hanging-whitespace-001.tentative.html
Fail => Pass css/css-text/white-space/hanging-whitespace-004.html
Fail => Pass css/css-text/white-space/pre-wrap-001.html
Fail => Pass css/css-text/white-space/pre-wrap-002.html
Fail => Pass css/css-text/white-space/pre-wrap-003.html
Fail => Pass css/css-text/white-space/pre-wrap-004.html
Fail => Pass css/css-text/white-space/pre-wrap-005.html
Fail => Pass css/css-text/white-space/pre-wrap-006.html
Fail => Pass css/css-text/white-space/pre-wrap-007.html
Fail => Pass css/css-text/white-space/pre-wrap-017.html
Fail => Pass css/css-text/white-space/pre-wrap-018.html
Fail => Pass css/css-text/white-space/pre-wrap-019.html
Fail => Pass css/css-text/white-space/pre-wrap-leading-spaces-015.html
Fail => Pass css/css-text/white-space/pre-wrap-leading-spaces-017.html
Fail => Pass css/css-text/white-space/trailing-ideographic-space-001.html
Fail => Pass css/css-text/white-space/trailing-ideographic-space-004.html
Fail => Pass css/css-text/white-space/trailing-ideographic-space-005.html
Fail => Pass css/css-text/white-space/trailing-ideographic-space-010.html
Fail => Pass css/css-text/white-space/trailing-ideographic-space-017.html
Fail => Pass css/css-text/white-space/trailing-ideographic-space-018.html
Fail => Pass css/css-text/white-space/trailing-ideographic-space-019.html
Fail => Pass css/css-text/white-space/trailing-ideographic-space-020.html
Fail => Pass css/css-text/white-space/trailing-ideographic-space-021.html
Fail => Pass css/css-text/white-space/trailing-ideographic-space-022.html
Fail => Pass css/css-text/white-space/trailing-ideographic-space-023.html
Fail => Pass css/css-text/white-space/trailing-ideographic-space-024.html
Fail => Pass css/css-text/white-space/trailing-ideographic-space-025.html
Fail => Pass css/css-text/white-space/white-space-intrinsic-size-002.html
Fail => Pass css/css-text/white-space/white-space-intrinsic-size-003.html
Fail => Pass css/css-text/white-space/white-space-intrinsic-size-004.html
Fail => Pass css/css-text/white-space/white-space-intrinsic-size-013.html
Fail => Pass css/css-text/white-space/white-space-intrinsic-size-014.html
Fail => Pass css/css-text/white-space/white-space-intrinsic-size-017.html
Fail => Pass css/css-text/white-space/white-space-intrinsic-size-019.html
Fail => Pass css/css-text/white-space/white-space-pre-wrap-trailing-spaces-001.html
Fail => Pass css/css-text/white-space/white-space-pre-wrap-trailing-spaces-003.html
Fail => Pass css/css-text/white-space/white-space-pre-wrap-trailing-spaces-005.html
Fail => Pass css/css-text/white-space/white-space-pre-wrap-trailing-spaces-007.html
Fail => Pass css/css-text/white-space/white-space-pre-wrap-trailing-spaces-008.html
Fail => Pass css/css-text/white-space/white-space-pre-wrap-trailing-spaces-011.html
Fail => Pass css/css-text/white-space/white-space-pre-wrap-trailing-spaces-021.html

Changelog

Changed

  • Following CSS Text 4 § 4.3.2, whitespace with WhiteSpaceCollapse::Preserve now hangs only if it's not on the last line, and is not followed by a newline.

@tomcur
tomcur force-pushed the hang-conditionally branch from 06fa2ad to 3401dca Compare September 9, 2026 00:38
@tomcur tomcur changed the title Hang conditionally Hang whitespace conditionally Sep 9, 2026
@nicoburns

Copy link
Copy Markdown
Collaborator

as perhaps it makes most sense to first make the whitespace collapse mode styles available to Layout

I think so. My preferred ordering would be:

Comment thread parley/src/layout/line_break.rs Outdated
Comment on lines +1301 to +1309
// Trailing whitespace before a forced break hangs conditionally (CSS Text 4 § 4.3.2): only
// the part that doesn't fit hangs. Whitespace that fits, stays inside the line box, and
// counts towards alignment and the layout width. The end of the layout is considered to be
// a forced line break as well (CSS Text 4 § 5). Trailing whitespace before a soft break
// hangs unconditionally.
if matches!(line.break_reason, BreakReason::Explicit | BreakReason::None) {
let overflow = line.metrics.advance - line.max_advance;
hanging_whitespace_advance = hanging_whitespace_advance.min(overflow).max(0.);
}

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 believe that this is one of the behaviours that Canva patches in, so this sequence is promising for us.

@tomcur

tomcur commented Sep 15, 2026 •

Copy link
Copy Markdown
Member Author

This now hangs conditionally, based on the whitespace collapse style added in #814.

There's a non-trivial cost to this. On my x86 machine, this benches as follows. (Unrelated to this PR, we are currently handling Japanese a bit badly, in that every switch between Kanji/Hiragana/Katakana becomes a font run, so there's a ton of runs. I think that may be why Japanese especially regresses badly on the content widths here.)

Line Breaking - arabic 8000 characters, narrow     [ 134.8 us ... 145.6 us ]      +8.02%*
Line Breaking - arabic 8000 characters, wider      [  91.1 us ...  96.5 us ]      +5.84%*
Line Breaking - arabic 8000 characters, unwrapped  [  75.9 us ...  78.2 us ]      +2.99%*
Line Breaking - latin 8000 characters, narrow      [ 100.9 us ... 113.5 us ]     +12.45%*
Line Breaking - latin 8000 characters, wider       [  57.8 us ...  62.8 us ]      +8.61%*
Line Breaking - latin 8000 characters, unwrapped   [  43.7 us ...  47.6 us ]      +8.86%*
Line Breaking - japanese 8000 characters, narrow   [ 135.0 us ... 137.3 us ]      +1.67%*
Line Breaking - japanese 8000 characters, wider    [  95.9 us ...  95.6 us ]      -0.35%
Line Breaking - japanese 8000 characters, unwrapped [  84.3 us ...  82.7 us ]      -1.89%*
Line Breaking - arabic 8000 characters, wrapped + spacing [ 106.9 us ... 114.6 us ]      +7.19%*
Line Breaking - latin 8000 characters, wrapped + spacing [  73.7 us ...  81.2 us ]     +10.22%*
Line Breaking - japanese 8000 characters, wrapped + spacing [ 110.4 us ... 116.4 us ]      +5.41%*
Page Break - arabic 8000 characters                [  78.9 us ...  81.7 us ]      +3.53%*
Page Break - latin 8000 characters                 [  47.1 us ...  51.1 us ]      +8.51%*
Page Break - japanese 8000 characters              [  88.1 us ...  87.2 us ]      -1.03%*

Content Widths - arabic 20 characters, uniform     [  51.7 ns ...  55.1 ns ]      +6.56%*
Content Widths - latin 20 characters, uniform      [  51.9 ns ...  46.7 ns ]     -10.04%*
Content Widths - japanese 20 characters, uniform   [  53.0 ns ...  53.6 ns ]      +1.07%*
Content Widths - arabic 400 characters, uniform    [   1.0 us ...   1.0 us ]      -1.35%*
Content Widths - latin 400 characters, uniform     [ 869.0 ns ... 882.8 ns ]      +1.59%*
Content Widths - japanese 400 characters, uniform  [   1.0 us ...   1.0 us ]      -0.81%
Content Widths - arabic 8000 characters, uniform   [  28.4 us ...  29.6 us ]      +4.55%*
Content Widths - latin 8000 characters, uniform    [  27.5 us ...  30.2 us ]      +9.81%*
Content Widths - japanese 8000 characters, uniform [  26.5 us ...  35.5 us ]     +33.76%*
Content Widths - arabic 20 characters, mixed       [  53.9 ns ...  51.9 ns ]      -3.73%*
Content Widths - latin 20 characters, mixed        [  49.0 ns ...  50.0 ns ]      +2.09%*
Content Widths - japanese 20 characters, mixed     [  54.3 ns ...  54.1 ns ]      -0.37%
Content Widths - arabic 400 characters, mixed      [   1.1 us ...   1.0 us ]      -3.10%*
Content Widths - latin 400 characters, mixed       [ 896.7 ns ... 914.2 ns ]      +1.95%*
Content Widths - japanese 400 characters, mixed    [   1.0 us ...   1.0 us ]      -0.26%
Content Widths - arabic 8000 characters, mixed     [  30.3 us ...  31.6 us ]      +4.41%*
Content Widths - latin 8000 characters, mixed      [  26.2 us ...  29.5 us ]     +12.45%*
Content Widths - japanese 8000 characters, mixed   [  29.8 us ...  38.7 us ]     +29.76%*
Page Content Widths - arabic 8000 characters       [  28.4 us ...  29.9 us ]      +5.39%*
Page Content Widths - latin 8000 characters        [  27.6 us ...  30.2 us ]      +9.49%*
Page Content Widths - japanese 8000 characters     [  28.5 us ...  36.3 us ]     +27.48%*

It does appear to bench slightly better than the alternative #817 on my machine, but both unfortunately have significant cost. #817 adds the following additional cost (head-to-head with this PR). For some content width cases it's faster; so perhaps some things can be improved.

Line Breaking - arabic 8000 characters, narrow     [ 145.9 us ... 155.1 us ]      +6.30%*
Line Breaking - arabic 8000 characters, wider      [  96.1 us ... 100.9 us ]      +5.03%*
Line Breaking - arabic 8000 characters, unwrapped  [  78.2 us ...  80.8 us ]      +3.37%*
Line Breaking - latin 8000 characters, narrow      [ 113.3 us ... 119.5 us ]      +5.52%*
Line Breaking - latin 8000 characters, wider       [  62.7 us ...  65.0 us ]      +3.56%*
Line Breaking - latin 8000 characters, unwrapped   [  47.6 us ...  48.0 us ]      +0.86%
Line Breaking - japanese 8000 characters, narrow   [ 136.7 us ... 145.2 us ]      +6.23%*
Line Breaking - japanese 8000 characters, wider    [  95.1 us ... 102.0 us ]      +7.25%*
Line Breaking - japanese 8000 characters, unwrapped [  82.7 us ...  89.7 us ]      +8.52%*
Line Breaking - arabic 8000 characters, wrapped + spacing [ 114.7 us ... 116.1 us ]      +1.25%*
Line Breaking - latin 8000 characters, wrapped + spacing [  80.7 us ...  85.0 us ]      +5.34%*
Line Breaking - japanese 8000 characters, wrapped + spacing [ 116.3 us ... 117.6 us ]      +1.13%*
Page Break - arabic 8000 characters                [  81.4 us ...  86.2 us ]      +5.86%*
Page Break - latin 8000 characters                 [  51.0 us ...  52.0 us ]      +1.93%*
Page Break - japanese 8000 characters              [  87.0 us ...  94.2 us ]      +8.29%*

Content Widths - arabic 20 characters, uniform     [  55.0 ns ...  68.3 ns ]     +24.30%*
Content Widths - latin 20 characters, uniform      [  47.1 ns ...  64.7 ns ]     +37.52%*
Content Widths - japanese 20 characters, uniform   [  53.7 ns ...  68.8 ns ]     +28.19%*
Content Widths - arabic 400 characters, uniform    [   1.0 us ...   1.2 us ]     +16.69%*
Content Widths - latin 400 characters, uniform     [ 889.9 ns ...   1.1 us ]     +23.80%*
Content Widths - japanese 400 characters, uniform  [   1.0 us ...   1.2 us ]     +19.99%*
Content Widths - arabic 8000 characters, uniform   [  30.5 us ...  30.8 us ]      +1.08%*
Content Widths - latin 8000 characters, uniform    [  30.4 us ...  31.5 us ]      +3.54%*
Content Widths - japanese 8000 characters, uniform [  35.0 us ...  31.9 us ]      -8.80%*
Content Widths - arabic 20 characters, mixed       [  52.1 ns ...  68.6 ns ]     +31.71%*
Content Widths - latin 20 characters, mixed        [  50.1 ns ...  65.6 ns ]     +30.82%*
Content Widths - japanese 20 characters, mixed     [  54.3 ns ...  70.2 ns ]     +29.31%*
Content Widths - arabic 400 characters, mixed      [   1.0 us ...   1.2 us ]     +18.65%*
Content Widths - latin 400 characters, mixed       [ 915.8 ns ...   1.1 us ]     +23.99%*
Content Widths - japanese 400 characters, mixed    [   1.0 us ...   1.2 us ]     +18.28%*
Content Widths - arabic 8000 characters, mixed     [  31.8 us ...  33.5 us ]      +5.22%*
Content Widths - latin 8000 characters, mixed      [  29.6 us ...  31.1 us ]      +5.10%*
Content Widths - japanese 8000 characters, mixed   [  39.0 us ...  35.5 us ]      -9.08%*

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

Not a full review, just a passing thought.

Comment thread parley/src/layout/data.rs

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

A believe a couple of edge cases aren't being handled quite correctly, but they seem relatively minor so I'm happy to approve this. Feel free to follow up on feedback comments as you see fit.

Comment thread parley/src/layout/data.rs
Comment on lines +295 to +299
// Whether that running whitespace hangs conditionally before a forced break (following CSS
// Text 4 § 4.3.2, that's the case for `WhiteSpaceCollapse::Preserve`). Conditional
// whitespace only hangs if it doesn't fit, which here means it counts towards the
// max-content width, but not the min-content width.
let mut hangs_conditionally = false;

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 believe this needs to be f32 to account for lines that end with a mix of unconditionally hanging and conditionally hanging whitespace (e.g. a Preserve space followed by a Collapse space).

Found by an LLM, but it makes sense to me (albeit a bit of an edge case).

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.

@tomcur tomcur Sep 17, 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 don't think that's necessary, actually!

Under CSS Text 4 § 4.3.2, a sequence of preserve whitespace hangs conditionally only if it's directly followed by a forced line break; otherwise, it hangs unconditionally. I.e., if the preserve sequence is followed by a sequence of collapse whitespace (and not a break), then it hangs unconditionally. Effectively, the two sequences can just be treated as one that's unconditionally hanging.

Under CSS Text 4 § 9.2, unconditionally hanging whitespace followed by conditionally hanging whitespace, hangs in full, but only if the conditionally-hanging whitespace hangs in full.

So, in effect, the only behaviorally observable case can be represented as a suffix of conditionally hanging whitespace, preceded by unconditionally hanging whitespace. For the content widths that means:

  • max-content width: the conditionally-hanging suffix fits, and nothing hangs; or
  • min-content width: the conditionally-hanging suffix hangs in full, so everything hangs.

See the following (Blink appears to mostly follow the spec, Gecko does not).

HTML sample
<style>
@import url("https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100..900;1,100..900&display=swap");
body {
    font-size: 16;
    font-family: "Roboto";
}
</style>

<h1><code>collapse</code> hangs unconditionally</h1>

<div style="width: 200px; border: 1px solid; text-align: right">
  X<span style="white-space-collapse: collapse">&#x3000;</span><br />Y
</div>

<h1><code>preserve</code> hangs conditionally</h1>

<div style="width: 200px; border: 1px solid; text-align: right">
  X<span style="white-space-collapse: preserve">&#x3000;</span><br />Y
</div>

<h1>
  <code>preserve</code> preceding <code>collapse</code> hangs unconditionally
</h1>

<div style="width: 200px; border: 1px solid; text-align: right">
  X<span style="white-space-collapse: preserve">&#x3000;</span
  ><span style="white-space-collapse: collapse">&#x3000;</span><br />Y
</div>

<h1>
  <code>collapse</code> preceding <code>preserve</code> hangs conditionally
</h1>

<p>
  The <code>collapse</code> part hangs in full if the <code>preserve</code> part
  hangs in full.
</p>

<p>Here, the <code>preserve</code> part fits in full, so nothing hangs:</p>

<div style="width: 200px; border: 1px solid; text-align: right">
  X<span style="white-space-collapse: collapse">&#x3000;&#x3000;&#x3000;</span
  ><span style="white-space-collapse: preserve">&#x3000;</span><br />Y
</div>

<p>Here, the <code>preserve</code> part doesn't fit, so everything hangs:</p>

<div style="width: 200px; border: 1px solid; text-align: right">
  X<span style="white-space-collapse: collapse"
    >&#x3000;&#x3000;&#x3000;&#x3000;&#x3000;&#x3000;&#x3000;&#x3000;&#x3000;&#x3000;&#x3000;&#x3000;&#x3000;&#x3000;&#x3000;&#x3000;&#x3000;&#x3000;&#x3000;&#x3000;&#x3000;&#x3000;&#x3000;&#x3000;&#x3000;&#x3000;</span
  ><span style="white-space-collapse: preserve">&#x3000;</span><br />Y
</div>

https://developer.mozilla.org/en-US/play?id=Xvs2WibuByVkeRPwc7hAy%2BfYc%2B5W6ZVidyHcfdDy1%2F%2F41DBLRABlyaBHukdkDbLhL4%2BnN5%2FL5YHGdy5B

let first_character = &atom.characters()[0];
let whitespace = first_character.info.whitespace();
if in_conditional_suffix && whitespace != Whitespace::Newline {
if layout.data.styles[first_character.style_index as usize]

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.

Is this relying on an Atom only having a single style?

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.

LLM generated example where this isn't the case:

// Copyright 2026 the Parley Authors
// SPDX-License-Identifier: Apache-2.0 OR MIT
 
//! Repro: hanging of a trailing space that shares an atom with a differently styled character.
//!
//! `"a\u{0D4E} "`: U+0D4E is a prepend character, so it forms a single grapheme (one atom) with
//! the following space, shaped as two clusters. Only the space is restyled below, so the two
//! clusters carry different `white-space-collapse` styles.
//!
//! Copy to `parley/examples/` and run with `cargo run -p parley --example hanging_repro`.
//!
//! Expected: `hanging_advance` equals the space's advance in the first case and `0` in the second.
 
use std::sync::Arc;
 
use parley::fontique::{Blob, Collection, CollectionOptions, SourceCache};
use parley::{
    FontContext, FontFamily, LayoutContext, StyleProperty, TextWrapMode, WhiteSpaceCollapse,
};
 
fn font_context() -> FontContext {
    let mut collection = Collection::new(CollectionOptions {
        shared: false,
        system_fonts: false,
    });
    for dir in parley_dev::font_dirs() {
        for entry in std::fs::read_dir(dir).unwrap().flatten() {
            let path = entry.path();
            if path
                .extension()
                .is_some_and(|ext| ext == "ttf" || ext == "otf")
            {
                collection.register_fonts(Blob::new(Arc::new(std::fs::read(path).unwrap())), None);
            }
        }
    }
    FontContext {
        collection,
        source_cache: SourceCache::default(),
    }
}
 
fn main() {
    let mut font_cx = font_context();
    let mut layout_cx: LayoutContext<()> = LayoutContext::new();
 
    let text = "a\u{0D4E} ";
    let space = text.len() - 1..text.len();
 
    // Whole text: (mode on the `a` + prepend mark, mode on the space, expected hanging).
    let cases = [
        // The space collapses, so it hangs unconditionally.
        (
            WhiteSpaceCollapse::Preserve,
            WhiteSpaceCollapse::Collapse,
            "hang",
        ),
        // The space is preserved, so it only hangs if it overflows; with no max advance it can't.
        (
            WhiteSpaceCollapse::Collapse,
            WhiteSpaceCollapse::Preserve,
            "not hang",
        ),
    ];
 
    for (default_mode, space_mode, expected) in cases {
        let mut builder = layout_cx.ranged_builder(&mut font_cx, text, 1.0, true);
        builder.push_default(StyleProperty::FontFamily(FontFamily::named("Roboto")));
        builder.push_default(StyleProperty::TextWrapMode(TextWrapMode::Wrap));
        builder.push_default(StyleProperty::WhiteSpaceCollapse(default_mode));
        builder.push(StyleProperty::WhiteSpaceCollapse(space_mode), space.clone());
        let mut layout = builder.build(text);
        layout.break_all_lines(None);
 
        let line = layout.lines().next().unwrap();
        let metrics = line.metrics();
        println!(
            "prefix={default_mode:?} space={space_mode:?}: advance={:.3} hanging_advance={:.3} \
             (space should {expected})",
            metrics.advance, metrics.hanging_advance,
        );
    }
}

@tomcur tomcur Sep 17, 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.

Yes, there's also e.g. interaction of space + U+0301. The spec says a bit about this, see CSS Text 4 § 1.4.

A UA must use the extended grapheme cluster (not legacy grapheme cluster), as defined in UAX29, as the basis for its typographic character unit. However, the UA should tailor the definitions as required by typographic tradition since the default rules are not always appropriate or ideal—​and is expected to tailor them differently depending on the operation as needed.

Note: The rules for such tailorings are out of scope for CSS.

These examples form a grapheme, so are the "typographic character unit" we're operating on. The spec then leaves it to the application to determine how these cases should be handled. The browsers diverge here, and there's no real right or wrong. I think, then, that the question is whether we want to follow a specific implementation (like Blink or Gecko). There are a few more divergences: e.g., the Unicode grapheme segmenter says space + U+0301 is a grapheme, but Unicode's line segmenter has a break opportunity after the space.

It's tempting to work at the ShapedCluster level for layout, but that is troublesome if something like "é" shapes to two glyphs and the combining mark glyph has an advance that makes it such that an emergency break falls in between.

Comment on lines +38 to 47
pub(crate) fn whitespace_hangs<B: Brush>(whitespace: Whitespace, style: &Style<B>) -> bool {
match whitespace {
Whitespace::Newline => true,
Whitespace::Space | Whitespace::IdeographicSpace | Whitespace::Tab => {
style.white_space_collapse != WhiteSpaceCollapse::Preserve
|| style.text_wrap_mode == TextWrapMode::Wrap
}
_ => false,
}
}

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.

Nice when the code mirrors the spec like this :)

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.

Should this be a method on Whitespace? (happy to leave to your judgement)

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.

It's possible, but we (and Blink and Gecko) diverge from CSS: we don't include all space separators (Unicode category Zs). That makes me a bit hesitant to make it a public API at the moment (Whitespace lives in parley_engine).

Comment thread parley/src/layout/line_break.rs
@tomcur
tomcur enabled auto-merge September 17, 2026 10:10
@tomcur
tomcur added this pull request to the merge queue Sep 17, 2026
@tomcur
tomcur removed this pull request from the merge queue due to a manual request Sep 17, 2026
@tomcur
tomcur enabled auto-merge September 17, 2026 10:17
@tomcur
tomcur disabled auto-merge September 17, 2026 10:17
@tomcur tomcur changed the title Hang whitespace conditionally Implement CSS's conditional hanging of whitespace Sep 17, 2026
@tomcur
tomcur added this pull request to the merge queue Sep 17, 2026
Merged via the queue into linebender:main with commit 3fdcdca Sep 17, 2026
24 checks passed
@tomcur
tomcur deleted the hang-conditionally branch September 17, 2026 10:41
staging-devin-ai-integration Bot pushed a commit to DioxusLabs/parley that referenced this pull request Sep 17, 2026
staging-devin-ai-integration Bot pushed a commit to DioxusLabs/parley that referenced this pull request Sep 17, 2026
staging-devin-ai-integration Bot pushed a commit to DioxusLabs/parley that referenced this pull request Sep 17, 2026
tomcur added a commit to tomcur/parley that referenced this pull request Sep 18, 2026
This explains the reading of the spec, and explains our model based on
that. Wrote this as an explanation in
linebender#790 (comment),
and figured it may be useful in the docs for posterity.
tomcur pushed a commit to tomcur/parley that referenced this pull request Sep 21, 2026
…n analysis) (linebender#833)

LLM Contributions: Generated with GPT Astra 6 High

Builds on:

- linebender#812
- linebender#814
- linebender#790

Supercedes:
- linebender#820

## Summary

Adds support for `WhiteSpaceCollapse::BreakSpaces`. This maps to the
existing `Preserve` mode for actual whitespace collapsing, but has it's
own unique behaviour during line-breaking and hanging. This is the
remaining (widely implemented) value of `WhiteSpaceCollapse` needed to
complete our whitespace handling.

## Context

When looking into @tomcur's review comments, I looked more closely at
what `soft_line_break` from linebender#820 was doing, and I noticed that it was
computing additional soft line break opportunities during layout /
content width computation. That seemed like a bit of a code smell to me,
as in Parley we normally compute line break opportunities during
analysis and just consume them during line-breaking / content width
computation.

This PR implements `break-spaces` in that way (line breaking
opportunities computed during analysis), and is both simpler
(subjective, but true IMO) and faster (measured: no observable overhead
vs. main!) than linebender#820 (and this benefit composes with the optimizations
from linebender#822 / linebender#823).

## Changes made

- Add `WhiteSpaceCollapse::BreakSpaces` enum variant
- Add `break_spaces: Vec<Range<usize>>` to `LayoutContext`. This
populated with text ranges that have `WhiteSpaceCollapse` set to
`BreakSpaces` similar to the existing `word_break` Vec (so empty if
`break-spaces` is not used).
- An additional check for `break-spaces` when computing line breaking
opportunities in `analyze_text` (essentially free if `break-spaces` is
not used (overhead not detectable by benchmarks at all), very cheap even
when it is (something on the order of 0.5-1%))
- Minimal changes in line-breaking / content width computation to map
the new enum variant to existing whitespace modes.


## Changelog

> ### Added
>
> - Support for `WhiteSpaceCollapse::BreakSpaces`
staging-devin-ai-integration Bot pushed a commit to DioxusLabs/parley that referenced this pull request Sep 21, 2026
staging-devin-ai-integration Bot pushed a commit to DioxusLabs/parley that referenced this pull request Sep 21, 2026
staging-devin-ai-integration Bot pushed a commit to DioxusLabs/parley that referenced this pull request Sep 22, 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: Review.

This explains the reading of the spec, and explains our model based on
that. Wrote this as an explanation in
linebender#790 (comment),
and figured it may be useful in the docs for posterity.

<details>
<summary>HTML sample</summary>

```html
<style>
@import url("https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100..900;1,100..900&display=swap");
body {
    font-size: 16;
    font-family: "Roboto";
}
</style>

<h1><code>collapse</code> hangs unconditionally</h1>

<div style="width: 200px; border: 1px solid; text-align: right">
  X<span style="white-space-collapse: collapse">&#x3000;</span><br />Y
</div>

<h1><code>preserve</code> hangs conditionally</h1>

<div style="width: 200px; border: 1px solid; text-align: right">
  X<span style="white-space-collapse: preserve">&#x3000;</span><br />Y
</div>

<h1>
  <code>preserve</code> preceding <code>collapse</code> hangs unconditionally
</h1>

<div style="width: 200px; border: 1px solid; text-align: right">
  X<span style="white-space-collapse: preserve">&#x3000;</span
  ><span style="white-space-collapse: collapse">&#x3000;</span><br />Y
</div>

<h1>
  <code>collapse</code> preceding <code>preserve</code> hangs conditionally
</h1>

<p>
  The <code>collapse</code> part hangs in full if the <code>preserve</code> part
  hangs in full.
</p>

<p>Here, the <code>preserve</code> part fits in full, so nothing hangs:</p>

<div style="width: 200px; border: 1px solid; text-align: right">
  X<span style="white-space-collapse: collapse">&#x3000;&#x3000;&#x3000;</span
  ><span style="white-space-collapse: preserve">&#x3000;</span><br />Y
</div>

<p>Here, the <code>preserve</code> part doesn't fit, so everything hangs:</p>

<div style="width: 200px; border: 1px solid; text-align: right">
  X<span style="white-space-collapse: collapse"
    >&#x3000;&#x3000;&#x3000;&#x3000;&#x3000;&#x3000;&#x3000;&#x3000;&#x3000;&#x3000;&#x3000;&#x3000;&#x3000;&#x3000;&#x3000;&#x3000;&#x3000;&#x3000;&#x3000;&#x3000;&#x3000;&#x3000;&#x3000;&#x3000;&#x3000;&#x3000;</span
  ><span style="white-space-collapse: preserve">&#x3000;</span><br />Y
</div>
```
</details>


https://developer.mozilla.org/en-US/play?id=Xvs2WibuByVkeRPwc7hAy%2BfYc%2B5W6ZVidyHcfdDy1%2F%2F41DBLRABlyaBHukdkDbLhL4%2BnN5%2FL5YHGdy5B

<!--
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**
staging-devin-ai-integration Bot pushed a commit to DioxusLabs/parley that referenced this pull request Sep 23, 2026
staging-devin-ai-integration Bot pushed a commit to DioxusLabs/parley that referenced this pull request Sep 23, 2026
staging-devin-ai-integration Bot pushed a commit to DioxusLabs/parley that referenced this pull request Sep 23, 2026
nicoburns added a commit to DioxusLabs/parley that referenced this pull request Sep 24, 2026
LLM Contributions: Generated with Fable 5.1 Low

Builds on:
- linebender#790

Gets almost all of the 0.11 performance back for
`calculate_content_widths` (only 10% slower than 0.11 with this PR vs
~60% slower on main). A little slower is expected as this function is
doing a lot more work now (for correct whitespace hanging).

This PR contains two changes (in separate commits):

- **Add the first character's whitespace and soft wrap opportunity bits
to `ShapedClusterFlags`**. Avoids an indirection to read the first
character in common cases.
- **Fast path in `calculate_content_widths` when a run has no
letter/word spacing**. Iterates shaped clusters rather than atoms.
staging-devin-ai-integration Bot pushed a commit to DioxusLabs/parley that referenced this pull request Sep 24, 2026
staging-devin-ai-integration Bot pushed a commit to DioxusLabs/parley that referenced this pull request Sep 26, 2026
staging-devin-ai-integration Bot pushed a commit to DioxusLabs/parley that referenced this pull request Sep 28, 2026
staging-devin-ai-integration Bot pushed a commit to DioxusLabs/parley that referenced this pull request Sep 28, 2026
staging-devin-ai-integration Bot pushed a commit to DioxusLabs/parley that referenced this pull request Sep 28, 2026
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