Skip to content

Reshape after line breaking in Parley - #930

Open
tomcur wants to merge 51 commits into
linebender:mainfrom
tomcur:reshape-lines
Open

tomcur wants to merge 51 commits into
linebender:mainfrom
tomcur:reshape-lines

Conversation

@tomcur

@tomcur tomcur commented Oct 25, 2026

Copy link
Copy Markdown
Member

LLM Contributions: Initial implementation, review, added tests.

On top of #908, which implements reshaping in parley_engine.

This wires up reshaping in Parley. Breaks we take at positions that are not safe to break are now reshaped. Breaks already safe to break are unchanged.

When starting on a new line, the breaker measures the effect that reshaping the start of that line would have, and takes it into account for measuring fit. If the end also needs to be reshaped, and the line overflows after reshaping, we do one more pass over the line with the max advance bumped down so we end one break sooner. (I first just saved the earlier break opportunity alongside the previous opportunity, but that slows down the general case by up to ten percent, whereas needing to step back is relatively rare.)

For now, the breaker still walks shaped clusters, so we don't yet have break opportunities inside ligatures. That would now of course absolutely be possible, but I think it's something we should do separately.

Note that like Blink, we choose not to reshape for the min-content width calculations. A single reshape would take about as much time as measuring an entire paragraph! That does mean the calculations can be slightly off. (I suppose that should be documented somewhere...)

Performance

This is neutral for text that doesn't reshape (including Roboto with kerning turned off). The speed up on wider lines seems to be a codegen effect.

For Roboto with kerning, the reshaping increases line breaking times by about 15-20% on narrow lines; less on wider lines. That's the cost of reshaping. Glyph iteration is about 2% slower. Some parts of querying got faster, others slower; possibly just a codegen effect, but I'll take another look tomorrow.

Line Breaking - arabic 8000 characters, narrow     [ 174.5 us ... 176.4 us ]      +1.05%*
Line Breaking - arabic 8000 characters, wider      [  99.7 us ...  97.4 us ]      -2.33%*
Line Breaking - arabic 8000 characters, unwrapped  [  79.0 us ...  72.8 us ]      -7.83%*
Line Breaking - latin 8000 characters, narrow      [ 141.8 us ... 166.0 us ]     +17.01%*
Line Breaking - latin 8000 characters, wider       [  71.4 us ...  78.4 us ]      +9.86%*
Line Breaking - latin 8000 characters, unwrapped   [  47.6 us ...  44.6 us ]      -6.34%*
Line Breaking - japanese 8000 characters, narrow   [ 163.7 us ... 166.2 us ]      +1.55%*
Line Breaking - japanese 8000 characters, wider    [ 105.3 us ... 104.9 us ]      -0.36%
Line Breaking - japanese 8000 characters, unwrapped [  88.6 us ...  87.6 us ]      -1.07%*
Line Breaking - arabic 8000 characters, wrapped + spacing [ 105.6 us ... 104.1 us ]      -1.48%*
Line Breaking - latin 8000 characters, wrapped + spacing [  78.5 us ...  90.2 us ]     +14.88%*
Line Breaking - japanese 8000 characters, wrapped + spacing [ 110.7 us ... 109.8 us ]      -0.84%
Page Break - arabic 8000 characters                [  82.1 us ...  78.8 us ]      -4.07%*
Page Break - latin 8000 characters                 [  54.1 us ...  57.7 us ]      +6.77%*
Page Break - japanese 8000 characters              [  96.1 us ... 100.8 us ]      +4.85%*

Glyph Runs - latin 8000 characters, uniform        [  45.2 us ...  44.8 us ]      -0.96%
Glyph Runs - latin 8000 characters, non-splitting every 1 chars [ 150.9 us ... 150.9 us ]      +0.01%
Glyph Runs - latin 8000 characters, non-splitting every 16 chars [  53.4 us ...  53.6 us ]      +0.40%
Glyph Runs - latin 8000 characters, splitting every 1 chars [ 217.3 us ... 223.4 us ]      +2.81%*
Glyph Runs - latin 8000 characters, splitting every 16 chars [  54.1 us ...  55.3 us ]      +2.22%*
Glyph Runs - arabic 8000 characters, mixed         [ 147.4 us ... 148.3 us ]      +0.60%
Glyph Runs - latin 8000 characters, mixed          [  67.4 us ...  67.3 us ]      -0.15%
Glyph Runs - japanese 8000 characters, mixed       [ 123.9 us ... 129.8 us ]      +4.75%*
Page Glyph Runs - arabic 8000 characters           [ 131.4 us ... 130.0 us ]      -1.08%*
Page Glyph Runs - latin 8000 characters            [  47.2 us ...  47.3 us ]      +0.21%
Page Glyph Runs - japanese 8000 characters         [ 106.2 us ... 108.5 us ]      +2.17%*

Query Cursor - arabic 8000 characters, next visual [  34.0 us ...  39.0 us ]     +14.68%*
Query Cursor - latin 8000 characters, next visual  [  40.6 us ...  46.1 us ]     +13.49%*
Query Cursor - japanese 8000 characters, next visual [  37.4 us ...  42.5 us ]     +13.78%*
Query Cursor - arabic 8000 characters, line down + up [  73.3 us ...  72.8 us ]      -0.72%
Query Cursor - latin 8000 characters, line down + up [ 266.8 us ... 257.0 us ]      -3.69%*
Query Cursor - japanese 8000 characters, line down + up [  64.8 us ...  62.9 us ]      -2.93%*
Query Cursor - latin 8000 characters, line down + up, long lines [   2.4 ms ...   2.1 ms ]     -10.55%*
Query Cursor - latin 8000 characters, from byte index [ 739.5 ns ... 865.7 ns ]     +17.06%*

Changelog

Added

  • Line breaking now reshapes the lines, such that the correct glyphs and kerning are applied across breaks.

Comment on lines +958 to +966
if whitespace_hangs(last.whitespace, &data.styles[last.style_index as usize]) {
// As a shortcut, we don't check if reshaping hanging whitespace causes overflow.
// The hanging whitespace itself doesn't need to fit, and reshaping would only
// change how far it hangs, except in the rare instance where a font's lookups reach
// across the trailing whitespace. Doing this fully correctly would entail walking
// the reshaped end's clusters to figure out if any part of the change in advance is
// in the non-hanging part.
return false;
}

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.

Blink applies this same shortcut.

Comment on lines 127 to +131
@@ -141,18 +124,18 @@
/// The amount of additional spacing to apply per justification opportunity.
pub(crate) amount_per_opportunity: f32,

/// One past the line's logically last shaped cluster eligible for justification. Shaped
/// clusters at or beyond this index are not justified, as they hang past the line's end, or are
/// part of the last atom that doesn't fully hang (e.g., an NBSP doesn't hang, but if it closes
/// the line, it shouldn't get justified).
pub(crate) justification_end_cluster: u32,
/// One past the line's logically last character eligible for justification. Atoms with
/// characters at or beyond this index are not justified, as they hang past the line's end, or
/// are the last atom that doesn't fully hang (e.g., an NBSP doesn't hang, but if it closes the
/// line, it shouldn't get justified).
pub(crate) justification_end_char: u32,

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 changed to a character index, because a shaped cluster index could now either mean one of the original shaped clusters, or one of the reshaped ones.

/// [word separator](`is_word_separator`), i.e., a justification opportunity.
#[inline]
#[inline(always)]
fn append_atom_to_line<B: Brush>(

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.

Technically orthogonal, but the code changes were causing the compiler to sometimes stop inlining, which measurably slows down line breaking.

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.

1 participant