Skip to content

fix(text): baseline-anchored layout, include lineGap, trim trailing letterSpacing#23

Merged
chiefcll merged 6 commits into
mainfrom
fix/text-layout-baseline-and-linegap
May 21, 2026
Merged

fix(text): baseline-anchored layout, include lineGap, trim trailing letterSpacing#23
chiefcll merged 6 commits into
mainfrom
fix/text-layout-baseline-and-linegap

Conversation

@chiefcll
Copy link
Copy Markdown
Contributor

Summary

Aligns SDF and Canvas text layout with CSS line-box semantics. Multiple fonts at the same size now share a baseline, default line spacing matches the web's normal line-height, and trailing letter-spacing no longer skews alignment.

Changes

TextLayoutEngine.mapTextLayout

  • bareLineHeight now includes lineGap (CSS normal = asc + lineGap - desc).
  • line[4] is the alphabetic baseline Y in screen px, not the line-box top.
  • Renamed halfDeltahalfLeading; computed firstBaselineY = halfLeading + ascender.
  • After lines are wrapped/measured, one trailing letterSpacing is trimmed from each non-empty line's reported width so effectiveMaxWidth and textAlign center/right offsets no longer over-count by one letter-spacing. Wrap decisions still use the un-trimmed accumulators (no wrap-behavior regressions).

SdfTextRenderer

  • Anchors glyphs against the alphabetic baseline using common.base: glyph.y = baselineY + yoffset − atlasBase. Fonts whose BMFont base differs (e.g. Ubuntu 32.6 vs Noto 44.9 at design size 42) now sit on the same line at the same fontSize.
  • Dropped the SDF-specific designLineHeight fallback so the engine's lineHeight × bareLineHeight (incl. lineGap) is used uniformly with Canvas.
  • fontStyle added to the layout cache key.

CanvasTextRenderer

  • Both measuring and drawing contexts switched from textBaseline = 'hanging' to 'alphabetic', matching the new line[4] semantics.

Why

Previous code:

  1. Dropped lineGap from default line height (~6% tighter than the browser for Ubuntu).
  2. Treated BMFont's line-box top as the engine's line-box top, so two fonts with different common.base could not share an on-screen baseline.
  3. Added trailing letterSpacing to each line width, shifting center/right alignment by letterSpacing px.
  4. Canvas/SDF used different baseline conventions ('hanging' vs ad-hoc yoffset placement), so the two backends could not produce identical layouts for the same input.

Reviewer notes

  • Expected visual-regression diffs: default-line-height text is taller (lineGap added). Multi-font scenes share a baseline. Centered/right-aligned text with non-zero letterSpacing shifts by ~½ letterSpacing toward the correct CSS position. pnpm test:visual:update will need to be re-run and certified snapshots reviewed.
  • verticalAlign is not implemented in this PR — it's the next change. A handful of related items (e.g. gating maxHeight truncation on contain) are intentionally deferred to land with that work.
  • The lineHeight <= 3 unitless-multiplier heuristic is kept as-is (API contract).

Test plan

  • pnpm test --run (148/148 pass)
  • pnpm build clean
  • Prettier/eslint clean on changed files
  • pnpm test:visual:update and review certified snapshot diffs before merge

🤖 Generated with Claude Code

…etterSpacing

Aligns SDF and Canvas text layout with CSS line-box semantics so that
multiple fonts at the same size share a baseline and default line spacing
matches web 'normal'.

- TextLayoutEngine: bareLineHeight now includes lineGap (CSS normal =
  asc + lineGap - desc). line[4] is the alphabetic baseline Y rather
  than the line-box top. Renamed halfDelta -> halfLeading for clarity.
- TextLayoutEngine: trim one trailing letterSpacing from each non-empty
  line's reported width so textAlign center/right and effectiveMaxWidth
  no longer over-count by one letter-spacing. Wrap decisions still use
  the un-trimmed accumulators.
- SdfTextRenderer: anchor glyphs to the alphabetic baseline via
  common.base (glyph.y = baselineY + yoffset - atlasBase). Fonts whose
  BMFont 'base' differs (e.g. Ubuntu 32.6 vs Noto 44.9 at design size 42)
  now sit on the same line. Dropped the SDF-specific designLineHeight
  fallback so the engine's lineHeight*bareLineHeight is used uniformly
  with Canvas.
- SdfTextRenderer: include fontStyle in the layout cache key.
- CanvasTextRenderer: switch both contexts from textBaseline 'hanging'
  to 'alphabetic' so fillText draws the baseline at line[4].

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@chiefcll chiefcll force-pushed the fix/text-layout-baseline-and-linegap branch from d532b39 to efde96c Compare May 21, 2026 16:32
chiefcll and others added 4 commits May 21, 2026 15:42
Pulls in #24 (renderUpdate event name) and #25 (VRT threshold + clip
shape) so this branch's CI runs against the tightened comparator. The
visual-regression diffs surfaced here should now reflect real layout
changes.
…ents

Each page(i) call in the viewport-events automation kicks off a multi-
frame cascade: position mutation -> bounds intersection recompute ->
inBounds / inViewport / outOfBounds events -> status-text mutations
in handlers -> text re-layout and SDF atlas update -> final render.
settings.snapshot() only sleeps a flat 200ms after the mutation, which
is usually enough at 60Hz but is not deterministic - under load or
when the cascade takes an extra frame the snapshot lands mid-update
and produces a sub-pixel diff against the certified image.

Added a local waitForIdle helper that awaits the renderer's 'idle'
event (fired from WebPlatform once stage.hasSceneUpdates() returns
false - i.e. every dirty node has actually rendered) with a 500ms
safety timeout in case a mutation happens to be a no-op. Called before
every snapshot including the initial one (so font load + first bounds
cascade are also flushed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After re-certifying snapshots in Docker, viewport-events and other
text-heavy tests still drifted by a small handful of glyph-edge pixels.
The Chromium flags that suppress text-AA variance only affect Canvas
text rendering; SDF glyphs go through our own WebGL pipeline where
sub-pixel float-precision differences across Docker rebuilds land as
isolated mis-classified anti-aliasing pixels at glyph boundaries.

pixelmatch's `threshold` is the wrong knob for this - it controls per-
pixel color distance, and lowering it would make these failures worse.
The standard graphics-test answer is a small absolute pixel-count
tolerance.

Added MAX_DIFF_RATIO = 0.05% of total frame pixels (about 460 px for a
1280x720 capture, ~1000 px for 1920x1080). Tight enough that a missing
word, a swapped color, or a shifted rect would still trip the check;
loose enough that residual AA jitter on character outlines doesn't.
The failure message now reports both the actual count and the allowed
tolerance so future drift expansions are obvious in the log.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@chiefcll chiefcll merged commit 85e6f7c into main May 21, 2026
1 check passed
@chiefcll chiefcll deleted the fix/text-layout-baseline-and-linegap branch May 21, 2026 20:53
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