feat(text): cap-height centering as the per-line vertical anchor#27
Merged
Conversation
Today the layout engine anchored each line by placing the alphabetic
baseline at `halfLeading + ascender` from the top of the line box. That
centers the abstract asc-to-desc-plus-leading rectangle on the box mid-
line, but the visible ink lands noticeably high because the asc/(asc-
desc) ratio is asymmetric for most Latin fonts (~4.2:1 for Ubuntu). At
fontSize 100 in a 120-px box the capital-letter center sat about 10 px
above the box's geometric center.
Switched the per-line anchor to cap-height centering:
baselineY(i) = lineHeight/2 + capHeight/2 + i * lineHeight
The baseline sits below the line's geometric mid-line by exactly
capHeight/2, so capital letters bracket the centre symmetrically.
Capitals (TXYZ) and digits (1234) appear visually centered for any
fontSize; descenders on words like 'gjpq' hang slightly below, matching
CSS button-label behavior in browsers. Reasoning and the two
alternatives considered (x-height centering, line-box centering) are
preserved as comments above the formula.
Data path:
- FontMetrics gains an optional `capHeight` field in font units.
NormalizedFontMetrics carries it as a required em-px value, derived
by `normalizeFontMetrics` (with a 0.7 * ascender fallback for fonts
that don't supply it - within a few percent of OS/2 sCapHeight
across common Latin families).
- SdfFontHandler.processFontData derives cap-height from the atlas at
font-load time: capHeight_atlas = common.base - glyph['H'].yoffset
(atlas design units). Converted into font units and stamped onto
`metrics.capHeight`. For Ubuntu/Noto at info.size=42 this yields
738 / 762 per 1000-em respectively, which line up with the
published cap-height values modulo a small atlas overshoot.
- CanvasFontHandler picks up the 0.7 * ascender fallback via
normalizeFontMetrics until consumers ship an explicit `capHeight`
in `lightningMetrics` from the msdf-generator (small follow-up).
Added examples/tests/text-vertical-center.ts: rows of fontSizes (20,
40, 60, 80, 100) in fixed boxes with lineHeight = boxHeight, each box
displaying a sample string (caps, mixed, descenders, digits, punct.)
on a colored background with a red guide line at the geometric centre.
Capitals and digits now bracket the red line at every fontSize.
Visual regression snapshots that contain text in centered or near-
centered contexts will diff and need to be re-certified; that shift is
the fix.
Short mixed-case word with no descenders ('Acme'). Makes it easy to
eyeball whether lowercase letters look balanced with capitals on the
box mid-line when there's no descender tail tugging the eye downward.
Sits between 'mixed' and 'descend.' for direct comparison.
Adds `RendererMainSettings.textBaselineMode` so apps can pick the per-
line vertical anchor used by the text layout engine without having to
fork the renderer. Three modes are supported:
'cap' (default) — capital letters centered on each line's geometric
mid-line. Best fit for UI text.
'x' — lowercase x-height centered. Better for body text.
'linebox' — legacy. Centers the asc/lineGap/desc rectangle.
The setting is engine-wide and intentionally not exposed per node: a
single app should not mix anchor models across its text. Picking it at
renderer creation also avoids the cache churn that would come from
flipping the value mid-session.
Wiring:
- New TextBaselineMode union in TextRenderer.ts is the single source
of truth for the type and documents the trade-offs.
- TextLayoutEngine holds the active mode in a module-level let and
exposes a setBaselineMode(mode) setter. Stage calls it during
construction, before any node exists, so mapTextLayout sees a
stable value for the lifetime of the renderer.
- mapTextLayout reads the module-level value directly when computing
`firstBaselineY`. The three anchor formulas are kept inline so the
code path matches the documentation block above it.
- SdfFontHandler now also derives xHeight from glyph 'x' (id 120) at
font-load time, mirroring the existing capHeight derivation. Only
consumed when textBaselineMode === 'x'; otherwise the 0.5 × ascender
fallback in normalizeFontMetrics is sufficient.
- RendererMain defaults the setting to 'cap' in its constructor and
forwards it to Stage alongside the other text-related options.
No behavioral change for existing apps: the default remains 'cap',
matching the previous PR's hard-coded anchor.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Replaces the per-line vertical anchor with cap-height centering: capital letters now bracket the line's geometric mid-line symmetrically at any font size. Previously the baseline was placed at `halfLeading + ascender`, centering the abstract `asc + lineGap − desc` rectangle. Because asc/(asc−desc) is asymmetric for most Latin fonts (~4.2:1 for Ubuntu), the visible ink landed noticeably high — at fontSize 100 in a 120-px box the cap-center sat ~10 px above the geometric center.
New formula:
```ts
baselineY(i) = lineHeight / 2 + capHeight / 2 + i * lineHeight
```
Reasoning and the two alternatives considered (x-height centering for body-text feel, the old line-box centering for backward compatibility) are preserved as comments above the formula in TextLayoutEngine.mapTextLayout.
Data path
Example
examples/tests/text-vertical-center.ts — rows of fontSizes (20, 40, 60, 80, 100) × sample strings (caps, mixed-case, descenders, digits, punctuation) in fixed-size colored boxes with `lineHeight === boxHeight` and a red guide line at the geometric center. `TXYZ` and `1234` now bracket the red line at every fontSize.
Why cap-height (and not the others)
`verticalAlign` (a separate concern — block positioning within a taller `maxHeight` container) is unaffected and slots cleanly on top of this when it lands.
Test plan
🤖 Generated with Claude Code