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,