Skip to content

fix(fonts): declare the bundled subset's unicode-range - #3086

Open
akzarma wants to merge 1 commit into
heygen-com:mainfrom
akzarma:fix/bundled-font-unicode-range
Open

fix(fonts): declare the bundled subset's unicode-range#3086
akzarma wants to merge 1 commit into
heygen-com:mainfrom
akzarma:fix/bundled-font-unicode-range

Conversation

@akzarma

@akzarma akzarma commented Aug 7, 2026

Copy link
Copy Markdown

What breaks

A pre-bundled family cannot render the scripts its embedded subset omits. The requested typeface is lost for those codepoints and the text falls through to whatever fonts the render host happens to have installed — the outcome a deterministic font injector exists to prevent.

Decoding the committed fontData.generated.ts and reading each font's cmap:

embedded face codepoints notes
@fontsource/inter:400:normal 230 no ł, č, ğ, ș, ; no Cyrillic; no Greek
@fontsource/roboto:400:normal 229 same
@fontsource/noto-sans-jp:400:normal 218 zero kana, zero kanji

skills/hyperframes-creative/references/typography.md lists Noto Sans JP | 400 · 700 | CJK (Japanese), and the real noto-sans-jp-japanese-400-normal.woff2 (~1 MB) sits unused in the same package directory.

This is not limited to CJK, and that is the more common case: FONT_ALIAS_MAP routes arial, helvetica, segoe ui, verdana and friends into Inter/Roboto, so ordinary Central European, Turkish, Vietnamese and Cyrillic text hits it.

Root cause

packages/producer/src/services/deterministicFonts.ts, path 1 of buildFontFaceCss:

  • scripts/generate-font-data.ts resolves ${slug}-latin-${weight}-${style}.woff2 — the latin subset, always.
  • The embedded face was emitted with no unicode-range, so it advertises the full range.
  • The supplementary Google fetch then skipped a weight entirely once the bundle covered it: "A weight covered by the embedded bundle is already full-coverage". It is covered for latin only.

The fix

  1. Emit the latin unicode-range on embedded faces, so they stop claiming coverage they lack.
  2. Skip only the fetched face that duplicates that subset, rather than the whole weight — every other subset is kept.

Merged PR #1123 made the same correction for Google-fetched faces (a face without unicode-range claiming full coverage), so this brings the bundled path in line with it.

On download cost: fetchGoogleFont already downloads the faces before the caller discards covered weights, so this does not add network requests to the existing fetch — it keeps subsets that were being fetched and then thrown away. It does add @font-face rules and embedded bytes to the generated HTML for bundled families whose weights Google serves in multiple subsets.

Known imprecision, called out deliberately: the range is a constant here because the generator does not record which subset it embedded. Deriving the true range per face at generation time would be more precise and is the natural follow-up; it would mean regenerating the committed font data, which felt out of scope for a fix. The constant matches Google's current latin subset range, so a handful of combining marks present in some embedded faces (U+0303, U+0309, U+0323) now resolve through a supplementary face instead of the bundled one.

Test

deterministicFonts-bundledSubset.test.ts — hermetic, injects fetchImpl, no network. For Noto Sans JP weight 400 (a bundled weight) it asserts that every emitted face declares a unicode-range, that the embedded faces declare the latin subset, that the Japanese subset face is injected, and that the duplicate latin face is still skipped.

Verified the test bites: against unfixed source 0 pass / 1 fail; with the fix 1 pass / 0 fail.

Test plan

  • bun test packages/producer/src/services/deterministicFonts-bundledSubset.test.ts → 1 pass, 0 fail
  • Same test against unfixed source → 1 fail, confirming it bites
  • bun test on deterministicFonts.test.ts, deterministicFonts-failClosed.test.ts, deterministicFonts-systemCapture.test.ts → 31 pass, 0 fail
  • oxfmt --check and oxlint on both changed files → clean
  • Codepoint counts above measured directly from the committed fontData.generated.ts with fontTools
  • No end-to-end distributed render performed — relying on CI

`generate-font-data.ts` embeds the `-latin-` subset file of every
canonical family, but `buildFontFaceCss` emitted those faces with no
`unicode-range`, so each one advertised coverage it does not have. The
supplementary Google fetch then skipped every subset of a weight the
bundle already "covered", on the premise that a covered weight is
full-coverage.

A bundled family therefore cannot render the scripts its latin subset
omits. Decoding the committed font data: `noto-sans-jp` weight 400 is
218 codepoints with zero kana and zero kanji, while the typography
reference lists the family as CJK (Japanese); `inter` and `roboto`
weight 400 are ~230 codepoints, missing latin-ext, Cyrillic and Greek.
Because FONT_ALIAS_MAP routes arial, helvetica, segoe ui and verdana
into those families, ordinary Central European, Turkish, Vietnamese and
Cyrillic text loses the requested typeface and falls through to whatever
the render host has installed.

Declare the latin range on the embedded faces, and skip only the fetched
face that duplicates that subset instead of the whole weight. Merged
PR heygen-com#1123 made the same correction for Google-fetched faces.

The exact range is a constant here because the generator does not record
the subset it embedded; deriving it per face at generation time would be
the more precise follow-up.
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