From d9d01939b4d6a9f37c97b90fb0d207496fa0d515 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] 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(", "),