Skip to content

feat(text): cap-height centering as the per-line vertical anchor#27

Merged
chiefcll merged 4 commits into
mainfrom
feat/text-cap-height-centering
May 22, 2026
Merged

feat(text): cap-height centering as the per-line vertical anchor#27
chiefcll merged 4 commits into
mainfrom
feat/text-cap-height-centering

Conversation

@chiefcll
Copy link
Copy Markdown
Contributor

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

Layer Change
`FontMetrics` Optional `capHeight` field (font units).
`NormalizedFontMetrics` Required `capHeight` (em-px).
`normalizeFontMetrics` Falls back to `0.7 × ascender` when `metrics.capHeight` is absent — generic Latin approximation, within a few percent of OS/2 sCapHeight on Ubuntu/Noto/Roboto/Arial.
`SdfFontHandler.processFontData` Derives the exact value from BMFont: `capHeight_atlas = common.base − glyph['H'].yoffset` (atlas-px), converted to font units. For Ubuntu / Noto at `info.size = 42` this yields 738 / 762 per 1000-em respectively.
Canvas backend No code change — picks up the 0.7-fallback path until consumers ship explicit `capHeight` in `lightningMetrics`.

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)

  • Cap-height: best fit for UI text in TV apps — button labels, headings, badges, status text. Capitals look anchored; descenders hang slightly below center, matching CSS button behavior in browsers.
  • x-height: better for running body text; would put capitals visibly high in our typical UI patterns.
  • Line-box (the previous behavior): mathematically tidy, visually unbalanced.

`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

  • `pnpm test --run` (168/168 pass)
  • Renderer + examples `pnpm build` clean
  • `pnpm test:visual --ci --capture --overwrite` — many text-containing snapshots will diff; the shift is the fix.

🤖 Generated with Claude Code

chiefcll added 4 commits May 22, 2026 10:30
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.
@chiefcll chiefcll merged commit f4a728f into main May 22, 2026
1 check passed
@chiefcll chiefcll deleted the feat/text-cap-height-centering branch May 22, 2026 16:02
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