Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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(
`<!doctype html><html><head><style>
h1 { font-family: "Noto Performance Test", sans-serif; }
</style></head><body><h1>旅行ランキング</h1></body></html>`,
{ fetchImpl, allowSystemFontCapture: false },
);

const url = new URL(requestedUrl);
const text = url.searchParams.get("text") ?? "";
for (const character of new Set("旅行ランキング")) {
expect(text).toContain(character);
}
});
});
27 changes: 23 additions & 4 deletions packages/producer/src/services/deterministicFonts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,7 @@ function buildFontFaceRule(
async function buildFontFaceCss(
requestedFamilies: Map<string, string>,
options: InternalFontFetchOptions,
fontText?: string,
): Promise<{
css: string;
unresolved: string[];
Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -735,10 +736,12 @@ async function ensureWoff2DataUri(
async function fetchGoogleFont(
familyName: string,
options: InternalFontFetchOptions,
fontText?: string,
): Promise<GoogleFontFace[]> {
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 {
Expand Down Expand Up @@ -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 = {},
Expand Down Expand Up @@ -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(", "),
Expand Down
Loading