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 = `

日本語

`; + +const b64 = (s: string) => Buffer.from(s).toString("base64"); + +describe("bundled subset coverage", () => { + it("declares the bundle's subset and keeps the subsets it omits", async () => { + const { injectDeterministicFontFaces } = await import("./deterministicFonts.js"); + const result = await injectDeterministicFontFaces(HTML, { + allowSystemFontCapture: false, + fetchImpl: googleFetch, + }); + + // Every emitted face declares a unicode-range: none claims full coverage. + const faces = result.match(/@font-face \{[\s\S]*?\}/g) ?? []; + expect(faces.length).toBeGreaterThan(0); + for (const face of faces) expect(face).toContain("unicode-range:"); + + // The embedded faces declare the latin subset they actually ship. + expect(result).toContain(`unicode-range: ${LATIN_RANGE};`); + + // Weight 400 is in the bundle, but its Japanese subset is still injected — + // otherwise a family documented as CJK cannot render Japanese. + expect(result).toContain(b64("JAPANESE_SUBSET_BYTES")); + expect(result).toContain(`unicode-range: ${JAPANESE_RANGE};`); + + // The latin face for that same weight is still skipped: the bundle has it. + expect(result).not.toContain(b64("LATIN_SUBSET_BYTES")); + }); +}); diff --git a/packages/producer/src/services/deterministicFonts.ts b/packages/producer/src/services/deterministicFonts.ts index 85e3e27d8a..86a06e34f0 100644 --- a/packages/producer/src/services/deterministicFonts.ts +++ b/packages/producer/src/services/deterministicFonts.ts @@ -436,6 +436,23 @@ export function fontFormatHint(src: string): "collection" | "woff2" { return src.startsWith("data:font/collection;") ? "collection" : "woff2"; } +/** + * `scripts/generate-font-data.ts` embeds the `-latin-` subset file of every + * bundled family, so a bundled face covers Google's `latin` subset and nothing + * else. Declaring that range stops the face from claiming coverage it does not + * have, and lets the supplementary Google faces serve the codepoints it omits. + */ +const BUNDLED_SUBSET_UNICODE_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"; + +/** True when a fetched face covers the same subset the embedded bundle ships. */ +function isBundledSubsetRange(unicodeRange: string | undefined): boolean { + if (!unicodeRange) return false; + const normalize = (range: string) => range.toLowerCase().replace(/\s+/g, ""); + return normalize(unicodeRange) === normalize(BUNDLED_SUBSET_UNICODE_RANGE); +} + function buildFontFaceRule( familyName: string, src: string, @@ -480,7 +497,15 @@ async function buildFontFaceCss( for (const face of canonical.faces) { const style = face.style || "normal"; const src = fontDataUri(canonical.packageName, face.weight, style); - rules.push(buildFontFaceRule(originalCaseFamily, src, face.weight, style)); + rules.push( + buildFontFaceRule( + originalCaseFamily, + src, + face.weight, + style, + BUNDLED_SUBSET_UNICODE_RANGE, + ), + ); coveredWeights.add(`${face.weight}:${style}`); } @@ -490,10 +515,18 @@ async function buildFontFaceCss( // if the bundle only ships 400/700/900. const googleFaces = await fetchGoogleFont(originalCaseFamily, options, fontText); for (const face of googleFaces) { - // A weight covered by the embedded bundle is already full-coverage — - // skip it. For weights the bundle lacks, add EVERY subset face (a - // weight has one face per unicode-range subset), not just the first. - if (coveredWeights.has(`${face.weight}:${face.style}`)) continue; + // The embedded bundle only covers the latin subset, so a "covered" + // weight is covered for that subset alone — skip just the face that + // duplicates it and keep every other subset, otherwise the family + // cannot render the scripts the bundle omits. For weights the bundle + // lacks, add EVERY subset face (a weight has one face per + // unicode-range subset), not just the first. + if ( + coveredWeights.has(`${face.weight}:${face.style}`) && + isBundledSubsetRange(face.unicodeRange) + ) { + continue; + } rules.push( buildFontFaceRule( originalCaseFamily,