Skip to content

Implement the vertical-align style - #766

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

Implement the vertical-align style#766
nicoburns wants to merge 11 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

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

Comment thread parley/src/layout/line_break.rs
@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.

@staging-devin-ai-integration
staging-devin-ai-integration Bot force-pushed the devin/1788383056-vertical-align branch 4 times, most recently from 9c229fb to a33e57c Compare September 8, 2026 15:59

@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 haven't quite finished reviewing all of line_break.rs, sorry!

In general, this looks great, and is a really valuable feature. The implementation also seems reasonably sensible. I've reviewed commit-by-commit as suggested, and so some of the comments might be a bit duplicative.

My biggest blockers are:

  • The usage of the term inline box consistently being confusing/inconsistent.
  • The wrong definition of first_available_font

Feel free to aggressively scope other comments as follow-ups; I think even opening an issue for that would be good (so you can mark them as resolved once added). GitHub's UI is pretty bad at making comments browsable.
I will still need to review any new changes, so if you can avoid squashing them into the existing commits, that would be a great help.

Comment on lines +160 to +167
for style in styles.drain(..) {
let style_index = if style.style == style_table[0] {
0
} else {
assert!(style_table.len() <= u16::MAX as usize, "too many styles");
style_table.push(style.style);
(style_table.len() - 1) as u16
};

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.

This doesn't feel right to me - we already have a RangedStyle for the root style, which was pushed first. That is, in what scenario does this code actually change the result?

styles.truncate(styles.len() - merged_count);

style_table.reserve(styles.len());
// The root style is always at index 0 and is the parent of every other style.

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.

Maybe "every other style has their parent set to zero; ensure that is actually the root style"

Comment thread parley/src/tests/utils/asserts.rs Outdated
Comment on lines +15 to +17
// The style tree (`parent`) is intentionally not part of the comparison: the tree builder
// records span nesting that the flat builders cannot express, so only the visual style
// properties are compared.

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.

Nit: This comment smells LLM-y. I agree that this is probably the right approach; a comment is also probably needed, so I guess it's fine?

Although also I am worried about how this would handle vertical alignment properties...

Comment thread parley/src/style/mod.rs
/// box (the enclosing style span, or the root style for top-level content). Mirrors the CSS
/// `alignment-baseline` property.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum AlignmentBaseline {

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.

Obviously the CSS spec has several additional values for this specified. Can you speak to why this subset was chosen?

See https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/alignment-baseline

@nicoburns nicoburns Sep 9, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Basically because these are the only ones anybody ever uses (they are the "CSS2" values that have existed forever). The other values (alphabetic, ideographic, hanging, mathematical, central, etc) are newer and refer to specific baselines from the opentype spec, and I would consider them advanced functionality. They also require actually reading those baselines from the font.

We should probably implement these eventually, but I would consider them low priority.

Comment thread parley/src/style/mod.rs Outdated
Comment on lines +272 to +274
pub(crate) fn quantize_offset(offset: f32, quantize: bool) -> f32 {
if quantize { offset.round() } else { offset }
}

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.

Nit: I'd probably just inline this function? At the very least, the current doc is bad.

fn line_height(self) -> f32 {
let line_box = self.line_box.or_zero();
line_box.over + line_box.under
fn reset(&mut self, strut: Option<&StyleMetrics>) {

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.

Where is strut none?

if root == 0 {
return &mut self.subtrees[0];
}
let index = match self.subtrees.iter().position(|s| s.root == root) {

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.

Nit: I'd probably use .iter_mut().find(), then in the None branch use push then last_mut (ideally, we'd use push_mut, but that would need a new 'MSRV-bump' reminder warning)

Comment on lines +303 to +308
subtree
.line_box
.add(baseline_offset, run_box.over, run_box.under);
subtree
.content_box
.add(baseline_offset, run_box.ascent, run_box.descent);

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.

This is the code which means we, in Canva, currently can't use this - I believe that the relevant spec line is:

ignoring glyphs from other fonts.

CSS Inline Layout Module Level 3

Landing this PR without addressing that would be fine; I suspect the majority of content Blitz sees has line-height: normal, for example.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

This should be fixed in DioxusLabs@e08c7f5

We now only apply these Metrics for LineHeight::MetricsRelative.

Comment on lines +321 to +322
// Inline box extents are exact box sizes supplied by the caller, not font metrics;
// rounding them would change the space the box reserves relative to its height.

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.

This comment has the wrong focus - the interesting question is how line-box quantisation is preserved despite this happening.

Why do we even have this parameter?

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.
…+0020 coverage check

CSS Fonts 4 defines the first available font via unicode-range coverage of U+0020, not glyph coverage. Parley has no unicode-range, so every available face qualifies and the first face of the stack is used.
…box"

Parley already uses "inline box" for `InlineBox`, an atomic box embedded in the text, so use "span" / "span box" for what CSS calls an inline box (the box generated by a style table entry on a line). Documents the term in the `style_metrics` module docs.
BreakerState::default() is public and can be passed to BreakLines::revert_to,
but its LineBoxMetrics had no root aligned subtree, so the next line would
index out of bounds. Default now contains the root subtree (without a strut)
and reset builds on it, so the breaker is valid from any BreakerState.
Split the font-derived part into BoxMetrics, whose from_font returns a
complete value and is what run_box_metrics needs; StyleMetrics::from_font
is only meaningful inside resolve_style_metrics, which fills in the
remaining tree-position fields.
Each style now resolves its first available font and metrics directly;
cross-layout caching of both is left to the FontContext font cache.
Every non-root span's parent must precede it in the style table
(debug-asserted); a malformed parent index falls back to the root rather
than producing a self-parented orphan, so ancestor walks always end at 0.
Replaces the parallel inline_boxes / inline_box_styles vectors with a single
Vec<LayoutInlineBox { inline_box, style_index }>, so sorting is a plain
stable sort_by_key and the two can no longer get out of step.

Layout::inline_boxes() / inline_boxes_mut() now return ExactSizeIterators
over &InlineBox / &mut InlineBox instead of slices.
@staging-devin-ai-integration
staging-devin-ai-integration Bot force-pushed the devin/1788383056-vertical-align branch from 2286707 to 7a48a76 Compare September 10, 2026 20:42
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