mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 17:27:09 +00:00
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:
co-authored by
Claude Opus 5.5
terence.cho_tai2
parent
fdbc5dcaf7
commit
46eedd4dfb
@@ -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>");
|
||||
|
||||
|
||||
@@ -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&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) {
|
||||
|
||||
Reference in New Issue
Block a user