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..48e091efc1 --- /dev/null +++ b/packages/producer/src/services/deterministicFonts-bundledSubset.test.ts @@ -0,0 +1,131 @@ +/** + * 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")); + }); +}); + +describe("bundled Latin precedence", () => { + it("retains text subsets and places bundled Latin after overlapping fetched faces", async () => { + const { injectDeterministicFontFaces } = await import("./deterministicFonts.js"); + const { EMBEDDED_FONT_DATA } = await import("./fontData.generated.js"); + const fetchImpl = (async (input: unknown) => { + const url = String(input); + if (url.startsWith("https://fonts.googleapis.com/")) { + expect(new URL(url).searchParams.get("family")).toStartWith("Inter:"); + return new Response(`@font-face { + font-family: 'Inter'; font-style: normal; font-weight: 400; + src: url(https://fonts.gstatic.com/s/inter/text-subset.woff2) format('woff2'); + }`); + } + return new Response("TEXT_SUBSET_CYRILLIC"); + }) as unknown as typeof fetch; + const html = HTML.replaceAll("Noto Sans JP", "Helvetica").replace("日本語", "Hello Привет"); + const result = await injectDeterministicFontFaces(html, { + fetchImpl, + allowSystemFontCapture: false, + }); + const fetched = b64("TEXT_SUBSET_CYRILLIC"); + const embedded = EMBEDDED_FONT_DATA.get("@fontsource/inter:400:normal")!; + expect(result).toContain(fetched); + expect(result.indexOf(embedded)).toBeGreaterThan(result.indexOf(fetched)); + const faces = result.match(/@font-face \{[\s\S]*?\}/g) ?? []; + const bundled = faces.find((face) => face.includes(embedded)); + expect(bundled).toContain('font-family: "Helvetica"'); + expect(bundled).toContain(`unicode-range: ${LATIN_RANGE};`); + }); +}); diff --git a/packages/producer/src/services/deterministicFonts.ts b/packages/producer/src/services/deterministicFonts.ts index 20fe3e7066..4c1c20bfaa 100644 --- a/packages/producer/src/services/deterministicFonts.ts +++ b/packages/producer/src/services/deterministicFonts.ts @@ -470,6 +470,19 @@ export function fontFormatHint(src: string): "collection" | "woff2" { return src.startsWith("data:font/collection;") ? "collection" : "woff2"; } +// generate-font-data.ts embeds Fontsource's -latin- subset for every family. +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"; + +function isBundledSubsetRange(unicodeRange: string | undefined): boolean { + const normalize = (range: string) => range.toLowerCase().replace(/\s+/g, ""); + return ( + unicodeRange !== undefined && + normalize(unicodeRange) === normalize(BUNDLED_SUBSET_UNICODE_RANGE) + ); +} + function buildFontFaceRule( familyName: string, src: string, @@ -584,17 +597,26 @@ async function buildFontFaceCss( for (const [normalizedFamily, originalCaseFamily] of requestedFamilies) { // Path 1: pre-bundled fonts via FONT_ALIASES — emit embedded faces, - // then fetch from Google Fonts to fill any weights not in the bundle. + // then fetch from Google Fonts to fill missing weights and character subsets. const canonicalKey = FONT_ALIASES[normalizedFamily]; if (canonicalKey) { const canonical = CANONICAL_FONTS[canonicalKey]; if (!canonical) continue; const coveredWeights = new Set(); + const bundledRules: string[] = []; 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)); + bundledRules.push( + buildFontFaceRule( + originalCaseFamily, + src, + face.weight, + style, + BUNDLED_SUBSET_UNICODE_RANGE, + ), + ); coveredWeights.add(coverageKey(face.weight, style)); } @@ -612,11 +634,12 @@ async function buildFontFaceCss( ? await fetchGoogleFont(canonicalFamily, options, fontText) : []; - // A weight covered by the embedded bundle is already full-coverage — - // skip it. For weights the bundle lacks, keep EVERY subset face (a - // weight has one face per unicode-range subset), not just the first. + // Bundled weights only cover Latin. Keep other subsets (including + // text= responses without a range), even for weights already embedded. const supplementary = googleFaces.filter( - (face) => !coveredWeights.has(coverageKey(face.weight, face.style)), + (face) => + !coveredWeights.has(coverageKey(face.weight, face.style)) || + !isBundledSubsetRange(face.unicodeRange), ); const runs = groupFacesBySource(supplementary).flatMap((group) => partitionWeightRuns(group, coveredWeights), @@ -641,6 +664,9 @@ async function buildFontFaceCss( ), ); } + // Broader or text-subset responses can overlap Latin. Emit the bundle + // last so existing Latin glyphs keep their deterministic bundled source. + rules.push(...bundledRules); continue; }