From af45032f6c1ff004c71cfbfc3b90d458a713d28c Mon Sep 17 00:00:00 2001 From: Akshay Kumar Sharma <25038017+akzarma@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:16:40 +0530 Subject: [PATCH] fix(fonts): declare the bundled subset's unicode-range `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 #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. --- .../deterministicFonts-bundledSubset.test.ts | 100 ++++++++++++++++++ .../src/services/deterministicFonts.ts | 43 +++++++- 2 files changed, 138 insertions(+), 5 deletions(-) create mode 100644 packages/producer/src/services/deterministicFonts-bundledSubset.test.ts diff --git a/packages/producer/src/services/deterministicFonts-bundledSubset.test.ts b/packages/producer/src/services/deterministicFonts-bundledSubset.test.ts new file mode 100644 index 0000000000..06c75b2c13 --- /dev/null +++ b/packages/producer/src/services/deterministicFonts-bundledSubset.test.ts @@ -0,0 +1,100 @@ +/** + * Regression test for the coverage a bundled face claims. + * + * `scripts/generate-font-data.ts` embeds the `-latin-` subset file of every + * canonical family, so an embedded face carries only Google's `latin` subset. + * The emitted `@font-face` used to omit `unicode-range` — advertising full + * coverage — and the supplementary Google fetch then skipped every subset of a + * weight the bundle "covered". A bundled family therefore could not render the + * scripts its own subset omits: `Noto Sans JP` weight 400 carries 218 + * codepoints with no kana and no kanji. + * + * These tests inject `fetchImpl` (no network) and a temp + * `HYPERFRAMES_FONT_CACHE_DIR` so they are hermetic. + */ + +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +let cacheDir: string; +let prevCacheEnv: string | undefined; + +beforeAll(() => { + prevCacheEnv = process.env.HYPERFRAMES_FONT_CACHE_DIR; + cacheDir = mkdtempSync(join(tmpdir(), "hf-font-subset-")); + process.env.HYPERFRAMES_FONT_CACHE_DIR = cacheDir; +}); + +afterAll(() => { + if (prevCacheEnv === undefined) delete process.env.HYPERFRAMES_FONT_CACHE_DIR; + else process.env.HYPERFRAMES_FONT_CACHE_DIR = prevCacheEnv; + rmSync(cacheDir, { recursive: true, force: true }); +}); + +const LATIN_RANGE = + "U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, " + + "U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD"; +const JAPANESE_RANGE = "U+3041-3096, U+30A0-30FF, U+4E00-9FFF"; + +const LATIN_URL = "https://fonts.gstatic.com/s/notosansjp/v1/notosansjp-latin.woff2"; +const JAPANESE_URL = "https://fonts.gstatic.com/s/notosansjp/v1/notosansjp-japanese.woff2"; + +// Weight 400 is in the embedded bundle; Google serves it as two subset faces. +const GOOGLE_CSS = `@font-face { + font-family: 'Noto Sans JP'; + font-style: normal; + font-weight: 400; + src: url(${JAPANESE_URL}) format('woff2'); + unicode-range: ${JAPANESE_RANGE}; +} +@font-face { + font-family: 'Noto Sans JP'; + font-style: normal; + font-weight: 400; + src: url(${LATIN_URL}) format('woff2'); + unicode-range: ${LATIN_RANGE}; +}`; + +const googleFetch = (async (input: unknown) => { + const url = String(input); + if (url.startsWith("https://fonts.googleapis.com/")) { + return new Response(GOOGLE_CSS, { status: 200 }); + } + if (url === JAPANESE_URL) return new Response("JAPANESE_SUBSET_BYTES", { status: 200 }); + if (url === LATIN_URL) return new Response("LATIN_SUBSET_BYTES", { status: 200 }); + return new Response("", { status: 404 }); +}) as unknown as typeof fetch; + +const HTML = `