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;
}