From 7e57c5e16f51596c13ff976ef40dce6061d5fdc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 13 Jul 2026 19:27:57 +0000 Subject: [PATCH 1/3] fix(fonts): subset Google Fonts to composition text --- .../deterministicFonts-textSubset.test.ts | 25 +++++++++++++++++ .../src/services/deterministicFonts.ts | 27 ++++++++++++++++--- 2 files changed, 48 insertions(+), 4 deletions(-) create mode 100644 packages/producer/src/services/deterministicFonts-textSubset.test.ts diff --git a/packages/producer/src/services/deterministicFonts-textSubset.test.ts b/packages/producer/src/services/deterministicFonts-textSubset.test.ts new file mode 100644 index 0000000000..0bcb8931ef --- /dev/null +++ b/packages/producer/src/services/deterministicFonts-textSubset.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "bun:test"; +import { injectDeterministicFontFaces } from "./deterministicFonts.js"; + +describe("Google Fonts text subsetting", () => { + it("sends the composition character set to the CSS API", async () => { + let requestedUrl = ""; + const fetchImpl = (async (input: unknown) => { + requestedUrl = String(input); + return new Response("", { status: 400 }); + }) as unknown as typeof fetch; + + await injectDeterministicFontFaces( + `

旅行ランキング

`, + { fetchImpl, allowSystemFontCapture: false }, + ); + + const url = new URL(requestedUrl); + const text = url.searchParams.get("text") ?? ""; + for (const character of new Set("旅行ランキング")) { + expect(text).toContain(character); + } + }); +}); diff --git a/packages/producer/src/services/deterministicFonts.ts b/packages/producer/src/services/deterministicFonts.ts index 2805bae2e9..6bebd8e213 100644 --- a/packages/producer/src/services/deterministicFonts.ts +++ b/packages/producer/src/services/deterministicFonts.ts @@ -456,6 +456,7 @@ function buildFontFaceRule( async function buildFontFaceCss( requestedFamilies: Map, options: InternalFontFetchOptions, + fontText?: string, ): Promise<{ css: string; unresolved: string[]; @@ -483,7 +484,7 @@ async function buildFontFaceCss( // already covered by the embedded bundle. This ensures that // compositions requesting e.g. wght@200 get that weight even // if the bundle only ships 400/700/900. - const googleFaces = await fetchGoogleFont(originalCaseFamily, options); + 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 @@ -503,7 +504,7 @@ async function buildFontFaceCss( } // Path 2: fetch from Google Fonts (with local cache) - const googleFaces = await fetchGoogleFont(originalCaseFamily, options); + const googleFaces = await fetchGoogleFont(originalCaseFamily, options, fontText); if (googleFaces.length > 0) { for (const face of googleFaces) { rules.push( @@ -735,10 +736,12 @@ async function ensureWoff2DataUri( async function fetchGoogleFont( familyName: string, options: InternalFontFetchOptions, + fontText?: string, ): Promise { const slug = fontSlug(familyName); const encodedFamily = encodeURIComponent(familyName); - const url = `https://fonts.googleapis.com/css2?family=${encodedFamily}:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,400;1,700`; + const textParam = fontText ? `&text=${encodeURIComponent(fontText)}` : ""; + const url = `https://fonts.googleapis.com/css2?family=${encodedFamily}:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,400;1,700${textParam}`; let cssText: string; try { @@ -844,6 +847,18 @@ export interface InjectDeterministicFontFacesOptions { allowSystemFontCapture?: boolean; } +// Keep the complete CSS request under the broadly supported ~2 KB URL limit. +// Using unique source characters covers static text plus strings authored in +// scripts, while collapsing repeated prose and base64 assets to a tiny set. +const GOOGLE_FONTS_TEXT_MAX_ENCODED_LENGTH = 1_700; + +function extractGoogleFontsText(html: string): string | undefined { + const uniqueCharacters = [...new Set(Array.from(html))].join(""); + return encodeURIComponent(uniqueCharacters).length <= GOOGLE_FONTS_TEXT_MAX_ENCODED_LENGTH + ? uniqueCharacters + : undefined; +} + export async function injectDeterministicFontFaces( html: string, options: InjectDeterministicFontFacesOptions = {}, @@ -871,7 +886,11 @@ export async function injectDeterministicFontFaces( return html; } - const { css, unresolved } = await buildFontFaceCss(pendingFamilies, fetchOptions); + const { css, unresolved } = await buildFontFaceCss( + pendingFamilies, + fetchOptions, + extractGoogleFontsText(html), + ); if (unresolved.length > 0 && options.failClosedFontFetch) { throw new FontFetchError( unresolved.join(", "), From 41fcd438681429ed83f16283b34a354e5b29a8a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 13 Jul 2026 07:49:37 +0000 Subject: [PATCH 2/3] fix(producer): skip slow TTC recompression --- .../deterministicFonts-systemCapture.test.ts | 7 ++++++- .../producer/src/services/deterministicFonts.ts | 6 +++++- .../src/services/fontCompression.test.ts | 16 ++++++++++++---- .../producer/src/services/fontCompression.ts | 3 +++ 4 files changed, 26 insertions(+), 6 deletions(-) diff --git a/packages/producer/src/services/deterministicFonts-systemCapture.test.ts b/packages/producer/src/services/deterministicFonts-systemCapture.test.ts index e04117d576..87c716b906 100644 --- a/packages/producer/src/services/deterministicFonts-systemCapture.test.ts +++ b/packages/producer/src/services/deterministicFonts-systemCapture.test.ts @@ -12,7 +12,7 @@ import { describe, expect, it } from "bun:test"; import { existsSync } from "node:fs"; -import { injectDeterministicFontFaces } from "./deterministicFonts.js"; +import { fontFormatHint, injectDeterministicFontFaces } from "./deterministicFonts.js"; // A font family that is NOT in FONT_ALIASES but exists on macOS as // /System/Library/Fonts/Supplemental/Impact.ttf. When Google Fonts @@ -36,6 +36,11 @@ function makeHttp400Fetch(): typeof fetch { } describe("system font capture — allowSystemFontCapture option", () => { + it("uses the CSS collection hint for raw TTC data URIs", () => { + expect(fontFormatHint("data:font/collection;base64,AA==")).toBe("collection"); + expect(fontFormatHint("data:font/woff2;base64,AA==")).toBe("woff2"); + }); + it("does not attempt system font capture when allowSystemFontCapture is false", async () => { const html = makeHtml("NotARealFontFamilyForTest"); const result = await injectDeterministicFontFaces(html, { diff --git a/packages/producer/src/services/deterministicFonts.ts b/packages/producer/src/services/deterministicFonts.ts index 6bebd8e213..29e91bb9b8 100644 --- a/packages/producer/src/services/deterministicFonts.ts +++ b/packages/producer/src/services/deterministicFonts.ts @@ -432,6 +432,10 @@ function extractRequestedFontFamilies(html: string): Map { return requested; } +export function fontFormatHint(src: string): "collection" | "woff2" { + return src.startsWith("data:font/collection;") ? "collection" : "woff2"; +} + function buildFontFaceRule( familyName: string, src: string, @@ -442,7 +446,7 @@ function buildFontFaceRule( return [ "@font-face {", ` font-family: "${familyName}";`, - ` src: url("${src}") format("woff2");`, + ` src: url("${src}") format("${fontFormatHint(src)}");`, ` font-style: ${style};`, ` font-weight: ${weight};`, " font-display: block;", diff --git a/packages/producer/src/services/fontCompression.test.ts b/packages/producer/src/services/fontCompression.test.ts index e4e3716790..37934e95bb 100644 --- a/packages/producer/src/services/fontCompression.test.ts +++ b/packages/producer/src/services/fontCompression.test.ts @@ -94,9 +94,17 @@ describe("fontToDataUri", () => { expect(uri).toMatch(/^data:font\/otf;base64,/); }); - it("falls back with correct MIME type for ttc", async () => { - const garbage = Buffer.from("not a font"); - const uri = await fontToDataUri(garbage, "ttc"); - expect(uri).toMatch(/^data:font\/collection;base64,/); + it("embeds ttc collections raw without attempting slow woff2 compression", async () => { + const warnings: unknown[][] = []; + const originalWarn = console.warn; + console.warn = (...args: unknown[]) => warnings.push(args); + try { + const raw = Buffer.from("ttc-bytes"); + const uri = await fontToDataUri(raw, "ttc"); + expect(uri).toBe(`data:font/collection;base64,${raw.toString("base64")}`); + expect(warnings).toHaveLength(0); + } finally { + console.warn = originalWarn; + } }); }); diff --git a/packages/producer/src/services/fontCompression.ts b/packages/producer/src/services/fontCompression.ts index ae3d952bfd..c968a1a68e 100644 --- a/packages/producer/src/services/fontCompression.ts +++ b/packages/producer/src/services/fontCompression.ts @@ -82,6 +82,9 @@ export async function fontToDataUri( if (originalFormat === "woff2") { return `data:font/woff2;base64,${input.toString("base64")}`; } + if (originalFormat === "ttc") { + return `data:font/collection;base64,${input.toString("base64")}`; + } try { const cachePath = cachedCompressionPath( input, From 9ebd396d21466741a17f44eb97f340766c083fa4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 14 Jul 2026 22:49:27 +0000 Subject: [PATCH 3/3] fix(fonts): include decoded composition text in subsets --- .../deterministicFonts-textSubset.test.ts | 17 +++++++++++++++++ .../producer/src/services/deterministicFonts.ts | 6 +++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/producer/src/services/deterministicFonts-textSubset.test.ts b/packages/producer/src/services/deterministicFonts-textSubset.test.ts index 0bcb8931ef..e3b0a39cd7 100644 --- a/packages/producer/src/services/deterministicFonts-textSubset.test.ts +++ b/packages/producer/src/services/deterministicFonts-textSubset.test.ts @@ -22,4 +22,21 @@ describe("Google Fonts text subsetting", () => { expect(text).toContain(character); } }); + + it("includes decoded HTML entities from visible composition text", async () => { + let requestedUrl = ""; + const fetchImpl = (async (input: unknown) => { + requestedUrl = String(input); + return new Response("", { status: 400 }); + }) as unknown as typeof fetch; + + await injectDeterministicFontFaces( + `

旅行

`, + { fetchImpl, allowSystemFontCapture: false }, + ); + + expect(new URL(requestedUrl).searchParams.get("text")).toContain("旅行"); + }); }); diff --git a/packages/producer/src/services/deterministicFonts.ts b/packages/producer/src/services/deterministicFonts.ts index 29e91bb9b8..24dee3db02 100644 --- a/packages/producer/src/services/deterministicFonts.ts +++ b/packages/producer/src/services/deterministicFonts.ts @@ -857,7 +857,11 @@ export interface InjectDeterministicFontFacesOptions { const GOOGLE_FONTS_TEXT_MAX_ENCODED_LENGTH = 1_700; function extractGoogleFontsText(html: string): string | undefined { - const uniqueCharacters = [...new Set(Array.from(html))].join(""); + const { document } = parseHTML(html); + const decodedBodyText = document.body?.textContent ?? ""; + const uniqueCharacters = [...new Set([...Array.from(html), ...Array.from(decodedBodyText)])].join( + "", + ); return encodeURIComponent(uniqueCharacters).length <= GOOGLE_FONTS_TEXT_MAX_ENCODED_LENGTH ? uniqueCharacters : undefined;