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-textSubset.test.ts b/packages/producer/src/services/deterministicFonts-textSubset.test.ts
new file mode 100644
index 0000000000..e3b0a39cd7
--- /dev/null
+++ b/packages/producer/src/services/deterministicFonts-textSubset.test.ts
@@ -0,0 +1,42 @@
+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);
+ }
+ });
+
+ 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 2805bae2e9..24dee3db02 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;",
@@ -456,6 +460,7 @@ function buildFontFaceRule(
async function buildFontFaceCss(
requestedFamilies: Map,
options: InternalFontFetchOptions,
+ fontText?: string,
): Promise<{
css: string;
unresolved: string[];
@@ -483,7 +488,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 +508,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 +740,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 +851,22 @@ 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 { 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;
+}
+
export async function injectDeterministicFontFaces(
html: string,
options: InjectDeterministicFontFacesOptions = {},
@@ -871,7 +894,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(", "),
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,