mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 06:30:03 +00:00
A family resolving through FONT_ALIAS_MAP could emit @font-face rules drawn from two unrelated typefaces under one font-family name, split by weight and style. The supplementation fetch was passed the authored name, so for a cross-typeface alias (helvetica -> inter) it asked Google for the very typeface the alias exists to replace. Diagnosed, reported and fixed by Akshay Kumar Sharma (@akzarma) in #3083 / #3085. This PR carries that work because the fix requires re-recorded regression baselines, which are LFS objects we cannot push to a fork's LFS store. Baselines re-recorded for style-15-prod and style-3-prod, each verified text-only before acceptance. All 9 regression shards pass. Closes #3083. Co-authored-by: Akshay Kumar Sharma <25038017+akzarma@users.noreply.github.com>
1305 lines
45 KiB
TypeScript
1305 lines
45 KiB
TypeScript
import { createHash } from "node:crypto";
|
||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
||
import { homedir, tmpdir } from "node:os";
|
||
import { join } from "node:path";
|
||
import { defaultLogger } from "../logger.js";
|
||
|
||
import { FONT_ALIAS_MAP, resolveAliasDisplayName } from "@hyperframes/core/fonts/aliases";
|
||
import {
|
||
locateSystemFontVariants,
|
||
SYSTEM_FONT_SIZE_LIMIT,
|
||
} from "@hyperframes/core/fonts/system-locator";
|
||
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";
|
||
|
||
type FontFaceSpec = {
|
||
weight: string;
|
||
style?: "normal" | "italic";
|
||
};
|
||
|
||
type CanonicalFontSpec = {
|
||
packageName: string;
|
||
faces: FontFaceSpec[];
|
||
};
|
||
|
||
/**
|
||
* Family names that resolve to a host-OS font (or a CSS generic that the
|
||
* browser substitutes with a host-OS font). Exported so plan-time validators
|
||
* can reject them as primary families in distributed renders.
|
||
*
|
||
* Lower-cased — call `normalizeFamilyName` on declared values before lookup.
|
||
*/
|
||
export const GENERIC_FAMILIES: ReadonlySet<string> = new Set([
|
||
"sans-serif",
|
||
"serif",
|
||
"monospace",
|
||
"cursive",
|
||
"fantasy",
|
||
"system-ui",
|
||
"ui-sans-serif",
|
||
"ui-serif",
|
||
"ui-monospace",
|
||
"emoji",
|
||
"math",
|
||
"fangsong",
|
||
"-apple-system",
|
||
"blinkmacsystemfont",
|
||
]);
|
||
|
||
/**
|
||
* Parse a single `font-family` value (e.g. `"Inter", -apple-system,
|
||
* sans-serif`) into a list of unquoted family names in declaration order.
|
||
* Whitespace and surrounding `"…"` / `'…'` quotes are stripped; case is
|
||
* preserved. Pass each name through `normalizeFamilyName` for case-
|
||
* insensitive comparisons.
|
||
*
|
||
* Only top-level commas split: a `var(--x, fallback)` expression stays one
|
||
* token, as does a comma inside a quoted family name.
|
||
*/
|
||
export function parseFontFamilyValue(value: string): string[] {
|
||
const pieces: string[] = [];
|
||
let start = 0;
|
||
let depth = 0;
|
||
let quote: "'" | '"' | null = null;
|
||
for (let index = 0; index < value.length; index += 1) {
|
||
const char = value[index];
|
||
if (char === "\\") {
|
||
index += 1;
|
||
continue;
|
||
}
|
||
if (quote) {
|
||
if (char === quote) quote = null;
|
||
continue;
|
||
}
|
||
if (char === "'" || char === '"') {
|
||
quote = char;
|
||
continue;
|
||
}
|
||
if (char === "(") {
|
||
depth += 1;
|
||
continue;
|
||
}
|
||
if (char === ")") {
|
||
depth = Math.max(0, depth - 1);
|
||
continue;
|
||
}
|
||
if (char !== "," || depth !== 0) continue;
|
||
pieces.push(value.slice(start, index));
|
||
start = index + 1;
|
||
}
|
||
pieces.push(value.slice(start));
|
||
|
||
return pieces
|
||
.map((piece) => piece.trim().replace(/^['"]/, "").replace(/['"]$/, "").trim())
|
||
.filter((piece) => piece.length > 0);
|
||
}
|
||
|
||
function systemPrimaryReplacement(value: string, deterministicPrimary: string): string | null {
|
||
const families = parseFontFamilyValue(value);
|
||
if (families.length === 0) return null;
|
||
if (!GENERIC_FAMILIES.has(normalizeFamilyName(families[0]!))) return null;
|
||
return `${deterministicPrimary}, ${value.trim()}`;
|
||
}
|
||
|
||
function parseCssRoot(css: string): postcss.Root | null {
|
||
try {
|
||
return postcss.parse(css);
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function isFontFaceDeclaration(decl: Declaration): boolean {
|
||
const parent = decl.parent;
|
||
return parent?.type === "atrule" && (parent as AtRule).name.toLowerCase() === "font-face";
|
||
}
|
||
|
||
function normalizeCssDeclarations(root: postcss.Root, deterministicPrimary: string): boolean {
|
||
let changed = false;
|
||
root.walkDecls((decl) => {
|
||
if (decl.prop.startsWith("--")) {
|
||
const replacement = systemPrimaryReplacement(decl.value, deterministicPrimary);
|
||
if (!replacement) return;
|
||
decl.value = replacement;
|
||
changed = true;
|
||
return;
|
||
}
|
||
|
||
if (decl.prop.toLowerCase() !== "font-family") return;
|
||
if (isFontFaceDeclaration(decl)) {
|
||
return;
|
||
}
|
||
const replacement = systemPrimaryReplacement(decl.value, deterministicPrimary);
|
||
if (!replacement) return;
|
||
decl.value = replacement;
|
||
changed = true;
|
||
});
|
||
|
||
return changed;
|
||
}
|
||
|
||
function normalizeCssFontFamilyDeclarations(css: string, deterministicPrimary: string): string {
|
||
const root = parseCssRoot(css);
|
||
if (!root) return css;
|
||
const changed = normalizeCssDeclarations(root, deterministicPrimary);
|
||
return changed ? root.toString() : css;
|
||
}
|
||
|
||
function normalizeInlineStyleAttribute(style: string, deterministicPrimary: string): string {
|
||
const root = parseCssRoot(`*{${style}}`);
|
||
if (!root) return style;
|
||
const rule = root.first;
|
||
if (rule?.type !== "rule") return style;
|
||
const before = rule.toString();
|
||
normalizeCssDeclarations(root, deterministicPrimary);
|
||
if (rule.toString() === before) return style;
|
||
const serialized = ((rule as Rule).nodes ?? []).map((node) => node.toString()).join("; ");
|
||
return serialized.endsWith(";") ? serialized : `${serialized};`;
|
||
}
|
||
|
||
/**
|
||
* Import/generated HTML often uses host UI stacks such as
|
||
* `-apple-system, BlinkMacSystemFont, sans-serif` as a primary family. That is
|
||
* fine on the author's machine but not in distributed render workers, where
|
||
* host fonts differ by OS. Promote a bundled deterministic family to the
|
||
* primary slot while preserving the original stack as fallbacks.
|
||
*/
|
||
export function normalizeSystemFontPrimaryFamilies(
|
||
html: string,
|
||
deterministicPrimary = "Inter",
|
||
): string {
|
||
const { document } = parseHTML(html);
|
||
let changed = false;
|
||
|
||
for (const styleEl of Array.from(document.querySelectorAll("style"))) {
|
||
const current = styleEl.textContent ?? "";
|
||
const next = normalizeCssFontFamilyDeclarations(current, deterministicPrimary);
|
||
if (next === current) continue;
|
||
styleEl.textContent = next;
|
||
changed = true;
|
||
}
|
||
|
||
for (const el of Array.from(document.querySelectorAll("[style]"))) {
|
||
const current = el.getAttribute("style") ?? "";
|
||
const next = normalizeInlineStyleAttribute(current, deterministicPrimary);
|
||
if (next === current) continue;
|
||
el.setAttribute("style", next);
|
||
changed = true;
|
||
}
|
||
|
||
for (const el of Array.from(document.querySelectorAll("[data-font-family]"))) {
|
||
const current = el.getAttribute("data-font-family") ?? "";
|
||
const next = systemPrimaryReplacement(current, deterministicPrimary);
|
||
if (!next) continue;
|
||
el.setAttribute("data-font-family", next);
|
||
changed = true;
|
||
}
|
||
|
||
return changed ? document.toString() : html;
|
||
}
|
||
|
||
/** Surfaces font-family is declared on in served HTML. */
|
||
export type FontFamilySurface = "font-family" | "data-font-family";
|
||
|
||
export type FontFamilyDeclaration = {
|
||
surface: FontFamilySurface;
|
||
declaration: string;
|
||
families: string[];
|
||
};
|
||
|
||
function collectCssCustomProperties(css: string, customProperties: Map<string, string>): void {
|
||
const root = parseCssRoot(css);
|
||
if (!root) return;
|
||
root.walkDecls((decl) => {
|
||
if (!decl.prop.startsWith("--")) return;
|
||
customProperties.set(decl.prop, decl.value);
|
||
});
|
||
}
|
||
|
||
function* iterateCssRootFontFamilyDeclarations(
|
||
root: postcss.Root,
|
||
): Generator<FontFamilyDeclaration> {
|
||
const declarations: FontFamilyDeclaration[] = [];
|
||
root.walkDecls((decl) => {
|
||
if (decl.prop.toLowerCase() !== "font-family") return;
|
||
if (isFontFaceDeclaration(decl)) return;
|
||
const declaration = decl.value;
|
||
declarations.push({
|
||
surface: "font-family",
|
||
declaration,
|
||
families: parseFontFamilyValue(declaration),
|
||
});
|
||
});
|
||
yield* declarations;
|
||
}
|
||
|
||
function* iterateCssFontFamilyDeclarations(css: string): Generator<FontFamilyDeclaration> {
|
||
const root = parseCssRoot(css);
|
||
if (!root) return;
|
||
yield* iterateCssRootFontFamilyDeclarations(root);
|
||
}
|
||
|
||
function* iterateInlineStyleFontFamilyDeclarations(
|
||
style: string,
|
||
): Generator<FontFamilyDeclaration> {
|
||
const root = parseCssRoot(`*{${style}}`);
|
||
if (!root) return;
|
||
yield* iterateCssRootFontFamilyDeclarations(root);
|
||
}
|
||
|
||
/**
|
||
* Collect simple CSS custom-property font aliases from style blocks and inline
|
||
* styles. CSS cascade is richer than this map, but for compiler-generated
|
||
* imports the common shape is `--font: Inter, sans-serif` paired with
|
||
* `font-family: var(--font)`.
|
||
*/
|
||
export function collectFontFamilyCustomProperties(html: string): Map<string, string> {
|
||
const { document } = parseHTML(html);
|
||
const customProperties = new Map<string, string>();
|
||
|
||
for (const styleEl of Array.from(document.querySelectorAll("style"))) {
|
||
collectCssCustomProperties(styleEl.textContent ?? "", customProperties);
|
||
}
|
||
for (const el of Array.from(document.querySelectorAll("[style]"))) {
|
||
collectCssCustomProperties(`*{${el.getAttribute("style") ?? ""}}`, customProperties);
|
||
}
|
||
|
||
return customProperties;
|
||
}
|
||
|
||
function primaryCssVariableName(value: string): string | null {
|
||
const trimmed = value.trim();
|
||
if (!trimmed.toLowerCase().startsWith("var(")) return null;
|
||
|
||
let depth = 0;
|
||
for (let index = 0; index < trimmed.length; index += 1) {
|
||
const char = trimmed[index];
|
||
if (char === "(") {
|
||
depth += 1;
|
||
continue;
|
||
}
|
||
if (char !== ")") continue;
|
||
depth -= 1;
|
||
if (depth !== 0) continue;
|
||
|
||
const varExpression = trimmed.slice(0, index + 1);
|
||
const inner = varExpression.slice(4, -1).trim();
|
||
const commaIndex = inner.indexOf(",");
|
||
const variableName = (commaIndex === -1 ? inner : inner.slice(0, commaIndex)).trim();
|
||
return /^--[A-Za-z0-9_-]+$/.test(variableName) ? variableName : null;
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
export function resolveFontFamilyDeclarationFamilies(
|
||
declaration: string,
|
||
customProperties: ReadonlyMap<string, string>,
|
||
): string[] {
|
||
const families = parseFontFamilyValue(declaration);
|
||
const variableName = primaryCssVariableName(declaration);
|
||
if (!variableName) return families;
|
||
|
||
const resolved = customProperties.get(variableName);
|
||
if (!resolved) return families;
|
||
return [...parseFontFamilyValue(resolved), ...families.slice(1)];
|
||
}
|
||
|
||
/**
|
||
* Iterate every font-family declaration in a compiled HTML document. Yields
|
||
* each declaration's surface (CSS property vs HTML attribute), raw value,
|
||
* and the parsed family list. Used by both the @font-face injector and the
|
||
* plan-time validator so they read the same surface area.
|
||
*/
|
||
export function* iterateFontFamilyDeclarations(
|
||
html: string,
|
||
): Generator<FontFamilyDeclaration, void, void> {
|
||
const { document } = parseHTML(html);
|
||
|
||
for (const styleEl of Array.from(document.querySelectorAll("style"))) {
|
||
yield* iterateCssFontFamilyDeclarations(styleEl.textContent ?? "");
|
||
}
|
||
|
||
for (const el of Array.from(document.querySelectorAll("[style]"))) {
|
||
yield* iterateInlineStyleFontFamilyDeclarations(el.getAttribute("style") ?? "");
|
||
}
|
||
|
||
for (const el of Array.from(document.querySelectorAll("[data-font-family]"))) {
|
||
const declaration = el.getAttribute("data-font-family") ?? "";
|
||
yield { surface: "data-font-family", declaration, families: parseFontFamilyValue(declaration) };
|
||
}
|
||
}
|
||
|
||
const CANONICAL_FONTS: Record<string, CanonicalFontSpec> = {
|
||
inter: {
|
||
packageName: "@fontsource/inter",
|
||
faces: [{ weight: "400" }, { weight: "700" }, { weight: "900" }],
|
||
},
|
||
montserrat: {
|
||
packageName: "@fontsource/montserrat",
|
||
faces: [{ weight: "400" }, { weight: "700" }, { weight: "900" }],
|
||
},
|
||
outfit: {
|
||
packageName: "@fontsource/outfit",
|
||
faces: [{ weight: "400" }, { weight: "700" }, { weight: "900" }],
|
||
},
|
||
nunito: {
|
||
packageName: "@fontsource/nunito",
|
||
faces: [{ weight: "400" }, { weight: "700" }, { weight: "900" }],
|
||
},
|
||
oswald: {
|
||
packageName: "@fontsource/oswald",
|
||
faces: [{ weight: "400" }, { weight: "700" }],
|
||
},
|
||
"league-gothic": {
|
||
packageName: "@fontsource/league-gothic",
|
||
faces: [{ weight: "400" }],
|
||
},
|
||
"archivo-black": {
|
||
packageName: "@fontsource/archivo-black",
|
||
faces: [{ weight: "400" }],
|
||
},
|
||
"space-mono": {
|
||
packageName: "@fontsource/space-mono",
|
||
faces: [{ weight: "400" }, { weight: "700" }],
|
||
},
|
||
"ibm-plex-mono": {
|
||
packageName: "@fontsource/ibm-plex-mono",
|
||
faces: [{ weight: "400" }, { weight: "700" }],
|
||
},
|
||
"jetbrains-mono": {
|
||
packageName: "@fontsource/jetbrains-mono",
|
||
faces: [{ weight: "400" }, { weight: "700" }],
|
||
},
|
||
"eb-garamond": {
|
||
packageName: "@fontsource/eb-garamond",
|
||
faces: [{ weight: "400" }, { weight: "700" }],
|
||
},
|
||
"playfair-display": {
|
||
packageName: "@fontsource/playfair-display",
|
||
faces: [{ weight: "400" }, { weight: "700" }, { weight: "900" }],
|
||
},
|
||
"source-code-pro": {
|
||
packageName: "@fontsource/source-code-pro",
|
||
faces: [{ weight: "400" }, { weight: "700" }],
|
||
},
|
||
"noto-sans-jp": {
|
||
packageName: "@fontsource/noto-sans-jp",
|
||
faces: [{ weight: "400" }, { weight: "700" }],
|
||
},
|
||
roboto: {
|
||
packageName: "@fontsource/roboto",
|
||
faces: [{ weight: "400" }, { weight: "700" }, { weight: "900" }],
|
||
},
|
||
"open-sans": {
|
||
packageName: "@fontsource/open-sans",
|
||
faces: [{ weight: "400" }, { weight: "700" }],
|
||
},
|
||
lato: {
|
||
packageName: "@fontsource/lato",
|
||
faces: [{ weight: "400" }, { weight: "700" }, { weight: "900" }],
|
||
},
|
||
poppins: {
|
||
packageName: "@fontsource/poppins",
|
||
faces: [{ weight: "400" }, { weight: "700" }, { weight: "900" }],
|
||
},
|
||
};
|
||
|
||
// FONT_ALIASES derives from the shared alias map in @hyperframes/core.
|
||
// The cast is safe: every value in FONT_ALIAS_MAP is a valid CANONICAL_FONTS key.
|
||
export const FONT_ALIASES = FONT_ALIAS_MAP as Record<string, keyof typeof CANONICAL_FONTS>;
|
||
|
||
export { FONT_ALIAS_KEYS } from "@hyperframes/core/fonts/aliases";
|
||
|
||
function normalizeFamilyName(family: string): string {
|
||
return family
|
||
.trim()
|
||
.replace(/^['"]|['"]$/g, "")
|
||
.trim()
|
||
.toLowerCase();
|
||
}
|
||
|
||
function fontDataUri(
|
||
packageName: string,
|
||
weight: string,
|
||
style: "normal" | "italic" = "normal",
|
||
): string {
|
||
const key = `${packageName}:${weight}:${style}`;
|
||
const uri = EMBEDDED_FONT_DATA.get(key);
|
||
if (!uri) {
|
||
throw new Error(
|
||
`No embedded font data for ${key}. Regenerate with: tsx scripts/generate-font-data.ts`,
|
||
);
|
||
}
|
||
return uri;
|
||
}
|
||
|
||
function extractExistingFontFaces(html: string): Set<string> {
|
||
const families = new Set<string>();
|
||
const fontFaceRegex = /@font-face\s*\{[\s\S]*?font-family\s*:\s*([^;]+);[\s\S]*?\}/gi;
|
||
for (const match of html.matchAll(fontFaceRegex)) {
|
||
const raw = match[1] || "";
|
||
const normalized = normalizeFamilyName(raw);
|
||
if (normalized) {
|
||
families.add(normalized);
|
||
}
|
||
}
|
||
return families;
|
||
}
|
||
|
||
function extractRequestedFontFamilies(html: string): Map<string, string> {
|
||
const requested = new Map<string, string>();
|
||
const customProperties = collectFontFamilyCustomProperties(html);
|
||
for (const { declaration } of iterateFontFamilyDeclarations(html)) {
|
||
for (const originalCase of resolveFontFamilyDeclarationFamilies(
|
||
declaration,
|
||
customProperties,
|
||
)) {
|
||
const normalized = originalCase.toLowerCase();
|
||
if (!normalized || GENERIC_FAMILIES.has(normalized)) continue;
|
||
if (normalized.startsWith("var(")) continue;
|
||
if (!requested.has(normalized)) requested.set(normalized, originalCase);
|
||
}
|
||
}
|
||
return requested;
|
||
}
|
||
|
||
export function fontFormatHint(src: string): "collection" | "woff2" {
|
||
return src.startsWith("data:font/collection;") ? "collection" : "woff2";
|
||
}
|
||
|
||
function buildFontFaceRule(
|
||
familyName: string,
|
||
src: string,
|
||
weight: string,
|
||
style: string,
|
||
unicodeRange?: string,
|
||
): string {
|
||
return [
|
||
"@font-face {",
|
||
` font-family: "${familyName}";`,
|
||
` src: url("${src}") format("${fontFormatHint(src)}");`,
|
||
` font-style: ${style};`,
|
||
` font-weight: ${weight};`,
|
||
" font-display: block;",
|
||
// Preserve the subset's unicode-range so the browser selects the right
|
||
// per-codepoint subset (matching Google Fonts' own CSS semantics).
|
||
...(unicodeRange ? [` unicode-range: ${unicodeRange};`] : []),
|
||
"}",
|
||
].join("\n");
|
||
}
|
||
|
||
/**
|
||
* Google serves several canonical families as a variable font: every static
|
||
* weight resolves to the same woff2. Faces sharing a source can be emitted as
|
||
* one weight-range rule instead of embedding that blob once per weight.
|
||
*
|
||
* Without `text=` the response is ordered weight-major, subset-minor, so those
|
||
* faces are not adjacent — group by source rather than scanning neighbours.
|
||
* Insertion order keeps the emitted CSS deterministic.
|
||
*/
|
||
function normalizeWeightKey(weight: string): string {
|
||
const numeric = Number(weight);
|
||
return Number.isFinite(numeric) ? String(numeric) : weight.trim().toLowerCase();
|
||
}
|
||
|
||
function coverageKey(weight: string, style: string): string {
|
||
return `${normalizeWeightKey(weight)}:${style}`;
|
||
}
|
||
|
||
function groupFacesBySource(faces: readonly GoogleFontFace[]): GoogleFontFace[][] {
|
||
const groups = new Map<string, GoogleFontFace[]>();
|
||
for (const face of faces) {
|
||
const key = [face.dataUri, face.style, face.unicodeRange ?? ""].join("\u0000");
|
||
const existing = groups.get(key);
|
||
if (existing) existing.push(face);
|
||
else groups.set(key, [face]);
|
||
}
|
||
return [...groups.values()];
|
||
}
|
||
|
||
/**
|
||
* A weight range must not span a weight the embedded bundle already serves, or
|
||
* the later rule would win for that weight and shadow the bundled face.
|
||
*/
|
||
function spansCoveredWeight(
|
||
from: GoogleFontFace,
|
||
to: GoogleFontFace,
|
||
coveredWeights: ReadonlySet<string>,
|
||
): boolean {
|
||
const start = Number(from.weight);
|
||
const end = Number(to.weight);
|
||
if (!Number.isFinite(start) || !Number.isFinite(end)) return true;
|
||
const low = Math.min(start, end);
|
||
const high = Math.max(start, end);
|
||
for (const covered of coveredWeights) {
|
||
const [weight, style] = covered.split(":");
|
||
if (style !== from.style) continue;
|
||
const value = Number(weight);
|
||
if (Number.isFinite(value) && value > low && value < high) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Split one source group into ascending runs, breaking wherever the embedded
|
||
* bundle already covers a weight inside the span. A weight that is not a plain
|
||
* number (a variable `100 900` range, say) cannot be ordered, so it stays on
|
||
* its own.
|
||
*/
|
||
function partitionWeightRuns(
|
||
faces: readonly GoogleFontFace[],
|
||
coveredWeights: ReadonlySet<string>,
|
||
): GoogleFontFace[][] {
|
||
const runs: GoogleFontFace[][] = [];
|
||
const sortable = faces.filter((face) => Number.isFinite(Number(face.weight)));
|
||
const unsortable = faces.filter((face) => !Number.isFinite(Number(face.weight)));
|
||
|
||
let current: GoogleFontFace[] = [];
|
||
for (const face of [...sortable].sort((a, b) => Number(a.weight) - Number(b.weight))) {
|
||
const previous = current[current.length - 1];
|
||
if (previous && spansCoveredWeight(previous, face, coveredWeights)) {
|
||
runs.push(current);
|
||
current = [];
|
||
}
|
||
current.push(face);
|
||
}
|
||
if (current.length > 0) runs.push(current);
|
||
for (const face of unsortable) runs.push([face]);
|
||
return runs;
|
||
}
|
||
|
||
async function buildFontFaceCss(
|
||
requestedFamilies: Map<string, string>,
|
||
options: InternalFontFetchOptions,
|
||
fontText?: string,
|
||
): Promise<{
|
||
css: string;
|
||
unresolved: string[];
|
||
}> {
|
||
const rules: string[] = [];
|
||
const unresolved: string[] = [];
|
||
|
||
for (const [normalizedFamily, originalCaseFamily] of requestedFamilies) {
|
||
// Path 1: pre-bundled fonts via FONT_ALIASES — emit embedded faces,
|
||
// then fetch from Google Fonts to fill any weights not in the bundle.
|
||
const canonicalKey = FONT_ALIASES[normalizedFamily];
|
||
if (canonicalKey) {
|
||
const canonical = CANONICAL_FONTS[canonicalKey];
|
||
if (!canonical) continue;
|
||
|
||
const coveredWeights = new Set<string>();
|
||
for (const face of canonical.faces) {
|
||
const style = face.style || "normal";
|
||
const src = fontDataUri(canonical.packageName, face.weight, style);
|
||
rules.push(buildFontFaceRule(originalCaseFamily, src, face.weight, style));
|
||
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 fetchGoogleFont(canonicalFamily, options, fontText)
|
||
: [];
|
||
|
||
// A weight covered by the embedded bundle is already full-coverage —
|
||
// skip it. For weights the bundle lacks, keep EVERY subset face (a
|
||
// weight has one face per unicode-range subset), not just the first.
|
||
const supplementary = googleFaces.filter(
|
||
(face) => !coveredWeights.has(coverageKey(face.weight, face.style)),
|
||
);
|
||
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,
|
||
),
|
||
);
|
||
}
|
||
continue;
|
||
}
|
||
|
||
// Path 2: fetch from Google Fonts (with local cache)
|
||
const googleFaces = await fetchGoogleFont(originalCaseFamily, options, fontText);
|
||
if (googleFaces.length > 0) {
|
||
for (const face of googleFaces) {
|
||
rules.push(
|
||
buildFontFaceRule(
|
||
originalCaseFamily,
|
||
face.dataUri,
|
||
face.weight,
|
||
face.style,
|
||
face.unicodeRange,
|
||
),
|
||
);
|
||
}
|
||
continue;
|
||
}
|
||
|
||
// Path 3: locate font on the local filesystem, compress, and embed.
|
||
if (options.allowSystemFontCapture) {
|
||
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);
|
||
}
|
||
|
||
return {
|
||
css: rules.join("\n\n").trim(),
|
||
unresolved: unresolved.sort(),
|
||
};
|
||
}
|
||
|
||
function warnUnresolvedFonts(unresolved: string[]): void {
|
||
const mapped = Object.entries(FONT_ALIASES)
|
||
.reduce<string[]>((acc, [alias, canonical]) => {
|
||
const display = alias === canonical ? alias : `${alias} → ${canonical}`;
|
||
if (!acc.includes(display)) acc.push(display);
|
||
return acc;
|
||
}, [])
|
||
.sort();
|
||
defaultLogger.warn(
|
||
`[Compiler] No deterministic font mapping for: ${unresolved.join(", ")}\n` +
|
||
` Mapped fonts: ${mapped.join(", ")}\n` +
|
||
` To fix, pick one:\n` +
|
||
` 1. Use a mapped font name instead (see list above)\n` +
|
||
` 2. Add a @font-face block in your HTML with a local or hosted font file\n` +
|
||
` 3. Install the font locally on the render machine (Docker: add to Dockerfile)\n` +
|
||
` 4. Add an alias to FONT_ALIAS_MAP in packages/core/src/fonts/aliases.ts (for contributors)\n` +
|
||
` Docs: https://hyperframes.heygen.com/docs/fonts`,
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Google Fonts on-demand fetch + local cache
|
||
// ---------------------------------------------------------------------------
|
||
|
||
let lambdaFontCacheRoot: string | undefined;
|
||
|
||
// On AWS Lambda `$HOME` resolves to a `/home/sbx_*` tree that's read-only;
|
||
// only `/tmp` is writable. Create one private, unguessable cache directory per
|
||
// warm process and reuse it across invocations. Honor HYPERFRAMES_FONT_CACHE_DIR
|
||
// as an explicit override for any environment.
|
||
function resolveFontCacheRoot(): string {
|
||
if (process.env.HYPERFRAMES_FONT_CACHE_DIR) {
|
||
return process.env.HYPERFRAMES_FONT_CACHE_DIR;
|
||
}
|
||
if (process.env.AWS_LAMBDA_FUNCTION_NAME) {
|
||
lambdaFontCacheRoot ??= mkdtempSync(join(tmpdir(), "hyperframes-fonts-"));
|
||
return lambdaFontCacheRoot;
|
||
}
|
||
return join(homedir(), ".cache", "hyperframes", "fonts");
|
||
}
|
||
|
||
// Chrome UA triggers woff2 responses from Google Fonts CSS API
|
||
const WOFF2_USER_AGENT =
|
||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
|
||
|
||
function fontSlug(familyName: string): string {
|
||
return familyName
|
||
.toLowerCase()
|
||
.replace(/[^a-z0-9]+/g, "-")
|
||
.replace(/^-|-$/g, "");
|
||
}
|
||
|
||
function fontCacheDir(slug: string): string {
|
||
const dir = join(resolveFontCacheRoot(), slug);
|
||
if (!existsSync(dir)) {
|
||
mkdirSync(dir, { recursive: true });
|
||
}
|
||
return dir;
|
||
}
|
||
|
||
// A short, stable discriminator for a single subset's woff2. Google Fonts'
|
||
// css2 API returns one @font-face per (weight × unicode-range subset) — e.g.
|
||
// `vietnamese`, `latin-ext`, and `latin` faces for the SAME weight, each with
|
||
// a distinct woff2 URL and glyph set. Keying the cache by weight+style alone
|
||
// collides every subset onto one filename, so only the first subset in the
|
||
// CSS gets downloaded and the rest read it back. Derive the cache key from the
|
||
// (subset-unique, version-stable) woff2 URL so each subset is cached on its own.
|
||
function subsetToken(woff2Url: string): string {
|
||
return createHash("sha1").update(woff2Url).digest("hex").slice(0, 12);
|
||
}
|
||
|
||
function cachedWoff2Path(slug: string, weight: string, style: string, subset: string): string {
|
||
return join(fontCacheDir(slug), `${weight}-${style}-${subset}.woff2`);
|
||
}
|
||
|
||
type GoogleFontFace = {
|
||
weight: string;
|
||
style: string;
|
||
dataUri: string;
|
||
unicodeRange?: string;
|
||
};
|
||
|
||
/**
|
||
* Typed codes let distributed workflow adapters distinguish deterministic
|
||
* resolution failures from temporary upstream unavailability.
|
||
*/
|
||
export const FONT_FETCH_FAILED = "FONT_FETCH_FAILED";
|
||
export const FONT_FETCH_UNAVAILABLE = "FONT_FETCH_UNAVAILABLE";
|
||
export type FontFetchErrorCode = typeof FONT_FETCH_FAILED | typeof FONT_FETCH_UNAVAILABLE;
|
||
|
||
/**
|
||
* Typed error thrown by {@link injectDeterministicFontFaces} when
|
||
* `failClosedFontFetch === true` and deterministic font resolution fails.
|
||
* The default (swallow + warn) preserves the in-process behavior.
|
||
*/
|
||
export class FontFetchError extends Error {
|
||
readonly code: FontFetchErrorCode;
|
||
readonly familyName: string;
|
||
readonly url: string;
|
||
readonly cause?: unknown;
|
||
|
||
constructor(
|
||
familyName: string,
|
||
url: string,
|
||
message: string,
|
||
cause?: unknown,
|
||
code: FontFetchErrorCode = FONT_FETCH_FAILED,
|
||
) {
|
||
super(message);
|
||
this.name = "FontFetchError";
|
||
this.code = code;
|
||
this.familyName = familyName;
|
||
this.url = url;
|
||
this.cause = cause;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Retryable font-fetch failure. Distributed adapters map this code to an
|
||
* unavailable response so the workflow can retry the plan activity.
|
||
*/
|
||
export class FontFetchUnavailableError extends FontFetchError {
|
||
constructor(familyName: string, url: string, message: string, cause?: unknown) {
|
||
super(familyName, url, message, cause, FONT_FETCH_UNAVAILABLE);
|
||
this.name = "FontFetchUnavailableError";
|
||
}
|
||
}
|
||
|
||
export interface FontFetchRetryPolicy {
|
||
/** Total fetch attempts for one CSS or woff2 URL. Default: 2. */
|
||
maxAttempts: number;
|
||
/** Timeout for each individual fetch attempt. Default: 8 seconds. */
|
||
attemptTimeoutMs: number;
|
||
/** Shared wall-clock budget for all Google Fonts requests in one compile. Default: 20 seconds. */
|
||
maxElapsedMs: number;
|
||
/** Initial full-jitter backoff ceiling. Default: 250 ms. */
|
||
baseDelayMs: number;
|
||
}
|
||
|
||
const DEFAULT_FONT_FETCH_RETRY_POLICY: FontFetchRetryPolicy = {
|
||
maxAttempts: 2,
|
||
attemptTimeoutMs: 8_000,
|
||
maxElapsedMs: 20_000,
|
||
baseDelayMs: 250,
|
||
};
|
||
|
||
/** Internal threading of the failClosed flag + fetch override through callers. */
|
||
interface InternalFontFetchOptions {
|
||
failClosedFontFetch: boolean;
|
||
fetchImpl: typeof fetch;
|
||
allowSystemFontCapture: boolean;
|
||
abortSignal?: AbortSignal;
|
||
retryPolicy: FontFetchRetryPolicy;
|
||
retryDeadlineMs: number;
|
||
}
|
||
|
||
/**
|
||
* Build a typed FontFetchError describing why a Google Fonts request failed.
|
||
* Centralizes the message wording so all four call sites (CSS/woff2 ×
|
||
* HTTP-error/exception) stay phrased identically.
|
||
*/
|
||
function fontFetchError(
|
||
familyName: string,
|
||
url: string,
|
||
what: "Google Fonts CSS" | `Google Fonts woff2 (${string}/${string})`,
|
||
cause: { status: number } | { error: unknown },
|
||
unavailable = false,
|
||
): FontFetchError {
|
||
const reason =
|
||
"status" in cause
|
||
? `returned HTTP ${cause.status}`
|
||
: `failed: ${cause.error instanceof Error ? cause.error.message : String(cause.error)}`;
|
||
const message =
|
||
`[deterministicFonts] ${what} fetch for ${JSON.stringify(familyName)} ${reason}. ` +
|
||
`Distributed renders require deterministic fonts; system-font fallback would produce ` +
|
||
`non-byte-identical output.`;
|
||
const errorCause = "error" in cause ? cause.error : undefined;
|
||
return unavailable
|
||
? new FontFetchUnavailableError(familyName, url, message, errorCause)
|
||
: new FontFetchError(familyName, url, message, errorCause);
|
||
}
|
||
|
||
function isRetryableFontFetchStatus(status: number): boolean {
|
||
return status === 408 || status === 425 || status === 429 || status >= 500;
|
||
}
|
||
|
||
function retryAfterMs(response: Response): number | null {
|
||
const value = response.headers.get("retry-after");
|
||
if (value === null) return null;
|
||
const seconds = Number(value);
|
||
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1_000);
|
||
const date = Date.parse(value);
|
||
return Number.isNaN(date) ? null : Math.max(0, date - Date.now());
|
||
}
|
||
|
||
function callerAbortReason(signal: AbortSignal): unknown {
|
||
return signal.reason ?? new DOMException("Font fetch cancelled", "AbortError");
|
||
}
|
||
|
||
function throwIfCallerAborted(signal: AbortSignal | undefined): void {
|
||
if (signal?.aborted) throw callerAbortReason(signal);
|
||
}
|
||
|
||
async function waitForFontFetchRetry(
|
||
delayMs: number,
|
||
signal: AbortSignal | undefined,
|
||
): Promise<void> {
|
||
throwIfCallerAborted(signal);
|
||
if (delayMs <= 0) return;
|
||
await new Promise<void>((resolve, reject) => {
|
||
const timeout = setTimeout(() => {
|
||
signal?.removeEventListener("abort", onAbort);
|
||
resolve();
|
||
}, delayMs);
|
||
const onAbort = (): void => {
|
||
clearTimeout(timeout);
|
||
reject(signal ? callerAbortReason(signal) : new DOMException("Cancelled", "AbortError"));
|
||
};
|
||
signal?.addEventListener("abort", onAbort, { once: true });
|
||
if (signal?.aborted) onAbort();
|
||
});
|
||
}
|
||
|
||
function retryDelayMs(
|
||
response: Response | undefined,
|
||
attempt: number,
|
||
baseDelayMs: number,
|
||
): number {
|
||
const requestedDelay = response ? retryAfterMs(response) : null;
|
||
if (requestedDelay !== null) return requestedDelay;
|
||
const ceiling = baseDelayMs * 2 ** attempt;
|
||
return Math.floor(Math.random() * (ceiling + 1));
|
||
}
|
||
|
||
function cancelResponseBody(response: Response): void {
|
||
try {
|
||
void response.body?.cancel().catch(() => {
|
||
// Best-effort connection cleanup must not replace the original status.
|
||
});
|
||
} catch {
|
||
// Best-effort connection cleanup must not replace the original status.
|
||
}
|
||
}
|
||
|
||
type FontFetchResult<T> =
|
||
| { ok: true; response: Response; body: T }
|
||
| { ok: false; response: Response };
|
||
|
||
type FontFetchAttemptResult<T> =
|
||
| { completed: true; result: FontFetchResult<T> }
|
||
| {
|
||
completed: false;
|
||
response?: Response;
|
||
cause: { status: number } | { error: unknown };
|
||
};
|
||
|
||
async function runFontFetchAttempt<T>(
|
||
url: string,
|
||
init: RequestInit | undefined,
|
||
readBody: (response: Response) => Promise<T>,
|
||
options: InternalFontFetchOptions,
|
||
remainingMs: number,
|
||
): Promise<FontFetchAttemptResult<T>> {
|
||
const timeoutSignal = AbortSignal.timeout(
|
||
Math.max(1, Math.min(options.retryPolicy.attemptTimeoutMs, remainingMs)),
|
||
);
|
||
const signal = options.abortSignal
|
||
? AbortSignal.any([options.abortSignal, timeoutSignal])
|
||
: timeoutSignal;
|
||
let response: Response | undefined;
|
||
try {
|
||
response = await options.fetchImpl(url, { ...init, signal });
|
||
if (!isRetryableFontFetchStatus(response.status)) {
|
||
if (!response.ok) return { completed: true, result: { ok: false, response } };
|
||
const body = await readBody(response);
|
||
return { completed: true, result: { ok: true, response, body } };
|
||
}
|
||
cancelResponseBody(response);
|
||
return { completed: false, response, cause: { status: response.status } };
|
||
} catch (error) {
|
||
throwIfCallerAborted(options.abortSignal);
|
||
return { completed: false, response, cause: { error } };
|
||
}
|
||
}
|
||
|
||
async function fetchFontResource<T>(
|
||
url: string,
|
||
init: RequestInit | undefined,
|
||
readBody: (response: Response) => Promise<T>,
|
||
familyName: string,
|
||
what: "Google Fonts CSS" | `Google Fonts woff2 (${string}/${string})`,
|
||
options: InternalFontFetchOptions,
|
||
): Promise<FontFetchResult<T>> {
|
||
if (!options.failClosedFontFetch) {
|
||
const response = await options.fetchImpl(url, { ...init, signal: options.abortSignal });
|
||
if (!response.ok) return { ok: false, response };
|
||
return { ok: true, response, body: await readBody(response) };
|
||
}
|
||
|
||
let lastCause: { status: number } | { error: unknown } = {
|
||
error: new DOMException("Font fetch budget exhausted", "TimeoutError"),
|
||
};
|
||
for (let attempt = 0; attempt < options.retryPolicy.maxAttempts; attempt += 1) {
|
||
throwIfCallerAborted(options.abortSignal);
|
||
const remainingMs = options.retryDeadlineMs - Date.now();
|
||
if (remainingMs <= 0) break;
|
||
|
||
const attemptResult = await runFontFetchAttempt(url, init, readBody, options, remainingMs);
|
||
if (attemptResult.completed) return attemptResult.result;
|
||
lastCause = attemptResult.cause;
|
||
|
||
if (attempt + 1 >= options.retryPolicy.maxAttempts) break;
|
||
const delayMs = retryDelayMs(attemptResult.response, attempt, options.retryPolicy.baseDelayMs);
|
||
if (delayMs >= options.retryDeadlineMs - Date.now()) break;
|
||
await waitForFontFetchRetry(delayMs, options.abortSignal);
|
||
}
|
||
|
||
throw fontFetchError(familyName, url, what, lastCause, true);
|
||
}
|
||
|
||
/**
|
||
* Ensure one subset's woff2 is cached on disk (downloading if absent) and
|
||
* return it as a `data:` URI. Returns `null` when the woff2 isn't served
|
||
* (4xx) so the caller skips that face. Throws {@link FontFetchError} on
|
||
* transient (5xx / network) failures when `failClosedFontFetch` is set.
|
||
*/
|
||
async function ensureWoff2DataUri(
|
||
cachePath: string,
|
||
woff2Url: string,
|
||
familyName: string,
|
||
weight: string,
|
||
style: string,
|
||
options: InternalFontFetchOptions,
|
||
): Promise<string | null> {
|
||
try {
|
||
return `data:font/woff2;base64,${readFileSync(cachePath).toString("base64")}`;
|
||
} catch {
|
||
// Not cached yet — fall through to fetch.
|
||
}
|
||
|
||
const woff2What = `Google Fonts woff2 (${weight}/${style})` as const;
|
||
try {
|
||
const fontResult = await fetchFontResource(
|
||
woff2Url,
|
||
undefined,
|
||
(response) => response.arrayBuffer(),
|
||
familyName,
|
||
woff2What,
|
||
options,
|
||
);
|
||
if (!fontResult.ok) return null;
|
||
// wx = O_CREAT|O_EXCL: atomic create, rejects symlinks, fails with
|
||
// EEXIST if a concurrent call cached it between our read and write.
|
||
writeFileSync(cachePath, Buffer.from(fontResult.body), { flag: "wx", mode: 0o644 });
|
||
} catch (err) {
|
||
throwIfCallerAborted(options.abortSignal);
|
||
if (err instanceof FontFetchError) throw err;
|
||
if ((err as NodeJS.ErrnoException).code === "EEXIST") {
|
||
// Concurrent call wrote it — read their result below.
|
||
} else if (options.failClosedFontFetch) {
|
||
throw fontFetchError(familyName, woff2Url, woff2What, { error: err });
|
||
} else {
|
||
return null;
|
||
}
|
||
}
|
||
return `data:font/woff2;base64,${readFileSync(cachePath).toString("base64")}`;
|
||
}
|
||
|
||
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
|
||
// while preserving `familyName` for the emitted @font-face alias so the
|
||
// authored CSS still matches it.
|
||
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}`;
|
||
|
||
let cssText: string;
|
||
try {
|
||
const cssResult = await fetchFontResource(
|
||
url,
|
||
{ headers: { "User-Agent": WOFF2_USER_AGENT } },
|
||
(response) => response.text(),
|
||
familyName,
|
||
"Google Fonts CSS",
|
||
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 [];
|
||
}
|
||
cssText = cssResult.body;
|
||
} catch (err) {
|
||
// Rethrow typed error untouched. Network / DNS / fetch-throws are
|
||
// non-deterministic infrastructure failures — wrapped when failClosed
|
||
// is on, swallowed otherwise.
|
||
throwIfCallerAborted(options.abortSignal);
|
||
if (err instanceof FontFetchError) throw err;
|
||
if (options.failClosedFontFetch) {
|
||
throw fontFetchError(familyName, url, "Google Fonts CSS", { error: err });
|
||
}
|
||
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.
|
||
const faceRegex =
|
||
/@font-face\s*\{[^}]*font-style:\s*(normal|italic)[^}]*font-weight:\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 cachePath = cachedWoff2Path(slug, weight, style, subsetToken(woff2Url));
|
||
const dataUri = await ensureWoff2DataUri(
|
||
cachePath,
|
||
woff2Url,
|
||
familyName,
|
||
weight,
|
||
style,
|
||
options,
|
||
);
|
||
if (dataUri) faces.push({ weight, style, dataUri, unicodeRange });
|
||
}
|
||
|
||
if (faces.length > 0) {
|
||
defaultLogger.info(
|
||
`[Compiler] Fetched ${faces.length} font face(s) for "${familyName}" from Google Fonts (cached to ${fontCacheDir(slug)})`,
|
||
);
|
||
}
|
||
|
||
return faces;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Options for {@link injectDeterministicFontFaces}.
|
||
*/
|
||
export interface InjectDeterministicFontFacesOptions {
|
||
/**
|
||
* When `true`, exhausted transient fetch failures throw
|
||
* {@link FontFetchUnavailableError} with code `FONT_FETCH_UNAVAILABLE`;
|
||
* deterministic resolution failures retain `FONT_FETCH_FAILED`.
|
||
*
|
||
* Default `false`: failed fetches are silently swallowed; the composition
|
||
* falls back to system fonts via `warnUnresolvedFonts`. This preserves the
|
||
* in-process behavior.
|
||
*
|
||
* Distributed callers pass `true` so font availability is part of the
|
||
* planDir's content-addressed hash and failures surface as typed errors.
|
||
*/
|
||
failClosedFontFetch?: boolean;
|
||
/**
|
||
* Injectable `fetch` implementation. Defaults to the global `fetch`.
|
||
* Tests pass a stub to simulate fetch failures without going over the
|
||
* network.
|
||
*/
|
||
fetchImpl?: typeof fetch;
|
||
/** Caller cancellation propagated through fetch attempts and retry waits. */
|
||
abortSignal?: AbortSignal;
|
||
/**
|
||
* Optional retry tuning. Defaults are deliberately bounded for composition
|
||
* planning; tests may lower delays and timeouts without replacing timers.
|
||
*/
|
||
fontFetchRetryPolicy?: Partial<FontFetchRetryPolicy>;
|
||
/**
|
||
* When `true` (default for local renders), fonts that aren't resolved by
|
||
* the bundled alias map or Google Fonts are located on the local filesystem,
|
||
* compressed to woff2, and embedded as data URIs. Set to `false` for
|
||
* distributed/Lambda renders where the host filesystem is not guaranteed
|
||
* to contain the same fonts as the authoring machine.
|
||
*/
|
||
allowSystemFontCapture?: boolean;
|
||
}
|
||
|
||
// Keep the complete CSS request under the broadly supported ~2 KB URL limit.
|
||
// Using unique source characters covers static text plus strings authored in
|
||
// scripts, while collapsing repeated prose and base64 assets to a tiny set.
|
||
const GOOGLE_FONTS_TEXT_MAX_ENCODED_LENGTH = 1_700;
|
||
|
||
function extractGoogleFontsText(html: string): string | undefined {
|
||
const { document } = parseHTML(html);
|
||
const decodedBodyText = document.body?.textContent ?? "";
|
||
const uniqueCharacters = [...new Set([...Array.from(html), ...Array.from(decodedBodyText)])].join(
|
||
"",
|
||
);
|
||
return encodeURIComponent(uniqueCharacters).length <= GOOGLE_FONTS_TEXT_MAX_ENCODED_LENGTH
|
||
? uniqueCharacters
|
||
: undefined;
|
||
}
|
||
|
||
function resolveFontFetchRetryPolicy(
|
||
configured: Partial<FontFetchRetryPolicy> | undefined,
|
||
): FontFetchRetryPolicy {
|
||
return {
|
||
maxAttempts: Math.max(
|
||
1,
|
||
Math.floor(configured?.maxAttempts ?? DEFAULT_FONT_FETCH_RETRY_POLICY.maxAttempts),
|
||
),
|
||
attemptTimeoutMs: Math.max(
|
||
1,
|
||
configured?.attemptTimeoutMs ?? DEFAULT_FONT_FETCH_RETRY_POLICY.attemptTimeoutMs,
|
||
),
|
||
maxElapsedMs: Math.max(
|
||
1,
|
||
configured?.maxElapsedMs ?? DEFAULT_FONT_FETCH_RETRY_POLICY.maxElapsedMs,
|
||
),
|
||
baseDelayMs: Math.max(
|
||
0,
|
||
configured?.baseDelayMs ?? DEFAULT_FONT_FETCH_RETRY_POLICY.baseDelayMs,
|
||
),
|
||
};
|
||
}
|
||
|
||
export async function injectDeterministicFontFaces(
|
||
html: string,
|
||
options: InjectDeterministicFontFacesOptions = {},
|
||
): Promise<string> {
|
||
const failClosedFontFetch = options.failClosedFontFetch === true;
|
||
const fetchImpl = options.fetchImpl ?? fetch;
|
||
const allowSystemFontCapture = options.allowSystemFontCapture !== false;
|
||
const retryPolicy = resolveFontFetchRetryPolicy(options.fontFetchRetryPolicy);
|
||
const fetchOptions: InternalFontFetchOptions = {
|
||
failClosedFontFetch,
|
||
fetchImpl,
|
||
allowSystemFontCapture,
|
||
abortSignal: options.abortSignal,
|
||
retryPolicy,
|
||
retryDeadlineMs: Date.now() + retryPolicy.maxElapsedMs,
|
||
};
|
||
|
||
const existingFaces = extractExistingFontFaces(html);
|
||
const requestedFamilies = extractRequestedFontFamilies(html);
|
||
const pendingFamilies = new Map<string, string>();
|
||
|
||
for (const [normalizedFamily, originalCaseFamily] of requestedFamilies) {
|
||
if (!existingFaces.has(normalizedFamily)) {
|
||
pendingFamilies.set(normalizedFamily, originalCaseFamily);
|
||
}
|
||
}
|
||
|
||
if (pendingFamilies.size === 0) {
|
||
return html;
|
||
}
|
||
|
||
const { css, unresolved } = await buildFontFaceCss(
|
||
pendingFamilies,
|
||
fetchOptions,
|
||
extractGoogleFontsText(html),
|
||
);
|
||
if (unresolved.length > 0 && options.failClosedFontFetch) {
|
||
throw new FontFetchError(
|
||
unresolved.join(", "),
|
||
"",
|
||
`[Compiler] Unresolved fonts in fail-closed mode: ${unresolved.join(", ")}. ` +
|
||
`Distributed renders require all fonts to be resolvable.`,
|
||
);
|
||
}
|
||
if (!css) {
|
||
if (unresolved.length > 0) {
|
||
warnUnresolvedFonts(unresolved);
|
||
}
|
||
return html;
|
||
}
|
||
|
||
const { document } = parseHTML(html);
|
||
const head = document.querySelector("head");
|
||
if (!head) {
|
||
return html;
|
||
}
|
||
|
||
const styleEl = document.createElement("style");
|
||
styleEl.setAttribute("data-hyperframes-deterministic-fonts", "true");
|
||
styleEl.textContent = css;
|
||
head.insertBefore(styleEl, head.firstChild);
|
||
|
||
defaultLogger.info(
|
||
`[Compiler] Injected deterministic @font-face rules for ${pendingFamilies.size - unresolved.length} requested font families`,
|
||
);
|
||
if (unresolved.length > 0) {
|
||
warnUnresolvedFonts(unresolved);
|
||
}
|
||
|
||
return document.toString();
|
||
}
|