mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 17:27:09 +00:00
fix(producer): embed the Google font the page linked instead of a wider cut (#4569)
* fix(producer): embed the Google font the page linked The weight-only download drops axes the page's link asked for. That file is written after the link, so the film draws a wider cut than the preview. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(producer): keep every weight the page's font link asked for Google answers a 400..900 link as font-weight: 400 900, and only 400 was kept. Heavier text then waited on the network. Large families are limited to the characters on the page, @import is followed, and a failed link is not replaced by a font found on this machine. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(producer): preserve italic supplements for bundled fonts * fix(producer): preserve active authored font resources * style(producer): condense font fetch comments * fix(producer): preserve authored oblique font faces --------- Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
import { expect, it } from "bun:test";
|
||||
import { authoredGoogleFontStylesheets, withPageText } from "./authoredGoogleFonts.js";
|
||||
|
||||
const url = "https://fonts.googleapis.com/css2?family=Fraunces:wght@400";
|
||||
|
||||
it("decodes every family parameter and retains active screen imports in document order", () => {
|
||||
const second = "https://fonts.googleapis.com/css2?family=Fraunces:wght@700";
|
||||
const html = `<link rel="stylesheet" media="screen" href="${url}&family=Test+Family:wght@700">
|
||||
<style media="all">@import '${second}' screen;</style>`;
|
||||
const resources = authoredGoogleFontStylesheets(html);
|
||||
expect(resources.get("fraunces")).toEqual([`${url}&family=Test+Family:wght@700`, second]);
|
||||
expect(resources.get("test family")).toEqual([`${url}&family=Test+Family:wght@700`]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
`<link rel="preload" href="${url}">`,
|
||||
`<link rel="stylesheet">`,
|
||||
`<link rel="stylesheet" type="text/plain" href="${url}">`,
|
||||
`<link rel="stylesheet" href="not-a-url">`,
|
||||
`<link rel="stylesheet" href="https://example.com/css2?family=Fraunces">`,
|
||||
`<link rel="stylesheet" href="http://fonts.googleapis.com/css2?family=Fraunces">`,
|
||||
`<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=">`,
|
||||
`<style>@media print { @import '${url}'; }</style>`,
|
||||
`<style>/* @import '${url}'; */</style>`,
|
||||
`<style>@import '${url}'; p {</style>`,
|
||||
`<style type="text/plain">@import '${url}';</style>`,
|
||||
])("does not collect an inactive or invalid resource: %s", (html) => {
|
||||
expect(authoredGoogleFontStylesheets(html).size).toBe(0);
|
||||
});
|
||||
|
||||
it("recognizes both families in a legacy Google Fonts URL", () => {
|
||||
const legacy = "https://fonts.googleapis.com/css?family=Fraunces:400|Other:700";
|
||||
expect([
|
||||
...authoredGoogleFontStylesheets(`<link rel=stylesheet href="${legacy}">`).keys(),
|
||||
]).toEqual(["fraunces", "other"]);
|
||||
});
|
||||
|
||||
it("adds a text subset before the URL fragment without replacing authored text", () => {
|
||||
expect(withPageText(`${url}#anchor`, "Hi")).toBe(`${url}&text=Hi#anchor`);
|
||||
expect(withPageText(`${url}&text=Original#anchor`, "Hi")).toBe(`${url}&text=Original#anchor`);
|
||||
expect(withPageText("https://fonts.googleapis.com/css2", "Hi")).toBe(
|
||||
"https://fonts.googleapis.com/css2?text=Hi",
|
||||
);
|
||||
expect(withPageText(url, undefined)).toBe(url);
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { parseHTML } from "linkedom";
|
||||
import postcss from "postcss";
|
||||
|
||||
function screenStylesheet(media: string | null): boolean {
|
||||
return !media?.trim() || media.split(",").some((query) => /^(all|screen)$/i.test(query.trim()));
|
||||
}
|
||||
|
||||
function googleStylesheetFamilies(urlText: string): string[] {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(urlText);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
if (parsed.protocol !== "https:" || parsed.hostname !== "fonts.googleapis.com") return [];
|
||||
return parsed.searchParams
|
||||
.getAll("family")
|
||||
.flatMap((family) => family.split("|"))
|
||||
.map((family) => family.split(":")[0]?.trim().toLowerCase() ?? "")
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function importUrls(css: string): string[] {
|
||||
let root: postcss.Root;
|
||||
try {
|
||||
root = postcss.parse(css);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const urls: string[] = [];
|
||||
for (const node of root.nodes) {
|
||||
if (node.type !== "atrule" || node.name.toLowerCase() !== "import") continue;
|
||||
const url = unconditionalImportUrl(node.params);
|
||||
if (url) urls.push(url);
|
||||
}
|
||||
return urls;
|
||||
}
|
||||
|
||||
function unconditionalImportUrl(params: string): string | undefined {
|
||||
const match = /^(?:url\(\s*['"]?([^'")\s]+)['"]?\s*\)|['"]([^'"]+)['"])\s*(.*)$/i.exec(params);
|
||||
if (!match || !screenStylesheet(match[3] ?? null)) return undefined;
|
||||
return match[1] ?? match[2];
|
||||
}
|
||||
|
||||
type StylesheetElement = Pick<
|
||||
Element,
|
||||
"getAttribute" | "hasAttribute" | "localName" | "textContent"
|
||||
>;
|
||||
|
||||
function activeStylesheet(element: StylesheetElement): boolean {
|
||||
if (element.hasAttribute("disabled") || !screenStylesheet(element.getAttribute("media")))
|
||||
return false;
|
||||
const type = element.getAttribute("type")?.trim().toLowerCase();
|
||||
return !type || type === "text/css";
|
||||
}
|
||||
|
||||
function stylesheetUrls(element: StylesheetElement): string[] {
|
||||
if (!activeStylesheet(element)) return [];
|
||||
if (element.localName !== "link") return importUrls(element.textContent ?? "");
|
||||
const rel = (element.getAttribute("rel") ?? "").toLowerCase().split(/\s+/);
|
||||
if (!rel.includes("stylesheet") || rel.includes("alternate")) return [];
|
||||
const href = element.getAttribute("href");
|
||||
return href ? [href] : [];
|
||||
}
|
||||
|
||||
export function authoredGoogleFontStylesheets(html: string): Map<string, string[]> {
|
||||
const byFamily = new Map<string, string[]>();
|
||||
const { document } = parseHTML(html);
|
||||
for (const element of document.querySelectorAll("link, style")) {
|
||||
for (const url of stylesheetUrls(element)) {
|
||||
for (const family of googleStylesheetFamilies(url)) {
|
||||
const previous = byFamily.get(family) ?? [];
|
||||
byFamily.set(family, [...previous.filter((existing) => existing !== url), url]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return byFamily;
|
||||
}
|
||||
|
||||
export function withPageText(url: string, fontText: string | undefined): string {
|
||||
const parsed = new URL(url);
|
||||
if (!fontText || parsed.searchParams.has("text")) return url;
|
||||
const withoutFragment = url.split("#")[0] ?? url;
|
||||
const joiner = parsed.search ? "&" : "?";
|
||||
return `${withoutFragment}${joiner}text=${encodeURIComponent(fontText)}${parsed.hash}`;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { afterEach, beforeEach, expect, it, spyOn } from "bun:test";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
_clearGoogleFontCssCacheForTests,
|
||||
injectDeterministicFontFaces,
|
||||
} from "./deterministicFonts.js";
|
||||
|
||||
let cacheDir: string;
|
||||
let previousCacheDir: string | undefined;
|
||||
const requests: string[] = [];
|
||||
let restoreFetch: () => void;
|
||||
|
||||
beforeEach(() => {
|
||||
previousCacheDir = process.env.HYPERFRAMES_FONT_CACHE_DIR;
|
||||
cacheDir = mkdtempSync(join(tmpdir(), "hf-authored-resources-"));
|
||||
process.env.HYPERFRAMES_FONT_CACHE_DIR = cacheDir;
|
||||
requests.length = 0;
|
||||
_clearGoogleFontCssCacheForTests();
|
||||
const mock = spyOn(globalThis, "fetch").mockImplementation(async (input) => {
|
||||
const url = new URL(String(input));
|
||||
requests.push(url.href);
|
||||
if (url.hostname === "fonts.googleapis.com") {
|
||||
const weight = url.searchParams.get("family")?.endsWith("700") ? "700" : "400";
|
||||
return new Response(`@font-face {
|
||||
font-family: 'Fraunces'; font-style: normal; font-weight: ${weight};
|
||||
src: url(https://fonts.gstatic.com/fraunces-${weight}.woff2) format('woff2');
|
||||
}`);
|
||||
}
|
||||
return new Response(url.pathname);
|
||||
});
|
||||
restoreFetch = () => mock.mockRestore();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
restoreFetch();
|
||||
_clearGoogleFontCssCacheForTests();
|
||||
if (previousCacheDir === undefined) delete process.env.HYPERFRAMES_FONT_CACHE_DIR;
|
||||
else process.env.HYPERFRAMES_FONT_CACHE_DIR = previousCacheDir;
|
||||
rmSync(cacheDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const normal = "https://fonts.googleapis.com/css2?family=Fraunces:wght@400";
|
||||
const bold = "https://fonts.googleapis.com/css2?family=Fraunces:wght@700";
|
||||
const page = (head: string) => `<!doctype html><html><head>${head}
|
||||
<style>p { font-family: Fraunces; }</style></head><body><p>Hi</p></body></html>`;
|
||||
|
||||
function requestedFamilies(): (string | null)[] {
|
||||
return requests
|
||||
.filter((url) => url.startsWith("https://fonts.googleapis.com/"))
|
||||
.map((url) => new URL(url).searchParams.get("family"));
|
||||
}
|
||||
|
||||
it("decodes HTML entities and preserves an authored text subset", async () => {
|
||||
await injectDeterministicFontFaces(page(`<link rel=stylesheet href="${normal}&text=Hi">`));
|
||||
expect(requests[0]).toBe(`${normal}&text=Hi`);
|
||||
});
|
||||
|
||||
it("embeds every separate weight resource once in document order", async () => {
|
||||
const result = await injectDeterministicFontFaces(
|
||||
page(
|
||||
`<link rel=stylesheet href="${normal}"><link rel=stylesheet href="${bold}">` +
|
||||
`<link rel=stylesheet href="${normal}">`,
|
||||
),
|
||||
);
|
||||
expect(requestedFamilies()).toEqual(["Fraunces:wght@700", "Fraunces:wght@400"]);
|
||||
expect(result).toContain(Buffer.from("/fraunces-400.woff2").toString("base64"));
|
||||
expect(result).toContain(Buffer.from("/fraunces-700.woff2").toString("base64"));
|
||||
});
|
||||
|
||||
it.each([
|
||||
`<link rel="stylesheet" media="print" href="${bold}">`,
|
||||
`<link rel="stylesheet" disabled href="${bold}">`,
|
||||
`<link rel="alternate stylesheet" title="other" href="${bold}">`,
|
||||
`<style media="print">@import url("${bold}");</style>`,
|
||||
`<style>@import url("${bold}") print;</style>`,
|
||||
`<style>@import url("${bold}") supports(display: grid);</style>`,
|
||||
`<!-- <link rel="stylesheet" href="${bold}"> -->`,
|
||||
])("does not promote an inactive font source: %s", async (inactive) => {
|
||||
await injectDeterministicFontFaces(page(`${inactive}<link rel="stylesheet" href="${normal}">`));
|
||||
expect(requestedFamilies()).toEqual(["Fraunces:wght@400"]);
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { afterEach, expect, it, spyOn } from "bun:test";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
_clearGoogleFontCssCacheForTests,
|
||||
injectDeterministicFontFaces,
|
||||
} from "./deterministicFonts.js";
|
||||
|
||||
const previousCacheDir = process.env.HYPERFRAMES_FONT_CACHE_DIR;
|
||||
const cacheDir = mkdtempSync(join(tmpdir(), "hf-bundled-stylesheet-"));
|
||||
const fetchMock = spyOn(globalThis, "fetch");
|
||||
|
||||
afterEach(() => {
|
||||
fetchMock.mockRestore();
|
||||
_clearGoogleFontCssCacheForTests();
|
||||
if (previousCacheDir === undefined) delete process.env.HYPERFRAMES_FONT_CACHE_DIR;
|
||||
else process.env.HYPERFRAMES_FONT_CACHE_DIR = previousCacheDir;
|
||||
rmSync(cacheDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("supplements bundled fonts with real italics even when the page imports only normal", async () => {
|
||||
process.env.HYPERFRAMES_FONT_CACHE_DIR = cacheDir;
|
||||
_clearGoogleFontCssCacheForTests();
|
||||
const italicUrl = "https://fonts.gstatic.com/s/ebgaramond/italic.woff2";
|
||||
const normalUrl = "https://fonts.gstatic.com/s/ebgaramond/normal.woff2";
|
||||
fetchMock.mockImplementation(async (input) => {
|
||||
const url = String(input);
|
||||
if (url.startsWith("https://fonts.googleapis.com/")) {
|
||||
const italic = url.includes("ital,wght@");
|
||||
return new Response(`@font-face {
|
||||
font-family: 'EB Garamond';
|
||||
font-style: ${italic ? "italic" : "normal"};
|
||||
font-weight: 400;
|
||||
src: url(${italic ? italicUrl : normalUrl}) format('woff2');
|
||||
unicode-range: U+0000-00FF;
|
||||
}`);
|
||||
}
|
||||
if (url === italicUrl) return new Response("REAL_ITALIC_FONT");
|
||||
if (url === normalUrl) return new Response("NORMAL_FONT");
|
||||
return new Response("", { status: 404 });
|
||||
});
|
||||
|
||||
const result = await injectDeterministicFontFaces(`<!doctype html><html><head><style>
|
||||
@import url("https://fonts.googleapis.com/css2?family=EB+Garamond:wght@400&display=swap");
|
||||
p { font-family: "EB Garamond", serif; font-style: italic; }
|
||||
</style></head><body><p>lack technical skills</p></body></html>`);
|
||||
|
||||
const italicFace = result.match(/@font-face\s*\{[^}]*font-style: italic[^}]*\}/)?.[0];
|
||||
expect(italicFace).toBeDefined();
|
||||
expect(italicFace).toContain('font-family: "EB Garamond"');
|
||||
expect(italicFace).toContain(Buffer.from("REAL_ITALIC_FONT").toString("base64"));
|
||||
});
|
||||
@@ -19,24 +19,33 @@
|
||||
* so they are hermetic.
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { homedir, tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { _clearGoogleFontCssCacheForTests } from "./deterministicFonts.js";
|
||||
|
||||
beforeEach(() => _clearGoogleFontCssCacheForTests());
|
||||
|
||||
let cacheDir: string;
|
||||
let prevCacheEnv: string | undefined;
|
||||
const LOCAL_FONT_DIR = join(homedir(), ".local", "share", "fonts");
|
||||
const LOCAL_FONT_FILE = join(LOCAL_FONT_DIR, "hf-authored-fail-test.woff2");
|
||||
const LOCAL_FONT_BYTES = "LOCAL_ONLY_BYTES";
|
||||
|
||||
beforeAll(() => {
|
||||
prevCacheEnv = process.env.HYPERFRAMES_FONT_CACHE_DIR;
|
||||
cacheDir = mkdtempSync(join(tmpdir(), "hf-font-cache-"));
|
||||
process.env.HYPERFRAMES_FONT_CACHE_DIR = cacheDir;
|
||||
mkdirSync(LOCAL_FONT_DIR, { recursive: true });
|
||||
writeFileSync(LOCAL_FONT_FILE, LOCAL_FONT_BYTES);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (prevCacheEnv === undefined) delete process.env.HYPERFRAMES_FONT_CACHE_DIR;
|
||||
else process.env.HYPERFRAMES_FONT_CACHE_DIR = prevCacheEnv;
|
||||
rmSync(cacheDir, { recursive: true, force: true });
|
||||
rmSync(LOCAL_FONT_FILE, { force: true });
|
||||
});
|
||||
|
||||
const VIET_RANGE = "U+0102-0103, U+1EA0-1EF9, U+20AB";
|
||||
@@ -108,3 +117,351 @@ describe("Google Fonts multi-subset embedding", () => {
|
||||
expect(latinFace).toContain(LATIN_RANGE);
|
||||
});
|
||||
});
|
||||
|
||||
const AUTHORED_HREF =
|
||||
"https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,600&family=Nunito+Sans:wght@400;600;700&display=swap";
|
||||
const FRAUNCES_FILE = "https://fonts.gstatic.com/s/fraunces/authored.woff2";
|
||||
const NUNITO_FILE = "https://fonts.gstatic.com/s/nunitosans/authored.woff2";
|
||||
const WIDE_FILE = "https://fonts.gstatic.com/s/fraunces/wide.woff2";
|
||||
const INTER_FILE = "https://fonts.gstatic.com/s/inter/v1/inter-supplement.woff2";
|
||||
|
||||
const AUTHORED_CSS = `@font-face {
|
||||
font-family: 'Fraunces';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url(${FRAUNCES_FILE}) format('woff2');
|
||||
unicode-range: U+0000-00FF;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Nunito Sans';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(${NUNITO_FILE}) format('woff2');
|
||||
unicode-range: U+0000-00FF;
|
||||
}`;
|
||||
|
||||
function authoredPage(head: string): string {
|
||||
return `<!doctype html><html><head>${head}</head><body><h1>Seconds</h1></body></html>`;
|
||||
}
|
||||
|
||||
function isAuthoredRequest(url: string, href: string): boolean {
|
||||
return url === href || url.startsWith(`${href}&text=`);
|
||||
}
|
||||
|
||||
function authoredFetch(cssStatus: number): { fetchImpl: typeof fetch; urls: string[] } {
|
||||
const urls: string[] = [];
|
||||
const fetchImpl = (async (input: unknown) => {
|
||||
const url = String(input);
|
||||
urls.push(url);
|
||||
if (isAuthoredRequest(url, AUTHORED_HREF))
|
||||
return new Response(cssStatus === 200 ? AUTHORED_CSS : "", { status: cssStatus });
|
||||
if (url.includes("family=Fraunces:ital,wght@")) {
|
||||
return new Response(
|
||||
`@font-face { font-family: 'Fraunces'; font-style: normal; font-weight: 500; src: url(${WIDE_FILE}) format('woff2'); unicode-range: U+0000-00FF; }`,
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
if (url.includes("family=Inter:")) {
|
||||
return new Response(
|
||||
`@font-face { font-family: 'Inter'; font-style: normal; font-weight: 300; src: url(${INTER_FILE}) format('woff2'); }`,
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
if (url === FRAUNCES_FILE) return new Response("FRAUNCES_LINKED", { status: 200 });
|
||||
if (url === NUNITO_FILE) return new Response("NUNITO_LINKED", { status: 200 });
|
||||
if (url === WIDE_FILE) return new Response("FRAUNCES_WIDE", { status: 200 });
|
||||
if (url === INTER_FILE) return new Response("INTER_BYTES", { status: 200 });
|
||||
return new Response("", { status: 404 });
|
||||
}) as unknown as typeof fetch;
|
||||
return { fetchImpl, urls };
|
||||
}
|
||||
|
||||
describe("authored Google font stylesheet", () => {
|
||||
it("embeds the linked file and leaves it after the link", async () => {
|
||||
const { injectDeterministicFontFaces } = await import("./deterministicFonts.js");
|
||||
const { fetchImpl, urls } = authoredFetch(200);
|
||||
const result = await injectDeterministicFontFaces(
|
||||
authoredPage(
|
||||
`<link rel="stylesheet" href="${AUTHORED_HREF}">` +
|
||||
`<style>h1 { font-family: "Fraunces", serif; } p { font-family: "Nunito Sans", sans-serif; }</style>`,
|
||||
),
|
||||
{ fetchImpl, allowSystemFontCapture: false },
|
||||
);
|
||||
|
||||
const cssUrls = urls.filter((url) => url.startsWith("https://fonts.googleapis.com/"));
|
||||
expect(cssUrls).toHaveLength(1);
|
||||
expect(cssUrls[0]?.startsWith(`${AUTHORED_HREF}&text=`)).toBe(true);
|
||||
expect(new URL(cssUrls[0] ?? "").searchParams.get("text") ?? "").toContain("S");
|
||||
expect(result).toContain(b64("FRAUNCES_LINKED"));
|
||||
expect(result).toContain(b64("NUNITO_LINKED"));
|
||||
expect(result).not.toContain(b64("FRAUNCES_WIDE"));
|
||||
const frauncesFace = result
|
||||
.split("@font-face")
|
||||
.find((block) => block.includes('font-family: "Fraunces"'));
|
||||
expect(frauncesFace).toBeDefined();
|
||||
expect(frauncesFace).not.toContain(b64("NUNITO_LINKED"));
|
||||
expect(result.indexOf("data-hyperframes-deterministic-fonts")).toBeGreaterThan(
|
||||
result.indexOf(AUTHORED_HREF),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not replace a failed link with the weight-only file", async () => {
|
||||
const { injectDeterministicFontFaces } = await import("./deterministicFonts.js");
|
||||
const { fetchImpl, urls } = authoredFetch(400);
|
||||
const result = await injectDeterministicFontFaces(
|
||||
authoredPage(
|
||||
`<link rel="stylesheet" href="${AUTHORED_HREF}"><style>h1 { font-family: "Fraunces", serif; }</style>`,
|
||||
),
|
||||
{ fetchImpl, allowSystemFontCapture: false },
|
||||
);
|
||||
|
||||
expect(urls.some((url) => url.includes("ital,wght@"))).toBe(false);
|
||||
expect(result).not.toContain(b64("FRAUNCES_WIDE"));
|
||||
expect(result).not.toContain("data-hyperframes-deterministic-fonts");
|
||||
});
|
||||
|
||||
it("still requests by family name when the page has no Google link", async () => {
|
||||
const { injectDeterministicFontFaces } = await import("./deterministicFonts.js");
|
||||
const { fetchImpl, urls } = authoredFetch(200);
|
||||
const result = await injectDeterministicFontFaces(
|
||||
authoredPage(`<style>h1 { font-family: "Fraunces", serif; }</style>`),
|
||||
{ fetchImpl, allowSystemFontCapture: false },
|
||||
);
|
||||
|
||||
expect(urls.some((url) => url.includes("family=Fraunces:ital,wght@"))).toBe(true);
|
||||
expect(result).toContain(b64("FRAUNCES_WIDE"));
|
||||
});
|
||||
|
||||
it("keeps a 400 900 weight span on every embedded subset", async () => {
|
||||
const href =
|
||||
"https://fonts.googleapis.com/css2?family=Bodoni+Moda:ital,opsz,wght@0,6..96,400..900&display=swap";
|
||||
const latin = "https://fonts.gstatic.com/s/bodonimoda/latin.woff2";
|
||||
const latinExt = "https://fonts.gstatic.com/s/bodonimoda/latin-ext.woff2";
|
||||
const urls: string[] = [];
|
||||
const fetchImpl = (async (input: unknown) => {
|
||||
const url = String(input);
|
||||
urls.push(url);
|
||||
if (isAuthoredRequest(url, href)) {
|
||||
return new Response(
|
||||
`@font-face { font-family: 'Bodoni Moda'; font-style: normal; font-weight: 400 900; src: url(${latinExt}) format('woff2'); unicode-range: U+0100-02BA; }
|
||||
@font-face { font-family: 'Bodoni Moda'; font-style: normal; font-weight: 400 900; src: url(${latin}) format('woff2'); unicode-range: U+0000-00FF; }`,
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
if (url === latin) return new Response("BODONI_LATIN", { status: 200 });
|
||||
if (url === latinExt) return new Response("BODONI_LATIN_EXT", { status: 200 });
|
||||
return new Response("", { status: 404 });
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const { injectDeterministicFontFaces } = await import("./deterministicFonts.js");
|
||||
const result = await injectDeterministicFontFaces(
|
||||
authoredPage(
|
||||
`<link rel="stylesheet" href="${href}"><style>h1 { font-family: "Bodoni Moda", serif; font-weight: 700; }</style>`,
|
||||
),
|
||||
{ fetchImpl, allowSystemFontCapture: false },
|
||||
);
|
||||
|
||||
const cssUrls = urls.filter((url) => url.startsWith("https://fonts.googleapis.com/"));
|
||||
expect(cssUrls).toHaveLength(1);
|
||||
expect(cssUrls[0]?.startsWith(`${href}&text=`)).toBe(true);
|
||||
const faces = result
|
||||
.split("@font-face")
|
||||
.slice(1)
|
||||
.filter((block) => block.includes('font-family: "Bodoni Moda"'));
|
||||
expect(faces).toHaveLength(2);
|
||||
for (const face of faces) {
|
||||
expect(face).toContain("font-weight: 400 900;");
|
||||
}
|
||||
expect(result).toContain(b64("BODONI_LATIN"));
|
||||
expect(result).toContain(b64("BODONI_LATIN_EXT"));
|
||||
});
|
||||
|
||||
it("still embeds Inter for Arial when the page links Arial", async () => {
|
||||
const { injectDeterministicFontFaces } = await import("./deterministicFonts.js");
|
||||
const { fetchImpl, urls } = authoredFetch(200);
|
||||
await injectDeterministicFontFaces(
|
||||
authoredPage(
|
||||
`<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Arial">` +
|
||||
`<style>h1 { font-family: Arial, sans-serif; }</style>`,
|
||||
),
|
||||
{ fetchImpl, allowSystemFontCapture: false },
|
||||
);
|
||||
|
||||
expect(urls.some((url) => url.includes("family=Arial"))).toBe(false);
|
||||
expect(urls.some((url) => url.includes("family=Inter:"))).toBe(true);
|
||||
});
|
||||
|
||||
it("embeds the Google file named by an import, not the weight-only file", async () => {
|
||||
const { injectDeterministicFontFaces } = await import("./deterministicFonts.js");
|
||||
const { fetchImpl, urls } = authoredFetch(200);
|
||||
const result = await injectDeterministicFontFaces(
|
||||
authoredPage(
|
||||
`<style>@import url("${AUTHORED_HREF}"); h1 { font-family: "Fraunces", serif; }</style>`,
|
||||
),
|
||||
{ fetchImpl, allowSystemFontCapture: false },
|
||||
);
|
||||
|
||||
const cssUrls = urls.filter((url) => url.startsWith("https://fonts.googleapis.com/"));
|
||||
expect(cssUrls.some((url) => url.startsWith(`${AUTHORED_HREF}&text=`))).toBe(true);
|
||||
expect(cssUrls.some((url) => url.includes("ital,wght@"))).toBe(false);
|
||||
expect(result).toContain(b64("FRAUNCES_LINKED"));
|
||||
expect(result).not.toContain(b64("FRAUNCES_WIDE"));
|
||||
});
|
||||
|
||||
it("preserves a later weight-range link alongside the first static link", async () => {
|
||||
const narrow = "https://fonts.googleapis.com/css2?family=Bodoni+Moda:wght@400&display=swap";
|
||||
const wide =
|
||||
"https://fonts.googleapis.com/css2?family=Bodoni+Moda:ital,opsz,wght@0,6..96,400..900&display=swap";
|
||||
const file = "https://fonts.gstatic.com/s/bodonimoda/range.woff2";
|
||||
const urls: string[] = [];
|
||||
const fetchImpl = (async (input: unknown) => {
|
||||
const url = String(input);
|
||||
urls.push(url);
|
||||
if (isAuthoredRequest(url, wide)) {
|
||||
return new Response(
|
||||
`@font-face { font-family: 'Bodoni Moda'; font-style: normal; font-weight: 400 900; src: url(${file}) format('woff2'); unicode-range: U+0000-00FF; }`,
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
if (url === file) return new Response("BODONI_RANGE", { status: 200 });
|
||||
return new Response("", { status: 404 });
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const { injectDeterministicFontFaces } = await import("./deterministicFonts.js");
|
||||
const result = await injectDeterministicFontFaces(
|
||||
authoredPage(
|
||||
`<link rel="stylesheet" href="${narrow}">` +
|
||||
`<link rel="stylesheet" href="${wide}">` +
|
||||
`<style>h1 { font-family: "Bodoni Moda", serif; font-weight: 700; }</style>`,
|
||||
),
|
||||
{ fetchImpl, allowSystemFontCapture: false },
|
||||
);
|
||||
|
||||
const cssUrls = urls.filter((url) => url.startsWith("https://fonts.googleapis.com/"));
|
||||
expect(cssUrls).toHaveLength(2);
|
||||
expect(cssUrls[0]?.startsWith(`${narrow}&text=`)).toBe(true);
|
||||
expect(cssUrls[1]?.startsWith(`${wide}&text=`)).toBe(true);
|
||||
expect(result).toContain("font-weight: 400 900;");
|
||||
expect(result).toContain(b64("BODONI_RANGE"));
|
||||
});
|
||||
|
||||
it("preserves the first weight-range link alongside a later static link", async () => {
|
||||
const wide =
|
||||
"https://fonts.googleapis.com/css2?family=Bodoni+Moda:ital,opsz,wght@0,6..96,400..900&display=swap";
|
||||
const narrow = "https://fonts.googleapis.com/css2?family=Bodoni+Moda:wght@400&display=swap";
|
||||
const file = "https://fonts.gstatic.com/s/bodonimoda/first-range.woff2";
|
||||
const urls: string[] = [];
|
||||
const fetchImpl = (async (input: unknown) => {
|
||||
const url = String(input);
|
||||
urls.push(url);
|
||||
if (isAuthoredRequest(url, wide)) {
|
||||
return new Response(
|
||||
`@font-face { font-family: 'Bodoni Moda'; font-style: normal; font-weight: 400 900; src: url(${file}) format('woff2'); unicode-range: U+0000-00FF; }`,
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
return new Response("", { status: 404 });
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const { injectDeterministicFontFaces } = await import("./deterministicFonts.js");
|
||||
await injectDeterministicFontFaces(
|
||||
authoredPage(
|
||||
`<link rel="stylesheet" href="${wide}">` +
|
||||
`<link rel="stylesheet" href="${narrow}">` +
|
||||
`<style>h1 { font-family: "Bodoni Moda", serif; }</style>`,
|
||||
),
|
||||
{ fetchImpl, allowSystemFontCapture: false },
|
||||
);
|
||||
|
||||
const cssUrls = urls.filter((url) => url.startsWith("https://fonts.googleapis.com/"));
|
||||
expect(cssUrls).toHaveLength(2);
|
||||
expect(cssUrls[0]?.startsWith(`${wide}&text=`)).toBe(true);
|
||||
expect(cssUrls[1]?.startsWith(`${narrow}&text=`)).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves a link's own text list unchanged", async () => {
|
||||
const href = "https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500&text=Hi";
|
||||
const urls: string[] = [];
|
||||
const fetchImpl = (async (input: unknown) => {
|
||||
const url = String(input);
|
||||
urls.push(url);
|
||||
if (url === href) {
|
||||
return new Response(
|
||||
`@font-face { font-family: 'Fraunces'; font-style: normal; font-weight: 500; src: url(${FRAUNCES_FILE}) format('woff2'); unicode-range: U+0000-00FF; }`,
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
if (url === FRAUNCES_FILE) return new Response("FRAUNCES_LINKED", { status: 200 });
|
||||
return new Response("", { status: 404 });
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const { injectDeterministicFontFaces } = await import("./deterministicFonts.js");
|
||||
await injectDeterministicFontFaces(
|
||||
authoredPage(
|
||||
`<link rel="stylesheet" href="${href}"><style>h1 { font-family: "Fraunces", serif; }</style>`,
|
||||
),
|
||||
{ fetchImpl, allowSystemFontCapture: false },
|
||||
);
|
||||
|
||||
expect(urls.filter((url) => url.startsWith("https://fonts.googleapis.com/"))).toEqual([href]);
|
||||
});
|
||||
|
||||
it("does not append page text when the character set does not fit on the font URL", async () => {
|
||||
const href = "https://fonts.googleapis.com/css2?family=Noto+Serif+JP:wght@400";
|
||||
const file = "https://fonts.gstatic.com/s/notoserifjp/full.woff2";
|
||||
const many = Array.from({ length: 600 }, (_, i) => String.fromCodePoint(0x4e00 + i)).join("");
|
||||
const urls: string[] = [];
|
||||
const fetchImpl = (async (input: unknown) => {
|
||||
const url = String(input);
|
||||
urls.push(url);
|
||||
if (url === href) {
|
||||
return new Response(
|
||||
`@font-face { font-family: 'Noto Serif JP'; font-style: normal; font-weight: 400; src: url(${file}) format('woff2'); unicode-range: U+3000-30FF; }`,
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
if (url === file) return new Response("NOTO_FULL", { status: 200 });
|
||||
return new Response("", { status: 404 });
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const { injectDeterministicFontFaces } = await import("./deterministicFonts.js");
|
||||
await injectDeterministicFontFaces(
|
||||
`<!doctype html><html><head><link rel="stylesheet" href="${href}"><style>p { font-family: "Noto Serif JP", serif; }</style></head><body>${many}</body></html>`,
|
||||
{ fetchImpl, allowSystemFontCapture: false },
|
||||
);
|
||||
|
||||
expect(urls.filter((url) => url.startsWith("https://fonts.googleapis.com/"))).toEqual([href]);
|
||||
});
|
||||
|
||||
it("does not embed a local file when the page's link failed", async () => {
|
||||
const href = "https://fonts.googleapis.com/css2?family=Hf+Authored+Fail+Test";
|
||||
const { injectDeterministicFontFaces } = await import("./deterministicFonts.js");
|
||||
const urls: string[] = [];
|
||||
const fetchImpl = (async (input: unknown) => {
|
||||
urls.push(String(input));
|
||||
return new Response("", { status: 400 });
|
||||
}) as unknown as typeof fetch;
|
||||
const result = await injectDeterministicFontFaces(
|
||||
authoredPage(
|
||||
`<link rel="stylesheet" href="${href}"><style>h1 { font-family: "Hf Authored Fail Test", serif; }</style>`,
|
||||
),
|
||||
{ fetchImpl, allowSystemFontCapture: true },
|
||||
);
|
||||
|
||||
expect(urls.some((url) => url.includes("ital,wght@"))).toBe(false);
|
||||
expect(result).not.toContain(b64(LOCAL_FONT_BYTES));
|
||||
});
|
||||
|
||||
it("still embeds a local file when the page has no Google link", async () => {
|
||||
const { injectDeterministicFontFaces } = await import("./deterministicFonts.js");
|
||||
const fetchImpl = (async () => new Response("", { status: 400 })) as unknown as typeof fetch;
|
||||
const result = await injectDeterministicFontFaces(
|
||||
authoredPage(`<style>h1 { font-family: "Hf Authored Fail Test", serif; }</style>`),
|
||||
{ fetchImpl, allowSystemFontCapture: true },
|
||||
);
|
||||
|
||||
expect(result).toContain(b64(LOCAL_FONT_BYTES));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { afterEach, beforeEach, expect, it, spyOn } from "bun:test";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
_clearGoogleFontCssCacheForTests,
|
||||
injectDeterministicFontFaces,
|
||||
} from "./deterministicFonts.js";
|
||||
|
||||
let cacheDir: string;
|
||||
let previousCacheDir: string | undefined;
|
||||
let restoreFetch: (() => void) | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
previousCacheDir = process.env.HYPERFRAMES_FONT_CACHE_DIR;
|
||||
cacheDir = mkdtempSync(join(tmpdir(), "hf-oblique-"));
|
||||
process.env.HYPERFRAMES_FONT_CACHE_DIR = cacheDir;
|
||||
_clearGoogleFontCssCacheForTests();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
restoreFetch?.();
|
||||
_clearGoogleFontCssCacheForTests();
|
||||
if (previousCacheDir === undefined) delete process.env.HYPERFRAMES_FONT_CACHE_DIR;
|
||||
else process.env.HYPERFRAMES_FONT_CACHE_DIR = previousCacheDir;
|
||||
rmSync(cacheDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it.each(["oblique", "oblique 10deg", "oblique 0deg 10deg", "oblique -12.5deg 2.5deg"])(
|
||||
"embeds every subset of an authored %s face in fail-closed mode",
|
||||
async (style) => {
|
||||
const subsets = ["U+0000-00FF", "U+0100-017F"];
|
||||
const fetchMock = spyOn(globalThis, "fetch").mockImplementation(async (input) => {
|
||||
const url = String(input);
|
||||
if (url.startsWith("https://fonts.googleapis.com/")) {
|
||||
return new Response(
|
||||
subsets
|
||||
.map(
|
||||
(unicodeRange, index) => `@font-face {
|
||||
font-family: 'Roboto Flex'; font-style: ${style}; font-weight: 100 1000;
|
||||
src: url(https://fonts.gstatic.com/oblique-${index}.woff2) format('woff2');
|
||||
unicode-range: ${unicodeRange};
|
||||
}`,
|
||||
)
|
||||
.join("\n"),
|
||||
);
|
||||
}
|
||||
return new Response(url);
|
||||
});
|
||||
restoreFetch = () => fetchMock.mockRestore();
|
||||
const html = `<html><head><link rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Roboto+Flex:slnt,wght@-10..0,100..1000">
|
||||
<style>p { font-family: 'Roboto Flex'; }</style></head><body><p>hello Ā</p></body></html>`;
|
||||
const result = await injectDeterministicFontFaces(html, { failClosedFontFetch: true });
|
||||
const faces = result.match(/@font-face\s*\{[^}]+\}/g) ?? [];
|
||||
expect(faces).toHaveLength(2);
|
||||
for (const [index, face] of faces.entries()) {
|
||||
expect(face).toContain(`font-style: ${style};`);
|
||||
expect(face).toContain("font-weight: 100 1000;");
|
||||
expect(face).toContain(`unicode-range: ${subsets[index]};`);
|
||||
expect(face).toContain(
|
||||
Buffer.from(`https://fonts.gstatic.com/oblique-${index}.woff2`).toString("base64"),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -13,6 +13,7 @@ import { parseHTML } from "linkedom";
|
||||
import postcss, { type AtRule, type Declaration, type Rule } from "postcss";
|
||||
import { EMBEDDED_FONT_DATA } from "./fontData.generated.js";
|
||||
import { fontToDataUri } from "./fontCompression.js";
|
||||
import { authoredGoogleFontStylesheets, withPageText } from "./authoredGoogleFonts.js";
|
||||
|
||||
type FontFaceSpec = {
|
||||
weight: string;
|
||||
@@ -700,8 +701,9 @@ async function buildFontFaceCss(
|
||||
continue;
|
||||
}
|
||||
|
||||
// Path 3: locate font on the local filesystem, compress, and embed.
|
||||
if (options.allowSystemFontCapture) {
|
||||
// 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;
|
||||
@@ -904,6 +906,8 @@ interface InternalFontFetchOptions {
|
||||
abortSignal?: AbortSignal;
|
||||
retryPolicy: FontFetchRetryPolicy;
|
||||
retryDeadlineMs: number;
|
||||
/** Google stylesheet URL the page already wrote, keyed by normalized family name. */
|
||||
authoredStylesheets: ReadonlyMap<string, readonly string[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1169,12 +1173,20 @@ function fetchGoogleFontCss(
|
||||
return raceAgainstAbort(shared, options.abortSignal);
|
||||
}
|
||||
|
||||
function pageNamedThisFile(familyName: string, options: InternalFontFetchOptions): boolean {
|
||||
return options.authoredStylesheets.has(normalizeFamilyName(familyName.replace(/\+/g, " ")));
|
||||
}
|
||||
|
||||
function declaredFaceFamily(block: string): string | undefined {
|
||||
const found = /font-family:\s*['"]([^'"]+)['"]/i.exec(block);
|
||||
return found?.[1];
|
||||
}
|
||||
|
||||
async function fetchGoogleFont(
|
||||
familyName: string,
|
||||
options: InternalFontFetchOptions,
|
||||
fontText?: string,
|
||||
): Promise<GoogleFontFace[]> {
|
||||
const slug = fontSlug(familyName);
|
||||
// Agents sometimes copy the `family=` value from a Google Fonts URL into
|
||||
// CSS, where `+` remains a literal character instead of being decoded as a
|
||||
// space. Resolve that URL-style spelling through the canonical Google family
|
||||
@@ -1183,23 +1195,40 @@ async function fetchGoogleFont(
|
||||
const googleFamilyName = familyName.replace(/\+/g, " ");
|
||||
const encodedFamily = encodeURIComponent(googleFamilyName);
|
||||
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}`;
|
||||
// Bundled families need the full supplement, including italics a page's
|
||||
// link may omit. Other families need the authored URL to preserve axes
|
||||
// such as optical size instead of replacing them with a weight-only face.
|
||||
const normalizedFamily = normalizeFamilyName(googleFamilyName);
|
||||
const authoredStylesheet = FONT_ALIASES[normalizedFamily]
|
||||
? undefined
|
||||
: options.authoredStylesheets.get(normalizedFamily);
|
||||
// `text=` asks Google for only the characters on the page. A CJK family
|
||||
// without it is a hundred files, and the compile's font budget is 20s.
|
||||
const urls = authoredStylesheet
|
||||
? authoredStylesheet.map((url) => withPageText(url, fontText))
|
||||
: [
|
||||
`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}`,
|
||||
];
|
||||
const faces: GoogleFontFace[] = [];
|
||||
for (const url of urls) {
|
||||
faces.push(...(await fetchGoogleFontStylesheet(googleFamilyName, url, options)));
|
||||
}
|
||||
return faces;
|
||||
}
|
||||
|
||||
let cssText: string;
|
||||
async function readGoogleFontStylesheet(
|
||||
familyName: string,
|
||||
url: string,
|
||||
options: InternalFontFetchOptions,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const cssResult = await fetchGoogleFontCss(url, familyName, options);
|
||||
if (!cssResult.ok) {
|
||||
// 4xx is a *deterministic* answer from Google Fonts that this
|
||||
// family is not served (e.g. HTTP 400 for "Segoe UI", "Arial",
|
||||
// "Futura" — names absent from Google's catalog) or is misnamed.
|
||||
// The render falls back to embedded faces / the composition's
|
||||
// font-family chain; we return [] in both modes. 5xx (and other
|
||||
// transient upstream failures) could return faces on retry, which
|
||||
// would break the byte-identical-retry contract distributed
|
||||
// renders rely on — those still fail closed when requested.
|
||||
return [];
|
||||
// Missing families are deterministic; transient failures follow the
|
||||
// caller's retry and fail-closed policy.
|
||||
return null;
|
||||
}
|
||||
cssText = cssResult.body;
|
||||
return cssResult.body;
|
||||
} catch (err) {
|
||||
// Rethrow typed error untouched. Network / DNS / fetch-throws are
|
||||
// non-deterministic infrastructure failures — wrapped when failClosed
|
||||
@@ -1209,25 +1238,52 @@ async function fetchGoogleFont(
|
||||
if (options.failClosedFontFetch) {
|
||||
throw fontFetchError(familyName, url, "Google Fonts CSS", { error: err });
|
||||
}
|
||||
return [];
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
type GoogleFontSource = Omit<GoogleFontFace, "dataUri"> & { url: string };
|
||||
|
||||
function parseGoogleFontSource(
|
||||
match: RegExpMatchArray,
|
||||
familyName: string,
|
||||
): GoogleFontSource | null {
|
||||
const declared = declaredFaceFamily(match[0]);
|
||||
if (declared && normalizeFamilyName(declared) !== normalizeFamilyName(familyName)) return null;
|
||||
const url = match[3];
|
||||
if (!url) return null;
|
||||
return {
|
||||
style: match[1] || "normal",
|
||||
weight: (match[2] || "400").replace(/\s+/g, " "),
|
||||
url,
|
||||
unicodeRange: match[4]?.trim() || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchGoogleFontStylesheet(
|
||||
familyName: string,
|
||||
url: string,
|
||||
options: InternalFontFetchOptions,
|
||||
): Promise<GoogleFontFace[]> {
|
||||
const slug = fontSlug(familyName);
|
||||
const cssText = await readGoogleFontStylesheet(familyName, url, options);
|
||||
if (!cssText) return [];
|
||||
|
||||
// Parse @font-face blocks from the CSS response. The optional trailing
|
||||
// capture grabs each face's `unicode-range` (Google emits it after `src`)
|
||||
// so the injected face only claims the codepoints the subset actually
|
||||
// covers — without it the face would advertise full coverage it lacks.
|
||||
// A variable face is `font-weight: 400 900`: one file for every weight in
|
||||
// the span. Keeping only the first number leaves 600/700/800 on the network.
|
||||
const faceRegex =
|
||||
/@font-face\s*\{[^}]*font-style:\s*(normal|italic)[^}]*font-weight:\s*(\d+)[^}]*src:\s*url\(([^)]+)\)\s*format\(['"]woff2['"]\)(?:[^}]*?unicode-range:\s*([^;}]+))?[^}]*\}/gi;
|
||||
/@font-face\s*\{[^}]*font-style:\s*(normal|italic|oblique(?:\s+-?\d+(?:\.\d+)?deg){0,2})\s*;[^}]*font-weight:\s*(\d+(?:\s+\d+)?)[^}]*src:\s*url\(([^)]+)\)\s*format\(['"]woff2['"]\)(?:[^}]*?unicode-range:\s*([^;}]+))?[^}]*\}/gi;
|
||||
|
||||
const faces: GoogleFontFace[] = [];
|
||||
|
||||
for (const match of cssText.matchAll(faceRegex)) {
|
||||
const style = match[1] || "normal";
|
||||
const weight = match[2] || "400";
|
||||
const woff2Url = match[3] || "";
|
||||
const unicodeRange = match[4]?.trim() || undefined;
|
||||
|
||||
if (!woff2Url) continue;
|
||||
const source = parseGoogleFontSource(match, familyName);
|
||||
if (!source) continue;
|
||||
const { weight, style, unicodeRange, url: woff2Url } = source;
|
||||
|
||||
const cachePath = cachedWoff2Path(slug, weight, style, subsetToken(woff2Url));
|
||||
const dataUri = await ensureWoff2DataUri(
|
||||
@@ -1474,6 +1530,7 @@ export async function injectDeterministicFontFaces(
|
||||
abortSignal: options.abortSignal,
|
||||
retryPolicy,
|
||||
retryDeadlineMs: Date.now() + retryPolicy.maxElapsedMs,
|
||||
authoredStylesheets: authoredGoogleFontStylesheets(html),
|
||||
};
|
||||
|
||||
const existingFaces = extractExistingFontFaces(html);
|
||||
|
||||
Reference in New Issue
Block a user