fix(fonts): resolve declared family names with missing spaces (#4670)

* fix(producer): a misspelled font-family resolves to the family the document declares

"Saira ExtraCondensed" in CSS next to an @import of family=Saira+Extra+Condensed
was unresolved (Google Fonts answers the typo with HTTP 400), so fail-closed
compiles threw FontFetchError. Once every existing path fails, a family whose
spelling (case, spaces, -, _, +) uniquely matches a family the document declares
in a Google Fonts @import/<link> URL or its own @font-face resolves to it. Faces
are emitted under the authored name, so every authored usage matches them. Zero
or ambiguous matches keep today's behavior.

FontFetchError gains a structured unresolvedFamilies list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cli): font localization failures name the unresolved font families

The failure line keeps its prefix and appends the sanitized names from
FontFetchError.unresolvedFamilies, e.g.
font localization failed (FontFetchError): unresolved font families: "Foo Bar"
The error message, URLs and HTML are still never printed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(producer): keep conditional font declarations out of alias fallback

* refactor(producer): bound declared font collection complexity

* test(producer): model multi-family Google font responses

* test(producer): preserve optional font policy through alias fallback

---------

Co-authored-by: terence.cho_tai2 <terence.cho_tai2@heygen.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
terencecho
2026-09-28 19:09:21 +00:00
committed by GitHub
co-authored by Claude Opus 5.5 terence.cho_tai2
parent fdbc5dcaf7
commit 46eedd4dfb
4 changed files with 870 additions and 115 deletions
+90
View File
@@ -19,6 +19,18 @@ function makeIo(input: string): {
};
}
// Shaped like the producer's FontFetchError: runFontLocalize reads only its
// class name and structured `unresolvedFamilies`, never the message.
class FontFetchLikeError extends Error {
readonly unresolvedFamilies: readonly unknown[];
constructor(message: string, unresolvedFamilies: readonly unknown[]) {
super(message);
this.name = "FontFetchError";
this.unresolvedFamilies = unresolvedFamilies;
}
}
describe("runFontLocalize", () => {
it("writes only the localized document to stdout", async () => {
const harness = makeIo("<html>source</html>");
@@ -60,6 +72,84 @@ describe("runFontLocalize", () => {
expect(harness.errors.join(" ")).not.toContain("<html>");
});
it("names the unresolved font families after the unchanged failure prefix", async () => {
const harness = makeIo("<html>source</html>");
const exitCode = await runFontLocalize(harness.io, async () => {
throw new FontFetchLikeError("unresolved", ["Foo Bar", "Baz"]);
});
expect(exitCode).toBe(1);
expect(harness.errors).toEqual([
'font localization failed (FontFetchError): unresolved font families: "Foo Bar", "Baz"\n',
]);
});
it("never echoes a URL or signed query from the error message", async () => {
const harness = makeIo('<html><link href="https://fonts.example/css?sig=SECRETSIG"></html>');
const message =
"[Compiler] Unresolved fonts in fail-closed mode: Saira ExtraCondensed. " +
"fetch https://fonts.googleapis.com/css2?family=Saira&X-Amz-Signature=SECRETSIG failed";
await runFontLocalize(harness.io, async () => {
throw new FontFetchLikeError(message, ["Saira ExtraCondensed"]);
});
const stderr = harness.errors.join("");
expect(stderr).toBe(
'font localization failed (FontFetchError): unresolved font families: "Saira ExtraCondensed"\n',
);
expect(stderr).not.toContain("SECRETSIG");
expect(stderr).not.toContain("https://");
expect(stderr).not.toContain("Unresolved fonts in fail-closed mode");
});
it("drops URL-shaped family names whole and strips other characters", async () => {
const harness = makeIo("<html>source</html>");
await runFontLocalize(harness.io, async () => {
throw new FontFetchLikeError("unresolved", [
"https://signed.example/font?token=SECRETTOKEN",
'Evil"Name\n<script>',
"Saira+ExtraCondensed",
42,
]);
});
const stderr = harness.errors.join("");
expect(stderr).toBe(
'font localization failed (FontFetchError): unresolved font families: "Evil Name script", "Saira ExtraCondensed" (+2 more)\n',
);
expect(stderr).not.toContain("SECRETTOKEN");
expect(stderr).not.toContain("signed");
});
it("caps each family name's length and the number of names", async () => {
const harness = makeIo("<html>source</html>");
const families = Array.from({ length: 11 }, (_, index) => `Family ${index}`);
families[0] = "A".repeat(200);
await runFontLocalize(harness.io, async () => {
throw new FontFetchLikeError("unresolved", families);
});
const stderr = harness.errors.join("");
expect(stderr).toContain(`"${"A".repeat(64)}", "Family 1"`);
expect(stderr).not.toContain("A".repeat(65));
expect(stderr).toContain('"Family 7" (+3 more)\n');
expect(stderr).not.toContain("Family 8");
});
it("keeps the bare failure line when the error carries no family list", async () => {
const harness = makeIo("<html>source</html>");
await runFontLocalize(harness.io, async () => {
throw new FontFetchLikeError("Google Fonts CSS returned HTTP 503", []);
});
expect(harness.errors).toEqual(["font localization failed (FontFetchError)\n"]);
});
it("fails closed when the resolver returns an empty document", async () => {
const harness = makeIo("<html>source</html>");
+37 -2
View File
@@ -34,10 +34,43 @@ function safeErrorName(error: unknown): string {
return /^[A-Za-z][A-Za-z0-9]*$/.test(name) ? name : "Error";
}
const MAX_REPORTED_FAMILIES = 8;
const MAX_FAMILY_NAME_LENGTH = 64;
function unresolvedFamilyList(error: unknown): unknown[] {
if (!(error instanceof Error) || !("unresolvedFamilies" in error)) return [];
const families = error.unresolvedFamilies;
return Array.isArray(families) ? families : [];
}
// URL-shaped names are dropped whole: stripping punctuation would still print the token.
function safeFamilyName(family: unknown): string | null {
if (typeof family !== "string" || /[:/?#&=%@\\]/.test(family)) return null;
const safe = family
.replace(/[^A-Za-z0-9 _-]+/g, " ")
.replace(/\s+/g, " ")
.trim()
.slice(0, MAX_FAMILY_NAME_LENGTH)
.trim();
return safe || null;
}
function unresolvedFamiliesSuffix(error: unknown): string {
const families = unresolvedFamilyList(error);
if (families.length === 0) return "";
const printable = families.map(safeFamilyName).filter((name): name is string => name !== null);
const distinct = [...new Set(printable)];
const shown = distinct.slice(0, MAX_REPORTED_FAMILIES);
const hidden = families.length - printable.length + (distinct.length - shown.length);
const listed = shown.map((name) => `"${name}"`).join(", ");
const more = hidden > 0 ? `${listed ? " " : ""}(+${hidden} more)` : "";
return `: unresolved font families: ${listed}${more}`;
}
/**
* Machine-only stdin/stdout boundary for deterministic font localization.
* Source HTML and resolver messages can contain signed URLs, so failures emit
* only a fixed category plus a sanitized error class.
* only a fixed category, a sanitized error class and sanitized family names.
*/
export async function runFontLocalize(
io: FontLocalizeIo,
@@ -58,7 +91,9 @@ export async function runFontLocalize(
io.writeOutput(localized);
return 0;
} catch (error) {
io.writeError(`font localization failed (${safeErrorName(error)})\n`);
io.writeError(
`font localization failed (${safeErrorName(error)})${unresolvedFamiliesSuffix(error)}\n`,
);
return 1;
}
}
@@ -0,0 +1,384 @@
/**
* Declared-family alias resolution.
*
* The bug: an agent wrote `font-family: "Saira ExtraCondensed"` while the same
* document imported `family=Saira+Extra+Condensed`. Google Fonts answers
* `Saira ExtraCondensed` with HTTP 400, so the family came out unresolved and a
* fail-closed compile threw `FontFetchError: Unresolved fonts in fail-closed
* mode`. To reproduce, compile such a document with `failClosedFontFetch: true`.
*
* An unresolved family whose spelling (case, spaces, hyphens, underscores)
* uniquely matches a family the document declares must resolve to that family,
* with faces emitted under the AUTHORED name so the authored CSS still matches.
*
* `fetchImpl` is injected (no network) and HYPERFRAMES_FONT_CACHE_DIR points
* at a temp dir, so these tests are hermetic.
*/
import { afterAll, beforeEach, describe, expect, it } from "bun:test";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { EMBEDDED_FONT_DATA } from "./fontData.generated.js";
import {
_clearGoogleFontCssCacheForTests,
FONT_FETCH_FAILED,
FontFetchError,
injectDeterministicFontFaces,
} from "./deterministicFonts.js";
const FONT_CACHE_ENV = "HYPERFRAMES_FONT_CACHE_DIR";
const originalFontCache = process.env[FONT_CACHE_ENV];
const fontCache = mkdtempSync(join(tmpdir(), "hf-font-declared-alias-"));
beforeEach(() => {
process.env[FONT_CACHE_ENV] = fontCache;
_clearGoogleFontCssCacheForTests();
});
afterAll(() => {
rmSync(fontCache, { recursive: true, force: true });
if (originalFontCache !== undefined) process.env[FONT_CACHE_ENV] = originalFontCache;
else delete process.env[FONT_CACHE_ENV];
});
// Families the fake Google Fonts serves, by exact `family=` name. Anything else
// answers HTTP 400, as the real API does for a name it does not know.
const SERVED_FAMILIES: ReadonlyMap<string, string> = new Map(
["Saira Extra Condensed", "Saira", "Open Sans"].map((family) => [
family,
`https://fonts.gstatic.com/s/test/v1/${family.toLowerCase().replace(/\s+/g, "-")}.woff2`,
]),
);
function fontBytes(family: string): string {
return `${family.toUpperCase().replace(/\s+/g, "_")}_BYTES`;
}
function dataUriFor(family: string): string {
return `data:font/woff2;base64,${Buffer.from(fontBytes(family)).toString("base64")}`;
}
function fakeGoogleFonts(queried: string[]): typeof fetch {
const woff2Owners = new Map([...SERVED_FAMILIES].map(([family, url]) => [url, family]));
const respond = (input: unknown): Response => {
const requested = new URL(String(input));
const owner = woff2Owners.get(requested.href);
if (owner) return new Response(fontBytes(owner));
if (requested.hostname !== "fonts.googleapis.com") return new Response("", { status: 404 });
const families = requested.searchParams
.getAll("family")
.flatMap((value) => value.split("|"))
.map((value) => value.split(":", 1)[0] ?? "");
queried.push(families[0] ?? "");
const rules = families.flatMap((family) => {
const woff2 = SERVED_FAMILIES.get(family);
return woff2
? [
`@font-face { font-family: "${family}"; font-style: italic; font-weight: 800; src: url(${woff2}) format('woff2'); }`,
]
: [];
});
if (rules.length === 0) return new Response("", { status: 400 });
return new Response(rules.join("\n"));
};
return (async (input: unknown) => respond(input)) as unknown as typeof fetch;
}
function htmlWith(head: string, body = "<h1>x</h1>"): string {
return `<!doctype html><html><head>${head}</head><body>${body}</body></html>`;
}
async function compile(
html: string,
failClosedFontFetch = true,
): Promise<{ result: string; queried: string[] }> {
const queried: string[] = [];
const result = await injectDeterministicFontFaces(html, {
failClosedFontFetch,
allowSystemFontCapture: false,
fetchImpl: fakeGoogleFonts(queried),
});
return { result, queried };
}
async function compileFailure(html: string): Promise<{ error: FontFetchError; queried: string[] }> {
const queried: string[] = [];
try {
await injectDeterministicFontFaces(html, {
failClosedFontFetch: true,
allowSystemFontCapture: false,
fetchImpl: fakeGoogleFonts(queried),
});
} catch (error) {
if (error instanceof FontFetchError) return { error, queried };
throw error;
}
throw new Error("Expected the compile to fail closed");
}
/** The injected block's @font-face rules as { family, src } pairs, in order. */
function injectedFaces(html: string): Array<{ family: string; src: string }> {
const block = /<style data-hyperframes-deterministic-fonts="true">([\s\S]*?)<\/style>/.exec(
html,
)?.[1];
if (!block) return [];
return [...block.matchAll(/@font-face\s*\{([^}]*)\}/g)].map((match) => {
const rule = match[1] ?? "";
return {
family: /font-family:\s*"([^"]*)"/.exec(rule)?.[1] ?? "",
src: /src:\s*url\("?([^")]+)"?\)/.exec(rule)?.[1] ?? "",
};
});
}
// Trimmed from the failing composition: the css2 import names the family
// correctly, the scene CSS does not.
const SAIRA_TYPO_HTML = htmlWith(
`<style>
@import url('https://fonts.googleapis.com/css2?family=Saira+Extra+Condensed:ital,wght@1,800&family=Inter:wght@400;700&display=swap');
.title { font-family: "Saira ExtraCondensed", sans-serif; font-style: italic; font-weight: 800; }
</style>`,
`<h1 class="title">TREINO</h1>`,
);
describe("declared-family alias resolution", () => {
it('resolves "Saira ExtraCondensed" to the imported "Saira Extra Condensed" in fail-closed mode', async () => {
const { result, queried } = await compile(SAIRA_TYPO_HTML);
// The authored spelling is tried first (400), then the declared family.
expect(queried).toEqual(["Saira ExtraCondensed", "Saira Extra Condensed"]);
// Faces carry the AUTHORED name, so `.title` actually matches them — and
// their bytes are the declared family's.
const faces = injectedFaces(result);
expect(faces).toEqual([
{ family: "Saira ExtraCondensed", src: dataUriFor("Saira Extra Condensed") },
]);
// The authored usage is untouched and still names a family with a face.
expect(result).toContain('font-family: "Saira ExtraCondensed", sans-serif');
expect(faces.map((face) => face.family)).toContain("Saira ExtraCondensed");
});
it("resolves through a legacy css <link> with several families in one param", async () => {
const { result, queried } = await compile(
htmlWith(
`<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Saira+Extra+Condensed:400,800|Roboto+Slab&amp;display=swap">
<style>h1 { font-family: 'saira-extra_condensed'; }</style>`,
),
);
expect(queried).toEqual(["saira-extra_condensed", "Saira Extra Condensed"]);
expect(injectedFaces(result)).toEqual([
{ family: "saira-extra_condensed", src: dataUriFor("Saira Extra Condensed") },
]);
});
it("re-emits the document's own @font-face under the authored name instead of fetching", async () => {
const ownSrc = "data:font/woff2;base64,QlJBTkRfU0FOU19PV04=";
const { result, queried } = await compile(
htmlWith(`<style>
@font-face { font-family: "Brand Sans"; src: url("${ownSrc}") format("woff2"); font-weight: 700; }
h1 { font-family: "BrandSans"; }
</style>`),
);
// Only the authored spelling is looked up; the declared face is reused.
expect(queried).toEqual(["BrandSans"]);
const block = /data-hyperframes-deterministic-fonts="true">([\s\S]*?)<\/style>/.exec(
result,
)?.[1];
expect(block).toContain('font-family: "BrandSans"');
expect(block).toContain(ownSrc);
expect(block).toContain("font-weight: 700");
// The original face is left as authored.
expect(result).toContain('@font-face { font-family: "Brand Sans";');
});
it("composes with FONT_ALIASES: a declared bundled family resolves to the bundle", async () => {
const { result, queried } = await compile(
htmlWith(`<style>
@import url("https://fonts.googleapis.com/css2?family=Open+Sans:wght@400;700");
h1 { font-family: "OpenSans"; }
</style>`),
);
// "Open Sans" is a FONT_ALIASES family: bundled faces + canonical supplement.
expect(queried).toEqual(["OpenSans", "Open Sans"]);
const bundled = new Set(
[...EMBEDDED_FONT_DATA]
.filter(([key]) => key.startsWith("@fontsource/open-sans:"))
.map(([, uri]) => uri),
);
const faces = injectedFaces(result);
expect(faces.every((face) => face.family === "OpenSans")).toBe(true);
expect(faces.filter((face) => bundled.has(face.src)).length).toBe(bundled.size);
});
it("keeps two different declared families apart", async () => {
const { result, queried } = await compile(
htmlWith(
`<style>
@import url('https://fonts.googleapis.com/css2?family=Saira:wght@400&family=Saira+Extra+Condensed:ital,wght@1,800');
h1 { font-family: "Saira ExtraCondensed"; }
p { font-family: "Saira"; }
</style>`,
"<h1>x</h1><p>y</p>",
),
);
// The authored Google stylesheet serves both declared names in one request.
expect(queried).toEqual(["Saira ExtraCondensed", "Saira"]);
expect(injectedFaces(result)).toEqual([
{ family: "Saira ExtraCondensed", src: dataUriFor("Saira Extra Condensed") },
{ family: "Saira", src: dataUriFor("Saira") },
]);
});
it("leaves a document whose families already resolve unchanged by the alias pass", async () => {
const { result, queried } = await compile(
htmlWith(`<style>
@import url('https://fonts.googleapis.com/css2?family=Saira+Extra+Condensed');
h1 { font-family: "Saira Extra Condensed"; }
</style>`),
);
expect(queried).toEqual(["Saira Extra Condensed"]);
expect(injectedFaces(result)).toEqual([
{ family: "Saira Extra Condensed", src: dataUriFor("Saira Extra Condensed") },
]);
});
it("resolves the typo in fail-open mode too", async () => {
const { result } = await compile(SAIRA_TYPO_HTML, false);
expect(injectedFaces(result).map((face) => face.family)).toContain("Saira ExtraCondensed");
});
it.each([
["an optional var() fallback", true],
["a required family", false],
])("keeps %s transient-failure policy after alias lookup", async (_case, optional) => {
const html = htmlWith(`<style>
@import url("https://fonts.googleapis.com/css2?family=Saira+Extra+Condensed");
h1 { font-family: ${optional ? 'var(--brand, "Saira ExtraCondensed")' : '"Saira ExtraCondensed"'}; }
</style>`);
const requests: string[] = [];
const fetchImpl = (async (input: unknown) => {
const url = String(input);
requests.push(url);
return new Response("", { status: url.includes("Saira%20ExtraCondensed") ? 400 : 503 });
}) as unknown as typeof fetch;
const localize = () =>
injectDeterministicFontFaces(html, {
failClosedFontFetch: true,
allowSystemFontCapture: false,
fetchImpl,
fontFetchRetryPolicy: { baseDelayMs: 0, maxAttempts: 3 },
});
if (optional) {
expect(await localize()).toBe(html);
expect(requests).toHaveLength(3);
} else {
await expect(localize()).rejects.toMatchObject({ code: "FONT_FETCH_UNAVAILABLE" });
expect(requests).toHaveLength(4);
}
});
});
describe("declared-family alias resolution — still unresolved", () => {
it.each([
[
"a truly unknown family",
`@import url('https://fonts.googleapis.com/css2?family=Saira+Extra+Condensed');
h1 { font-family: "Totally Unknown Face", sans-serif; }`,
"Totally Unknown Face",
],
[
// Both declared names collapse to "sairaextracondensed".
"an ambiguous spelling",
`@import url('https://fonts.googleapis.com/css2?family=Saira+Extra+Condensed&family=Saira+ExtraCondensed');
h1 { font-family: "SairaExtraCondensed"; }`,
"SairaExtraCondensed",
],
[
// Declared as written (Google has no such family), so the near-spelling
// "Saira Extra Condensed" must not be substituted.
"a family the document declares verbatim",
`@import url('https://fonts.googleapis.com/css2?family=Saira+ExtraCondensed');
@import url('https://fonts.googleapis.com/css2?family=Saira+Extra+Condensed');
h1 { font-family: "Saira ExtraCondensed"; }`,
"Saira ExtraCondensed",
],
[
"a declaration from a stylesheet that is not Google Fonts",
`@import url('https://cdn.example.com/css2?family=Saira+Extra+Condensed');
@import url('/css2?family=Saira+Extra+Condensed');
h1 { font-family: "Saira ExtraCondensed"; }`,
"Saira ExtraCondensed",
],
])("fails closed for %s", async (_case, css, authored) => {
const { error, queried } = await compileFailure(htmlWith(`<style>${css}</style>`));
expect(error.code).toBe(FONT_FETCH_FAILED);
expect(error.familyName).toBe(authored);
expect(error.unresolvedFamilies).toEqual([authored]);
// Nothing but the authored spelling was ever looked up.
expect(queried).toEqual([authored]);
});
});
describe("declared-family aliases preserve stylesheet conditions", () => {
const googleUrl = "https://fonts.googleapis.com/css2?family=Saira+Extra+Condensed";
it.each([
[
"style media on a face",
'<style media="print">@font-face { font-family: "Saira Extra Condensed"; src: url("data:font/woff2;base64,QlJBTkQ="); }</style>',
],
["style media on an import", `<style media="print">@import url("${googleUrl}");</style>`],
[
"nested media face",
'<style>@media print { @font-face { font-family: "Saira Extra Condensed"; src: url("data:font/woff2;base64,QlJBTkQ="); } }</style>',
],
[
"nested supports import",
`<style>@supports (display: grid) { @import url("${googleUrl}"); }</style>`,
],
["import media", `<style>@import url("${googleUrl}") print;</style>`],
["quoted import media", `<style>@import "${googleUrl}" print;</style>`],
["import supports", `<style>@import url("${googleUrl}") supports(display: grid);</style>`],
["import layer", `<style>@import url("${googleUrl}") layer(brand);</style>`],
["link media", `<link rel="stylesheet" media="print" href="${googleUrl}">`],
["alternate link", `<link rel="alternate stylesheet" title="Brand" href="${googleUrl}">`],
["disabled link", `<link rel="stylesheet" disabled href="${googleUrl}">`],
["non-stylesheet link", `<link rel="preload" href="${googleUrl}">`],
["non-CSS style", `<style type="text/plain">@import url("${googleUrl}");</style>`],
["non-CSS link", `<link rel="stylesheet" type="text/plain" href="${googleUrl}">`],
["titled style", `<style title="Brand">@import url("${googleUrl}");</style>`],
["titled link", `<link rel="stylesheet" title="Brand" href="${googleUrl}">`],
])("ignores %s as an unconditional alias source", async (_case, declaration) => {
const html = htmlWith(
`${declaration}<style>h1 { font-family: "Saira ExtraCondensed"; }</style>`,
);
const { error, queried } = await compileFailure(html);
expect(error.unresolvedFamilies).toEqual(["Saira ExtraCondensed"]);
expect(queried).toEqual(["Saira ExtraCondensed"]);
const { result } = await compile(html, false);
expect(injectedFaces(result)).toEqual([]);
});
it.each([
`<style media="all" type="text/css">@import url("${googleUrl}");</style>`,
`<link rel="stylesheet" media="all" type="text/css" href="${googleUrl}">`,
`<style>@import "${googleUrl}" all;</style>`,
])("accepts an unconditional stylesheet: %s", async (declaration) => {
const { result } = await compile(
htmlWith(`${declaration}<style>h1 { font-family: "Saira ExtraCondensed"; }</style>`),
);
expect(injectedFaces(result)).toEqual([
{ family: "Saira ExtraCondensed", src: dataUriFor("Saira Extra Condensed") },
]);
});
});
@@ -667,9 +667,339 @@ function partitionWeightRuns(
return runs;
}
// Path 1: pre-bundled fonts via FONT_ALIASES — emit embedded faces,
// then fetch from Google Fonts to fill missing weights and character subsets.
async function bundledFamilyFaceRules(
canonical: CanonicalFontSpec,
normalizedFamily: string,
emitFamily: string,
optional: boolean,
options: InternalFontFetchOptions,
fontText?: string,
): Promise<string[]> {
const rules: string[] = [];
const coveredWeights = new Set<string>();
const bundledRules: string[] = [];
for (const face of canonical.faces) {
const style = face.style || "normal";
const src = fontDataUri(canonical.packageName, face.weight, style);
bundledRules.push(
buildFontFaceRule(emitFamily, src, face.weight, style, BUNDLED_SUBSET_UNICODE_RANGE),
);
coveredWeights.add(coverageKey(face.weight, style));
}
// Fetch all weights from Google Fonts and add any that aren't
// 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. Query the CANONICAL
// family, not the authored one: for a cross-typeface alias
// (helvetica → inter) the authored name is a different typeface,
// so supplementing from it would mix two typefaces under one
// font-family. The faces are still emitted under
// `emitFamily` so the authored CSS keeps matching.
const canonicalFamily = resolveAliasDisplayName(normalizedFamily);
const googleFaces = canonicalFamily
? await fetchFamilyFaces(canonicalFamily, optional, options, fontText)
: [];
// Bundled weights only cover Latin. Keep other subsets (including
// text= responses without a range), even for weights already embedded.
const supplementary = googleFaces.filter(
(face) =>
!coveredWeights.has(coverageKey(face.weight, face.style)) ||
!isBundledSubsetRange(face.unicodeRange),
);
const runs = groupFacesBySource(supplementary).flatMap((group) =>
partitionWeightRuns(group, coveredWeights),
);
// Overlapping `unicode-range` rules resolve last-defined-first, so a run
// is emitted where its first face appeared in the response rather than
// grouped by source. Collapsing must not reorder the faces.
const firstAppearance = (run: readonly GoogleFontFace[]): number =>
Math.min(...run.map((face) => supplementary.indexOf(face)));
for (const run of [...runs].sort((a, b) => firstAppearance(a) - firstAppearance(b))) {
const first = run[0];
const last = run[run.length - 1];
if (!first || !last) continue;
const weight = run.length > 1 ? `${first.weight} ${last.weight}` : first.weight;
rules.push(
buildFontFaceRule(emitFamily, first.dataUri, weight, first.style, first.unicodeRange),
);
}
// Broader or text-subset responses can overlap Latin. Emit the bundle
// last so existing Latin glyphs keep their deterministic bundled source.
rules.push(...bundledRules);
return rules;
}
// Path 2: fetch from Google Fonts (with local cache)
async function googleFamilyFaceRules(
lookupFamily: string,
emitFamily: string,
optional: boolean,
options: InternalFontFetchOptions,
fontText?: string,
): Promise<string[] | null> {
const googleFaces = await fetchFamilyFaces(lookupFamily, optional, options, fontText);
if (googleFaces.length === 0) return null;
return googleFaces.map((face) =>
buildFontFaceRule(emitFamily, face.dataUri, face.weight, face.style, face.unicodeRange),
);
}
// Path 3: locate font on the local filesystem, compress, and embed.
async function systemFamilyFaceRules(
lookupFamily: string,
emitFamily: string,
): Promise<string[] | null> {
const variants = locateSystemFontVariants(lookupFamily);
if (variants.length === 0) return null;
const rules: string[] = [];
let totalBytes = 0;
for (const variant of variants) {
const fontBuffer = readFileSync(variant.path);
totalBytes += fontBuffer.length;
const dataUri = await fontToDataUri(fontBuffer, variant.format);
rules.push(buildFontFaceRule(emitFamily, dataUri, variant.weight, variant.style));
}
if (totalBytes > SYSTEM_FONT_SIZE_LIMIT) {
defaultLogger.warn(
`[Compiler] System font "${lookupFamily}" is large (${(totalBytes / 1024 / 1024).toFixed(1)} MB total across ${variants.length} variant(s)) — embedding anyway. Consider font subsetting for production.`,
);
}
defaultLogger.info(
`[Compiler] Embedded system font "${lookupFamily}" — ${variants.length} variant(s), ${(totalBytes / 1024).toFixed(0)} KB total`,
);
return rules;
}
/** Faces of `lookupFamily` named `emitFamily`, or `null` when no path resolves it. */
async function resolveFamilyFaceRules(
normalizedFamily: string,
lookupFamily: string,
emitFamily: string,
optional: boolean,
options: InternalFontFetchOptions,
fontText?: string,
): Promise<string[] | null> {
const canonicalKey = FONT_ALIASES[normalizedFamily];
if (canonicalKey) {
const canonical = CANONICAL_FONTS[canonicalKey];
if (!canonical) return [];
return bundledFamilyFaceRules(
canonical,
normalizedFamily,
emitFamily,
optional,
options,
fontText,
);
}
return (
(await googleFamilyFaceRules(lookupFamily, emitFamily, optional, options, fontText)) ??
(options.allowSystemFontCapture && !pageNamedThisFile(lookupFamily, options)
? await systemFamilyFaceRules(lookupFamily, emitFamily)
: null)
);
}
/** Named by a Google Fonts stylesheet URL or by the document's own top-level @font-face rules. */
type DeclaredFontFamily = {
family: string;
fontFaceRules: AtRule[];
};
type DeclaredFontFamilies = ReadonlyMap<string, DeclaredFontFamily>;
// css2 repeats `family=Name:axes`; the legacy css API packs `family=Name:400|Other`.
function googleFontsUrlFamilies(rawUrl: string): string[] {
let url: URL;
try {
// A relative href resolves onto the placeholder host and is rejected below.
url = new URL(rawUrl.trim(), "https://document.invalid/");
} catch {
return [];
}
if (url.hostname !== "fonts.googleapis.com") return [];
if (url.pathname !== "/css" && url.pathname !== "/css2") return [];
return url.searchParams
.getAll("family")
.flatMap((value) => value.split("|"))
.map((entry) => (entry.split(":", 1)[0] ?? "").trim())
.filter((family) => family.length > 0);
}
function importRuleUrl(params: string): string | null {
const trimmed = params.trim();
if (trimmed.toLowerCase().startsWith("url(")) {
const close = trimmed.indexOf(")");
if (close < 0 || !isUnconditionalMedia(trimmed.slice(close + 1))) return null;
return trimmed.slice(4, close).trim().replace(/^['"]/, "").replace(/['"]$/, "");
}
const quote = trimmed[0];
if (quote !== '"' && quote !== "'") return null;
const end = trimmed.indexOf(quote, 1);
return end > 0 && isUnconditionalMedia(trimmed.slice(end + 1)) ? trimmed.slice(1, end) : null;
}
function fontFaceRuleFamily(rule: AtRule): string | undefined {
let family: string | undefined;
rule.walkDecls((decl) => {
if (decl.prop.toLowerCase() === "font-family") family = parseFontFamilyValue(decl.value)[0];
});
return family;
}
function isUnconditionalMedia(media: string | null): boolean {
const normalized = (media ?? "").trim().toLowerCase();
return normalized === "" || normalized === "all";
}
function isUnconditionalStylesheet(element: {
getAttribute(name: string): string | null;
hasAttribute(name: string): boolean;
}): boolean {
const type = (element.getAttribute("type") ?? "").trim().toLowerCase();
return (
isUnconditionalMedia(element.getAttribute("media")) &&
(type === "" || type === "text/css") &&
!(element.getAttribute("title") ?? "").trim() &&
!element.hasAttribute("disabled")
);
}
type DeclaredFontRegister = (family: string, fontFaceRule?: AtRule) => void;
function declareStyleFontFamilies(
styleEl: {
textContent: string | null;
getAttribute(name: string): string | null;
hasAttribute(name: string): boolean;
},
register: DeclaredFontRegister,
): void {
if (!isUnconditionalStylesheet(styleEl)) return;
const root = parseCssRoot(styleEl.textContent ?? "");
if (!root) return;
root.walkAtRules((atRule) => {
if (atRule.parent?.type !== "root") return;
const name = atRule.name.toLowerCase();
if (name === "import") {
const url = importRuleUrl(atRule.params);
if (url) for (const family of googleFontsUrlFamilies(url)) register(family);
return;
}
if (name !== "font-face") return;
const family = fontFaceRuleFamily(atRule);
if (family) register(family, atRule);
});
}
function declareLinkedFontFamilies(
link: {
getAttribute(name: string): string | null;
hasAttribute(name: string): boolean;
},
register: DeclaredFontRegister,
): void {
const rel = (link.getAttribute("rel") ?? "").toLowerCase().split(/\s+/);
if (!rel.includes("stylesheet") || rel.includes("alternate") || !isUnconditionalStylesheet(link))
return;
for (const family of googleFontsUrlFamilies(link.getAttribute("href") ?? "")) register(family);
}
function collectDeclaredFontFamilies(html: string): DeclaredFontFamilies {
const { document } = parseHTML(html);
const declared = new Map<string, DeclaredFontFamily>();
const register: DeclaredFontRegister = (family, fontFaceRule) => {
const key = normalizeFamilyName(family);
if (!key || GENERIC_FAMILIES.has(key)) return;
let entry = declared.get(key);
if (!entry) {
entry = { family, fontFaceRules: [] };
declared.set(key, entry);
}
if (fontFaceRule) entry.fontFaceRules.push(fontFaceRule);
};
for (const styleEl of Array.from(document.querySelectorAll("style"))) {
declareStyleFontFamilies(styleEl, register);
}
for (const link of Array.from(document.querySelectorAll("link[href]"))) {
declareLinkedFontFamilies(link, register);
}
return declared;
}
// `+` too: fetchGoogleFont already reads it as the URL-encoded space.
function familySpellingKey(family: string): string {
return family.toLowerCase().replace(/[\s_+-]+/g, "");
}
function findDeclaredFamilyAlias(
authoredFamily: string,
declaredFamilies: DeclaredFontFamilies,
): DeclaredFontFamily | null {
// Declared verbatim yet unresolved: substituting a near-spelling would be a guess.
if (declaredFamilies.has(normalizeFamilyName(authoredFamily))) return null;
const key = familySpellingKey(authoredFamily);
if (!key) return null;
let match: DeclaredFontFamily | null = null;
for (const entry of declaredFamilies.values()) {
if (familySpellingKey(entry.family) !== key) continue;
// Two declared families collapse to the same key; picking one is a guess.
if (match) return null;
match = entry;
}
return match;
}
// Emits under the authored name instead of rewriting usages, as FONT_ALIASES does,
// so script-assigned and var()-indirected usages match too.
async function resolveDeclaredFamilyAlias(
authoredFamily: string,
declaredFamilies: DeclaredFontFamilies,
optional: boolean,
options: InternalFontFetchOptions,
fontText?: string,
): Promise<string[] | null> {
const declared = findDeclaredFamilyAlias(authoredFamily, declaredFamilies);
if (!declared) return null;
let rules: string[] | null;
if (declared.fontFaceRules.length > 0) {
// The document's own faces win, as they would had the family been spelled as declared.
rules = declared.fontFaceRules.map((rule) => {
const renamed = rule.clone();
renamed.walkDecls((decl) => {
if (decl.prop.toLowerCase() === "font-family") decl.value = `"${authoredFamily}"`;
});
return renamed.toString();
});
} else {
rules = await resolveFamilyFaceRules(
normalizeFamilyName(declared.family),
declared.family,
authoredFamily,
optional,
options,
fontText,
);
}
if (!rules) return null;
defaultLogger.warn(
`[Compiler] font-family "${authoredFamily}" is not a known family; resolved it to "${declared.family}", ` +
`which this document declares. Correct the authored font-family to "${declared.family}".`,
);
return rules;
}
async function buildFontFaceCss(
requestedFamilies: Map<string, ResolvedFamily>,
options: InternalFontFetchOptions,
declaredFamilies: () => DeclaredFontFamilies,
fontText?: string,
): Promise<{
css: string;
@@ -683,121 +1013,27 @@ async function buildFontFaceCss(
([, a], [, b]) => Number(a.optional) - Number(b.optional),
);
for (const [normalizedFamily, { family: originalCaseFamily, optional }] of requiredFirst) {
// Path 1: pre-bundled fonts via FONT_ALIASES — emit embedded faces,
// then fetch from Google Fonts to fill missing weights and character subsets.
const canonicalKey = FONT_ALIASES[normalizedFamily];
if (canonicalKey) {
const canonical = CANONICAL_FONTS[canonicalKey];
if (!canonical) continue;
const coveredWeights = new Set<string>();
const bundledRules: string[] = [];
for (const face of canonical.faces) {
const style = face.style || "normal";
const src = fontDataUri(canonical.packageName, face.weight, style);
bundledRules.push(
buildFontFaceRule(
originalCaseFamily,
src,
face.weight,
style,
BUNDLED_SUBSET_UNICODE_RANGE,
),
);
coveredWeights.add(coverageKey(face.weight, style));
}
// Fetch all weights from Google Fonts and add any that aren't
// 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. Query the CANONICAL
// family, not the authored one: for a cross-typeface alias
// (helvetica → inter) the authored name is a different typeface,
// so supplementing from it would mix two typefaces under one
// font-family. The faces are still emitted under
// `originalCaseFamily` so the authored CSS keeps matching.
const canonicalFamily = resolveAliasDisplayName(normalizedFamily);
const googleFaces = canonicalFamily
? await fetchFamilyFaces(canonicalFamily, optional, options, fontText)
: [];
// Bundled weights only cover Latin. Keep other subsets (including
// text= responses without a range), even for weights already embedded.
const supplementary = googleFaces.filter(
(face) =>
!coveredWeights.has(coverageKey(face.weight, face.style)) ||
!isBundledSubsetRange(face.unicodeRange),
);
const runs = groupFacesBySource(supplementary).flatMap((group) =>
partitionWeightRuns(group, coveredWeights),
);
// Overlapping `unicode-range` rules resolve last-defined-first, so a run
// is emitted where its first face appeared in the response rather than
// grouped by source. Collapsing must not reorder the faces.
const firstAppearance = (run: readonly GoogleFontFace[]): number =>
Math.min(...run.map((face) => supplementary.indexOf(face)));
for (const run of [...runs].sort((a, b) => firstAppearance(a) - firstAppearance(b))) {
const first = run[0];
const last = run[run.length - 1];
if (!first || !last) continue;
const weight = run.length > 1 ? `${first.weight} ${last.weight}` : first.weight;
rules.push(
buildFontFaceRule(
originalCaseFamily,
first.dataUri,
weight,
first.style,
first.unicodeRange,
),
);
}
// Broader or text-subset responses can overlap Latin. Emit the bundle
// last so existing Latin glyphs keep their deterministic bundled source.
rules.push(...bundledRules);
const familyRules =
(await resolveFamilyFaceRules(
normalizedFamily,
originalCaseFamily,
originalCaseFamily,
optional,
options,
fontText,
)) ??
// Last, so a family that already resolves keeps exactly the faces it had.
(await resolveDeclaredFamilyAlias(
originalCaseFamily,
declaredFamilies(),
optional,
options,
fontText,
));
if (familyRules) {
rules.push(...familyRules);
continue;
}
// Path 2: fetch from Google Fonts (with local cache)
const googleFaces = await fetchFamilyFaces(originalCaseFamily, optional, options, fontText);
if (googleFaces.length > 0) {
for (const face of googleFaces) {
rules.push(
buildFontFaceRule(
originalCaseFamily,
face.dataUri,
face.weight,
face.style,
face.unicodeRange,
),
);
}
continue;
}
// The page already named the file. A font found on this machine is a
// different file, and the render machines do not have it.
if (options.allowSystemFontCapture && !pageNamedThisFile(originalCaseFamily, options)) {
const variants = locateSystemFontVariants(originalCaseFamily);
if (variants.length > 0) {
let totalBytes = 0;
for (const variant of variants) {
const fontBuffer = readFileSync(variant.path);
totalBytes += fontBuffer.length;
const dataUri = await fontToDataUri(fontBuffer, variant.format);
rules.push(buildFontFaceRule(originalCaseFamily, dataUri, variant.weight, variant.style));
}
if (totalBytes > SYSTEM_FONT_SIZE_LIMIT) {
defaultLogger.warn(
`[Compiler] System font "${originalCaseFamily}" is large (${(totalBytes / 1024 / 1024).toFixed(1)} MB total across ${variants.length} variant(s)) — embedding anyway. Consider font subsetting for production.`,
);
}
defaultLogger.info(
`[Compiler] Embedded system font "${originalCaseFamily}" — ${variants.length} variant(s), ${(totalBytes / 1024).toFixed(0)} KB total`,
);
continue;
}
}
// No path resolved
unresolved.push(originalCaseFamily);
}
@@ -925,6 +1161,8 @@ export class FontFetchError extends Error {
readonly familyName: string;
readonly url: string;
readonly cause?: unknown;
/** Unlike `message`, holds no URLs: safe for callers that must not echo resolver output. */
readonly unresolvedFamilies: readonly string[];
constructor(
familyName: string,
@@ -932,6 +1170,7 @@ export class FontFetchError extends Error {
message: string,
cause?: unknown,
code: FontFetchErrorCode = FONT_FETCH_FAILED,
unresolvedFamilies: readonly string[] = [],
) {
super(message);
this.name = "FontFetchError";
@@ -939,6 +1178,7 @@ export class FontFetchError extends Error {
this.familyName = familyName;
this.url = url;
this.cause = cause;
this.unresolvedFamilies = unresolvedFamilies;
}
}
@@ -1256,6 +1496,7 @@ function declaredFaceFamily(block: string): string | undefined {
return found?.[1];
}
// fallow-ignore-next-line complexity
async function fetchGoogleFont(
familyName: string,
options: InternalFontFetchOptions,
@@ -1645,9 +1886,11 @@ export async function injectDeterministicFontFaces(
return html;
}
let declaredFamilies: DeclaredFontFamilies | undefined;
const { css, unresolved } = await buildFontFaceCss(
pendingFamilies,
fetchOptions,
() => (declaredFamilies ??= collectDeclaredFontFamilies(html)),
extractGoogleFontsText(html),
);
// An optional family that no source serves, or that stayed unavailable, is tolerated.
@@ -1660,6 +1903,9 @@ export async function injectDeterministicFontFaces(
"",
`[Compiler] Unresolved fonts in fail-closed mode: ${required.join(", ")}. ` +
`Distributed renders require all fonts to be resolvable.`,
undefined,
FONT_FETCH_FAILED,
unresolved,
);
}
if (!css) {