Skip to content

feat: resolve rem/em font units to px before canvas measurement - #109

Open
okaris wants to merge 1 commit into
chenglou:mainfrom
okaris:feat/resolve-relative-font-units
Open

feat: resolve rem/em font units to px before canvas measurement#109
okaris wants to merge 1 commit into
chenglou:mainfrom
okaris:feat/resolve-relative-font-units

Conversation

@okaris

@okaris okaris commented Apr 6, 2026

Copy link
Copy Markdown

Problem

Canvas ctx.font silently ignores relative CSS units (rem, em). When callers pass CSS-derived font strings like "0.9rem Inter", canvas keeps its previous font and returns wrong widths. This is a spec limitation — canvas can exist without a document root, so relative units have no reference point (WHATWG HTML #1682).

In practice, anyone using Tailwind or a CSS framework with custom text-sm sizes (e.g. --text-sm: 0.9rem instead of the default 0.875rem) gets silently wrong measurements. The text measures at whatever font was previously set on the canvas context, not at the intended size.

Related: #107 (canvas doesn't account for certain CSS properties).

Solution

Add resolveFont() in measurement.ts that rewrites relative units to absolute px before the string reaches canvas:

"0.9rem Inter" → "14.4px Inter"  (given 16px root)
"bold 1.5em mono" → "bold 24px mono"
"14px Inter" → "14px Inter"  (no-op, no allocation)

Resolution uses getComputedStyle(document.documentElement).fontSize — the same level of DOM access that getEmojiCorrection() already does. Results are cached at module level so the DOM read happens once. Environments without a document (OffscreenCanvas, Node.js) fall back to 16px.

Changes

  • resolveFont(font) — exported, cached, handles rem/em → px
  • getFontMeasurementState() — resolves font before ctx.font = and cache keying
  • parseFontSize() — resolves before extracting the px value
  • clearMeasurementCaches() — clears the resolution cache + cached root font size
  • Tests for px passthrough, rem resolution, em resolution, and parseFontSize integration

Test plan

  • bun test src/layout.test.ts — 85 tests pass (5 new)
  • Existing tests unaffected (all use px fonts, passthrough is zero-cost)
  • No document in test env → falls back to 16px root, rem math still correct

Canvas ctx.font silently ignores relative CSS units like rem and em
(spec limitation — WHATWG HTML #1682). Callers passing CSS-derived font
strings such as "0.9rem Inter" get no measurement at all; canvas keeps
the previous font and returns wrong widths.

This adds resolveFont() which rewrites relative units to absolute px
using getComputedStyle(document.documentElement).fontSize before the
string reaches canvas. Results are cached at module level so the DOM
read happens once. Environments without a document (OffscreenCanvas,
Node) fall back to 16px.

- getFontMeasurementState() now resolves the font before setting ctx.font
- parseFontSize() resolves before extracting the px value
- clearMeasurementCaches() clears the resolution cache
- resolveFont() is exported for direct use by consumers
@okaris
okaris force-pushed the feat/resolve-relative-font-units branch from 66f926d to 961808b Compare July 26, 2026 13:21
@okaris

okaris commented Jul 26, 2026

Copy link
Copy Markdown
Author

Rebased onto current main and force-pushed — sorry for the churn on an old PR.

The previous version accidentally included built dist/ output (16 files, +4343), which both buried the actual change and made the PR conflict with every release. That commit is dropped; dist is gitignored upstream and stays that way.

What is left is the original change only:

  • src/measurement.ts (+46/-5) — resolve rem/em to px before assigning ctx.font, since canvas silently ignores relative units. Cached root font-size read, cached per-font-string result, absolute fonts pass through untouched with no allocation.
  • src/layout.test.ts (+31) — coverage for rem resolution and parseFontSize on resolved values.

measurement.ts rebased with no conflicts. The only conflict was in the test file, where main had since added its own MeasurementModule type and measurementMod import — I kept yours and dropped my now-duplicate ones.

Verified on the rebased branch: bun test 121 pass / 0 fail, tsc clean.

Happy to split it further or adjust the caching approach if you would prefer something different.

okaris added a commit to inference-sh/shadcn-registry that referenced this pull request Jul 26, 2026
The fork branch was 4 releases behind upstream (0.0.4 vs 0.0.8). Rebasing
it onto upstream main brings in substantial text-layout work — analysis
+29%, layout +20%, line-break +12%, plus a new line-text module — while
keeping our rem/em canvas fix.

Switches the ref from feat/ to built/resolve-relative-font-units. The feat/
branch is now the clean upstream PR (chenglou/pretext#109, 2 files) and no
longer carries dist/. package.json points main/exports at ./dist and there
is no prepare script, so a git dep needs the built output committed — the
built/ branch tracks feat/ plus that output.
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