Skip to content
Merged
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
Expand Up @@ -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
Expand All @@ -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, {
Expand Down
Original file line number Diff line number Diff line change
@@ -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(
`<!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);
}
});

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(
`<!doctype html><html><head><style>
h1 { font-family: "Noto Performance Test", sans-serif; }
</style></head><body><h1>&#x65C5;&#34892;</h1></body></html>`,
{ fetchImpl, allowSystemFontCapture: false },
);

expect(new URL(requestedUrl).searchParams.get("text")).toContain("旅行");
});
});
37 changes: 32 additions & 5 deletions packages/producer/src/services/deterministicFonts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,10 @@ function extractRequestedFontFamilies(html: string): Map<string, string> {
return requested;
}

export function fontFormatHint(src: string): "collection" | "woff2" {
return src.startsWith("data:font/collection;") ? "collection" : "woff2";
}

function buildFontFaceRule(
familyName: string,
src: string,
Expand All @@ -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;",
Expand All @@ -456,6 +460,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 +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
Expand All @@ -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(
Expand Down Expand Up @@ -735,10 +740,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 +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 = {},
Expand Down Expand Up @@ -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(", "),
Expand Down
16 changes: 12 additions & 4 deletions packages/producer/src/services/fontCompression.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
});
});
3 changes: 3 additions & 0 deletions packages/producer/src/services/fontCompression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading