Skip to content

Implement the vertical-align style - #766

Open
nicoburns wants to merge 12 commits into
linebender:mainfrom
DioxusLabs:devin/1788383056-vertical-align
Open

Implement the vertical-align style#766
nicoburns wants to merge 12 commits into
linebender:mainfrom
DioxusLabs:devin/1788383056-vertical-align

Conversation

@nicoburns

@nicoburns nicoburns commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Implements vertical alignment.

LLM Contributions: Generated with Fable 5.1 Low. Reviewed by GPT 5.6 Sol XHigh. This has also been manually reviewed, and gone through several rounds of iteration. I probably still need to do another round on the details of the the actual alignment, but I think this is architecturally in a good place.

This PR is designed to be reviewed commit-by-commit (4 commits):

  1. Introduce a minimal tree structure:
    Adds parent: u16 to every style, which allows for upwards tree-traversal. The TreeBuilder encodes it's tree with this new field. The RangedBuilder builds a trivial tree with one root, and where all style spans are direct children of that root. This builds on the existing convention that the 0th style is the root/paragraph level style.

  2. Adds VerticalAlign style. VerticalAlign is struct which consists of BaselineShift, and AlignmentBaseline fields, and can be set both as a span style and on inline boxes.

  3. Introduces the concept of first available font and resolves a font + metrics for every style. We thus end up with a tree of spans each with associated line metrics before we even begin layout. We do not use the actual resolved fonts for any run/atom/cluster for vertical alignment at all (this matches browsers, but there are other reasonable choices here, and we could bring back using run/cluster/atom metrics for this as an option if desired). Some level of alignment relative to the parent is already pre-computed at this stage.

    (Note: these style lookups are kinda expensive - I have a follow-up PR that eliminates that overhead with a global cache on the FontContext - Cache primary font queries and font metrics across layouts in FontContext DioxusLabs/parley#19 for those who want a preview)

  4. Implements the actual vertical alignment logic by building a list of "aligned subtrees" for each line, and computing the offsets of each span in each aligned subtree relative to each other. Most lines actually only have one "aligned subtree", so it is alignment within a tree doing most of the work. vertical-align: top and bottom introduce new "aligned subtrees" which are initially aligned independently of the other "aligned subtrees" on the line. There is then a final alignment step when completing each line that aligns the "aligned subtrees" to each other.

Some notes:

  • The mental model for how spans interact with lines is that each span creates a box fragment in every line it intersects, and each a span's box fragment on a given line is always contained within (and aligned with) a box fragment for every ancestor span up to the root level, as well as with other spans (and their ancestors) that exist on the same line.
  • This is a generalisation of the "strut metric" concept: spans align with not just the root, but every span in between. RangedBuilder layouts fallback gracefully to "strut metric"-style layout (spans in ranged layouts only have one ancestor: the root).

Changelog

Added

  • vertical-align style.
  • TODO

tomcur and others added 12 commits September 3, 2026 12:33
The root style is always materialised at index 0 and every other style
records the index of its enclosing span's style. The tree builder now
materialises ancestor spans that contain no direct text so that they can
contribute their inline box to line metrics.
`VerticalAlign` mirrors css-inline-3 as a compound of `AlignmentBaseline`
(baseline, text-top, text-bottom, middle) and `BaselineShift` (length, sub,
super, top, bottom), so callers holding the longhands (e.g. Blitz over Stylo)
can pass them through losslessly and values such as `vertical-align: text-top
2px` compose. The CSS 2 keywords are available as associated constants
(`VerticalAlign::SUPER` etc.) plus `VerticalAlign::length`.

The property is plumbed through `StyleProperty`, `TextStyle`, both builders
and `ResolvedStyle`; atomic inline boxes carry their own `vertical_align` and
optional `baseline`. No layout behaviour changes yet.
Every style table entry gets `StyleMetrics`: primary-font ascent/descent/
x-height (honouring font variations), the line-height expanded `over`/`under`
box with CSS 2 §10.8.1 half-leading, and its baseline offset relative to the
root of its aligned subtree. Offsets accumulate parent-first through the
style's parent chain (`alignment-baseline` offset plus `baseline-shift`);
`top`/`bottom` start a new line-relative aligned subtree. `sub`/`super` use
the WebKit/Blink constants (font-size / 5 and / 3).

Per-character style indices are reset for each layout so an empty layout's
substitute space uses the root style rather than a stale index.
… glyphs/inline boxes

Line breaking now seeds every line with the root (strut) inline box, adds each
run's style box plus its not-yet-contributed ancestors, and tracks
vertical-align: top/bottom subtrees separately. Glyph runs and inline boxes are
positioned at their style's shifted baseline. Lines containing only inline boxes
now get strut height (snapshots updated).

Details folded in from review:
- Lines containing only empty or out-of-flow inline boxes have zero height;
  negative-height in-flow boxes still count as content and keep the strut.
- The trailing line after a final newline is sized by the newline's style
  chain rather than collapsing to the strut.
- With `quantize`, the exact baseline offset is accumulated down the style
  chain and rounded once per style (and once per top/bottom subtree), so
  glyph baselines are whole pixels without per-level rounding drift, and
  inline box ascent/descent are not rounded separately.
- Font queries and metrics are shared between styles with identical font
  selection inputs, run box metrics are computed once per run, and the
  shaped run's metrics are reused for its style; aligned-subtree offsets are
  stored per layout instead of per line.

@conor-93 conor-93 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The approach seems correct overall. I just reviewed your commits.

There appears to be some performance cost to the per-style font querying, and the new SmallVecs on LineBoxMetrics:

Default Style - arabic 20 characters               [   9.7 us ...   9.9 us ]      +2.13%*
Default Style - latin 20 characters                [   4.4 us ...   4.7 us ]      +8.13%*
Default Style - japanese 20 characters             [   8.2 us ...   8.7 us ]      +5.73%*
Default Style - arabic 1 paragraph                 [  52.6 us ...  53.0 us ]      +0.72%
Default Style - latin 1 paragraph                  [  18.3 us ...  19.5 us ]      +6.72%*
Default Style - japanese 1 paragraph               [  68.6 us ...  69.9 us ]      +1.94%*
Default Style - arabic 4 paragraph                 [ 220.6 us ... 218.8 us ]      -0.82%
Default Style - latin 4 paragraph                  [  72.2 us ...  75.4 us ]      +4.56%*
Default Style - japanese 4 paragraph               [  97.6 us ... 100.3 us ]      +2.79%*
Styled - arabic 20 characters                      [  10.7 us ...  11.3 us ]      +5.74%*
Styled - latin 20 characters                       [   5.7 us ...   6.2 us ]      +8.49%*
Styled - japanese 20 characters                    [   8.8 us ...   9.7 us ]      +9.55%*
Styled - arabic 1 paragraph                        [  55.3 us ...  56.4 us ]      +2.10%*
Styled - latin 1 paragraph                         [  22.4 us ...  23.7 us ]      +6.03%*
Styled - japanese 1 paragraph                      [  74.9 us ...  79.0 us ]      +5.46%*
Styled - arabic 4 paragraph                        [ 239.9 us ... 244.1 us ]      +1.73%*
Styled - latin 4 paragraph                         [  90.2 us ...  93.6 us ]      +3.79%*
Styled - japanese 4 paragraph                      [ 108.5 us ... 114.4 us ]      +5.42%*
Word + Letter Spacing - arabic 20 characters       [   9.5 us ...   9.8 us ]      +3.45%*
Word + Letter Spacing - latin 20 characters        [   4.4 us ...   4.8 us ]      +7.65%*
Word + Letter Spacing - japanese 20 characters     [   8.3 us ...   8.7 us ]      +4.98%*
Word + Letter Spacing - arabic 1 paragraph         [  53.2 us ...  53.9 us ]      +1.19%*
Word + Letter Spacing - latin 1 paragraph          [  18.7 us ...  19.8 us ]      +5.89%*
Word + Letter Spacing - japanese 1 paragraph       [  69.3 us ...  70.4 us ]      +1.60%*
Word + Letter Spacing - arabic 4 paragraph         [ 223.7 us ... 223.6 us ]      -0.04%
Word + Letter Spacing - latin 4 paragraph          [  71.7 us ...  75.9 us ]      +5.89%*
Word + Letter Spacing - japanese 4 paragraph       [  99.7 us ... 101.1 us ]      +1.45%
Repeated Justification - latin 4 paragraph         [   1.3 us ...   1.3 us ]      +0.04%
Fontique - system fonts init (CoreText)            [  41.4 ms ...  42.0 ms ]      +1.35%*

Comment thread parley/src/inline_box.rs
Comment on lines +25 to +26
/// Vertical alignment of the box within its line, relative to the enclosing style span.
pub vertical_align: VerticalAlign,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is a breaking change, let's add a changelog notice?

/// The extents from the line box's baseline.
/// Extents of each aligned subtree with content on this line. The first entry is always the
/// root subtree (root style index `0`).
subtrees: SmallVec<[SubtreeExtents; 2]>,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

(Fable helped find this) By Default, this is empty. reset establishes 0 as the root (the comment on subtrees says the first entry is always the root subtree: 0), but this isn't true if a public consumer uses BreakLines::revert_to(BreakerState::default()). We'd then OOB at &self.subtrees[0] through LineBoxMetrics::root next time we break lines.

How about just pushing 0 to subtrees in the default impl, and having reset build on that, to avoid drift? The manual default should include setting last_text to the (MAX, 0) sentinel, too.

@nicoburns

Copy link
Copy Markdown
Collaborator Author

There appears to be some performance cost to the per-style font querying, and the new SmallVecs on LineBoxMetrics

The follow-up PR linked in the description mostly eliminates this for the font querying. The SmallVecs I haven't addressed, but I think we can make them Vecs on BreakLines with indices into them per-line if we want to.

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