Repository navigation
Implement CSS's conditional hanging of whitespace - #790
Conversation
06fa2ad to
3401dca
Compare
I think so. My preferred ordering would be:
|
| // 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.); | ||
| } |
There was a problem hiding this comment.
I believe that this is one of the behaviours that Canva patches in, so this sequence is promising for us.
3401dca to
c6e622c
Compare
c6e622c to
a3be749
Compare
a3be749 to
68194fa
Compare
|
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.) 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. |
0e7bf48 to
cbf2fa1
Compare
DJMcNab
left a comment
There was a problem hiding this comment.
Not a full review, just a passing thought.
nicoburns
left a comment
There was a problem hiding this comment.
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.
| // 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; |
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
I quite like how 817 tracks this with a dedicated struct https://github.com/linebender/parley/pull/817/changes#diff-9335befa3bafd4f93bff984dc9a4adfe5552adfdbfd5c56d0a7d6128c71bff55R66
There was a problem hiding this comment.
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"> </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"> </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"> </span
><span style="white-space-collapse: collapse"> </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">   </span
><span style="white-space-collapse: preserve"> </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"
>                          </span
><span style="white-space-collapse: preserve"> </span><br />Y
</div>| 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] |
There was a problem hiding this comment.
Is this relying on an Atom only having a single style?
There was a problem hiding this comment.
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,
);
}
}There was a problem hiding this comment.
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.
| 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, | ||
| } | ||
| } |
There was a problem hiding this comment.
Nice when the code mirrors the spec like this :)
There was a problem hiding this comment.
Should this be a method on Whitespace? (happy to leave to your judgement)
There was a problem hiding this comment.
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).
…y in content widths, share whitespace walk
cbf2fa1 to
d981cba
Compare
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.
…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`
<!-- 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"> </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"> </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"> </span
><span style="white-space-collapse: collapse"> </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">   </span
><span style="white-space-collapse: preserve"> </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"
>                          </span
><span style="white-space-collapse: preserve"> </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**
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.
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: preservehangs 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!).Changelog