From 744146eb6e71f3c108091fe981a7d63859a6287b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 25 Aug 2026 23:57:07 +0000 Subject: [PATCH] fix(fonts): cover rendered case variants in subsets --- .../deterministicFonts-textSubset.test.ts | 43 +++++++++++++++++++ .../src/services/deterministicFonts.ts | 22 +++++++--- 2 files changed, 58 insertions(+), 7 deletions(-) diff --git a/packages/producer/src/services/deterministicFonts-textSubset.test.ts b/packages/producer/src/services/deterministicFonts-textSubset.test.ts index e3b0a39cd..ec3552e76 100644 --- a/packages/producer/src/services/deterministicFonts-textSubset.test.ts +++ b/packages/producer/src/services/deterministicFonts-textSubset.test.ts @@ -39,4 +39,47 @@ describe("Google Fonts text subsetting", () => { expect(new URL(requestedUrl).searchParams.get("text")).toContain("旅行"); }); + + it("includes case variants for transformed supplemental alias weights", async () => { + let requestedUrl = ""; + const fetchImpl = (async (input: unknown) => { + requestedUrl = String(input); + return new Response("", { status: 400 }); + }) as unknown as typeof fetch; + + await injectDeterministicFontFaces( + `

Your Kidney Transplant:
What Happens Next

`, + { fetchImpl, allowSystemFontCapture: false }, + ); + + const text = new URL(requestedUrl).searchParams.get("text") ?? ""; + for (const character of new Set("YOUR KIDNEY TRANSPLANT:WHAT HAPPENS NEXT")) { + expect(text).toContain(character); + } + }); + + it("falls back to the full font when case closure exceeds the text URL budget", async () => { + let requestedUrl = ""; + const fetchImpl = (async (input: unknown) => { + requestedUrl = String(input); + return new Response("", { status: 400 }); + }) as unknown as typeof fetch; + const caseChangingCharacters = Array.from({ length: 0x500 }, (_, index) => + String.fromCodePoint(index), + ) + .filter((character) => character.toUpperCase() !== character.toLowerCase()) + .slice(0, 300) + .join(""); + + await injectDeterministicFontFaces( + `

${caseChangingCharacters}

`, + { fetchImpl, allowSystemFontCapture: false }, + ); + + expect(new URL(requestedUrl).searchParams.has("text")).toBe(false); + }); }); diff --git a/packages/producer/src/services/deterministicFonts.ts b/packages/producer/src/services/deterministicFonts.ts index 0a20486de..158bc681c 100644 --- a/packages/producer/src/services/deterministicFonts.ts +++ b/packages/producer/src/services/deterministicFonts.ts @@ -1193,18 +1193,26 @@ export interface InjectDeterministicFontFacesOptions { } // 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. +// Using unique source/decoded characters plus deterministic case variants covers +// static text, strings authored in scripts, and CSS case transforms 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 { 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 + const characters = [...Array.from(html), ...Array.from(decodedBodyText)]; + const uniqueCharacters = new Set(); + for (const character of characters) { + uniqueCharacters.add(character); + // CSS text-transform can render glyphs absent from the authored source. + for (const variant of `${character.toUpperCase()}${character.toLowerCase()}`) { + uniqueCharacters.add(variant); + } + } + const fontText = [...uniqueCharacters].join(""); + return encodeURIComponent(fontText).length <= GOOGLE_FONTS_TEXT_MAX_ENCODED_LENGTH + ? fontText : undefined; }