mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +00:00
feat(producer): plan-time validator — reject system fonts
Part of Phase 2 of the distributed rendering plan (determinism hardening).
See DISTRIBUTED-RENDERING-PLAN.md §5.3 (Banned in distributed mode) and
§9.3 (typed non-retryable failures).
Extends packages/producer/src/services/render/planValidation.ts with:
- validateNoSystemFonts(compiledHtml) — scans `font-family:` declarations
and `data-font-family=…` attributes. If the PRIMARY family (first
entry in the comma-separated list) resolves to a host-OS / CSS-generic
family, throws PlanValidationError with code SYSTEM_FONT_USED.
- parseFontFamilyValue(value) — pure helper that splits a font-family
declaration value, stripping whitespace + quotes.
Banned primary families: sans-serif, serif, monospace, cursive, fantasy,
system-ui, ui-sans-serif, ui-serif, ui-monospace, emoji, math, fangsong,
-apple-system, BlinkMacSystemFont. Mirrors the GENERIC_FAMILIES list in
deterministicFonts.ts (deliberately a separate copy — they're two
different concerns that happen to overlap today).
Generic families remain acceptable as CSS fallbacks; only the primary
slot is rejected. `font-family: "Inter", -apple-system, sans-serif` is
fine; `font-family: -apple-system, BlinkMacSystemFont` is rejected.
No caller invokes the validator yet. Phase 3's `plan()` will run it on
the compiled HTML before freezing the plan, so chunk workers (Linux
containers without macOS / Windows system fonts) never see compositions
that would render differently between the controller and the workers.
In-process behavior is unchanged.
14 unit tests added to packages/producer/src/services/render/
planValidation.test.ts cover: clean compositions, missing font-family,
each banned primary family, data-font-family= surface, case-insensitive
matching, fallback acceptance, and parser edge cases.
This is part of a stack of 10 PRs; this is PR 9 of 10.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -15,7 +15,14 @@ type CanonicalFontSpec = {
|
||||
faces: FontFaceSpec[];
|
||||
};
|
||||
|
||||
const GENERIC_FAMILIES = new Set([
|
||||
/**
|
||||
* 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",
|
||||
@@ -32,6 +39,44 @@ const GENERIC_FAMILIES = new Set([
|
||||
"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.
|
||||
*/
|
||||
export function parseFontFamilyValue(value: string): string[] {
|
||||
return value
|
||||
.split(",")
|
||||
.map((piece) => piece.trim().replace(/^['"]/, "").replace(/['"]$/, "").trim())
|
||||
.filter((piece) => piece.length > 0);
|
||||
}
|
||||
|
||||
/** Surfaces font-family is declared on in served HTML. */
|
||||
export type FontFamilySurface = "font-family" | "data-font-family";
|
||||
|
||||
/**
|
||||
* 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<{ surface: FontFamilySurface; declaration: string; families: string[] }, void, void> {
|
||||
const sources: ReadonlyArray<readonly [RegExp, FontFamilySurface]> = [
|
||||
[/font-family\s*:\s*([^;}{]+)[;}]?/gi, "font-family"],
|
||||
[/data-font-family=["']([^"']+)["']/gi, "data-font-family"],
|
||||
];
|
||||
for (const [regex, surface] of sources) {
|
||||
for (const match of html.matchAll(regex)) {
|
||||
const declaration = match[1] ?? "";
|
||||
yield { surface, declaration, families: parseFontFamilyValue(declaration) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const CANONICAL_FONTS: Record<string, CanonicalFontSpec> = {
|
||||
inter: {
|
||||
packageName: "@fontsource/inter",
|
||||
@@ -179,32 +224,13 @@ function extractExistingFontFaces(html: string): Set<string> {
|
||||
|
||||
function extractRequestedFontFamilies(html: string): Map<string, string> {
|
||||
const requested = new Map<string, string>();
|
||||
const addFamilyList = (value: string) => {
|
||||
for (const family of value.split(",")) {
|
||||
const originalCase = family
|
||||
.trim()
|
||||
.replace(/^['"]|['"]$/g, "")
|
||||
.trim();
|
||||
for (const { families } of iterateFontFamilyDeclarations(html)) {
|
||||
for (const originalCase of families) {
|
||||
const normalized = originalCase.toLowerCase();
|
||||
if (!normalized || GENERIC_FAMILIES.has(normalized)) {
|
||||
continue;
|
||||
}
|
||||
if (!requested.has(normalized)) {
|
||||
requested.set(normalized, originalCase);
|
||||
}
|
||||
if (!normalized || GENERIC_FAMILIES.has(normalized)) continue;
|
||||
if (!requested.has(normalized)) requested.set(normalized, originalCase);
|
||||
}
|
||||
};
|
||||
|
||||
const fontFamilyRegex = /font-family\s*:\s*([^;}{]+)[;}]?/gi;
|
||||
for (const match of html.matchAll(fontFamilyRegex)) {
|
||||
addFamilyList(match[1] || "");
|
||||
}
|
||||
|
||||
const dataFontFamilyRegex = /data-font-family=["']([^"']+)["']/gi;
|
||||
for (const match of html.matchAll(dataFontFamilyRegex)) {
|
||||
addFamilyList(match[1] || "");
|
||||
}
|
||||
|
||||
return requested;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,10 @@ import { describe, expect, it } from "bun:test";
|
||||
import {
|
||||
BROWSER_GPU_NOT_SOFTWARE,
|
||||
PlanValidationError,
|
||||
SYSTEM_FONT_USED,
|
||||
parseFontFamilyValue,
|
||||
validateNoGpuEncode,
|
||||
validateNoSystemFonts,
|
||||
} from "./planValidation.js";
|
||||
|
||||
describe("PlanValidationError", () => {
|
||||
@@ -87,3 +90,122 @@ describe("validateNoGpuEncode", () => {
|
||||
expect((caught as Error).message).toContain("GPU encode is banned");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseFontFamilyValue", () => {
|
||||
it("splits a comma-separated list and strips whitespace + quotes", () => {
|
||||
expect(parseFontFamilyValue(`"Inter", -apple-system, sans-serif`)).toEqual([
|
||||
"Inter",
|
||||
"-apple-system",
|
||||
"sans-serif",
|
||||
]);
|
||||
});
|
||||
|
||||
it("strips single quotes too", () => {
|
||||
expect(parseFontFamilyValue(`'My Custom Font', serif`)).toEqual(["My Custom Font", "serif"]);
|
||||
});
|
||||
|
||||
it("ignores empty entries (trailing commas)", () => {
|
||||
expect(parseFontFamilyValue(`Inter,,sans-serif`)).toEqual(["Inter", "sans-serif"]);
|
||||
});
|
||||
|
||||
it("handles a single value with no commas", () => {
|
||||
expect(parseFontFamilyValue(`"My Font"`)).toEqual(["My Font"]);
|
||||
});
|
||||
|
||||
it("handles an all-whitespace value as empty", () => {
|
||||
expect(parseFontFamilyValue(` `)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateNoSystemFonts", () => {
|
||||
const CLEAN_HTML = `<!doctype html>
|
||||
<html><head><style>
|
||||
body { font-family: "Inter", -apple-system, sans-serif; margin: 0; }
|
||||
h1 { font-family: "Montserrat", "Helvetica Neue", sans-serif; }
|
||||
</style></head>
|
||||
<body><h1 data-font-family="Outfit, sans-serif">Hello</h1></body>
|
||||
</html>`;
|
||||
|
||||
it("passes a composition with deterministic web fonts as primary", () => {
|
||||
expect(() => validateNoSystemFonts(CLEAN_HTML)).not.toThrow();
|
||||
});
|
||||
|
||||
it("passes when font-family is absent entirely (plain text composition)", () => {
|
||||
expect(() =>
|
||||
validateNoSystemFonts(`<!doctype html><html><body><p>no fonts here</p></body></html>`),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("throws SYSTEM_FONT_USED when primary family is `-apple-system`", () => {
|
||||
const offending = `<style>body { font-family: -apple-system, BlinkMacSystemFont, sans-serif; }</style>`;
|
||||
let caught: unknown;
|
||||
try {
|
||||
validateNoSystemFonts(offending);
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(PlanValidationError);
|
||||
expect((caught as PlanValidationError).code).toBe(SYSTEM_FONT_USED);
|
||||
expect((caught as PlanValidationError).code).toBe("SYSTEM_FONT_USED");
|
||||
expect((caught as Error).message).toContain(`"-apple-system"`);
|
||||
expect((caught as Error).message).toContain("font-family");
|
||||
});
|
||||
|
||||
it("throws SYSTEM_FONT_USED when primary family is `system-ui`", () => {
|
||||
const offending = `<div style="font-family: system-ui, sans-serif">text</div>`;
|
||||
let caught: unknown;
|
||||
try {
|
||||
validateNoSystemFonts(offending);
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(PlanValidationError);
|
||||
expect((caught as PlanValidationError).code).toBe(SYSTEM_FONT_USED);
|
||||
expect((caught as Error).message).toContain(`"system-ui"`);
|
||||
});
|
||||
|
||||
it("throws SYSTEM_FONT_USED when primary family is `sans-serif` (CSS generic alone)", () => {
|
||||
const offending = `<style>.x { font-family: sans-serif; }</style>`;
|
||||
let caught: unknown;
|
||||
try {
|
||||
validateNoSystemFonts(offending);
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(PlanValidationError);
|
||||
expect((caught as PlanValidationError).code).toBe(SYSTEM_FONT_USED);
|
||||
expect((caught as Error).message).toContain(`"sans-serif"`);
|
||||
});
|
||||
|
||||
it("treats data-font-family= as a valid surface for the same check", () => {
|
||||
const offending = `<h1 data-font-family="ui-monospace, monospace">hi</h1>`;
|
||||
let caught: unknown;
|
||||
try {
|
||||
validateNoSystemFonts(offending);
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(PlanValidationError);
|
||||
expect((caught as PlanValidationError).code).toBe(SYSTEM_FONT_USED);
|
||||
expect((caught as Error).message).toContain("data-font-family");
|
||||
});
|
||||
|
||||
it("is case-insensitive (`SYSTEM-UI` is the same as `system-ui`)", () => {
|
||||
const offending = `<style>p { font-family: SYSTEM-UI, sans-serif; }</style>`;
|
||||
let caught: unknown;
|
||||
try {
|
||||
validateNoSystemFonts(offending);
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(PlanValidationError);
|
||||
expect((caught as PlanValidationError).code).toBe(SYSTEM_FONT_USED);
|
||||
});
|
||||
|
||||
it("accepts generic families when used only as fallbacks", () => {
|
||||
// The whole point: `font-family: "Inter", -apple-system, sans-serif` is
|
||||
// the canonical fallback chain. We want this to pass.
|
||||
const ok = `<style>body { font-family: "Inter", -apple-system, BlinkMacSystemFont, sans-serif; }</style>`;
|
||||
expect(() => validateNoSystemFonts(ok)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
import { BROWSER_GPU_NOT_SOFTWARE } from "@hyperframes/engine";
|
||||
import { GENERIC_FAMILIES, iterateFontFamilyDeclarations } from "../deterministicFonts.js";
|
||||
|
||||
/**
|
||||
* Re-export the BROWSER_GPU_NOT_SOFTWARE code so distributed adapters and
|
||||
@@ -14,6 +15,13 @@ import { BROWSER_GPU_NOT_SOFTWARE } from "@hyperframes/engine";
|
||||
*/
|
||||
export { BROWSER_GPU_NOT_SOFTWARE } from "@hyperframes/engine";
|
||||
|
||||
/**
|
||||
* Re-export the shared font-family parser. The plan-time validator and the
|
||||
* @font-face injector consume the same surface, so the parser lives next to
|
||||
* the data.
|
||||
*/
|
||||
export { parseFontFamilyValue } from "../deterministicFonts.js";
|
||||
|
||||
/**
|
||||
* Typed plan-validation error. Workflow adapters key retry policies off the
|
||||
* `code` field to mark errors as non-retryable.
|
||||
@@ -46,6 +54,14 @@ export interface ValidateNoGpuEncodeInput {
|
||||
browserGpuMode?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Typed code for {@link validateNoSystemFonts}. Distributed chunk workers
|
||||
* render in a Linux container without host-OS fonts; compositions declaring
|
||||
* `-apple-system` / `system-ui` as a primary family would render differently
|
||||
* on the worker, breaking byte-identical retries.
|
||||
*/
|
||||
export const SYSTEM_FONT_USED = "SYSTEM_FONT_USED";
|
||||
|
||||
/**
|
||||
* Reject any config that would let GPU encode or hardware-GL slip into a
|
||||
* distributed render. Throws {@link PlanValidationError} with
|
||||
@@ -74,3 +90,34 @@ export function validateNoGpuEncode(config: ValidateNoGpuEncodeInput): void {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject a compiled HTML document whose top-priority font-family resolves to
|
||||
* a host-OS / generic family. Throws {@link PlanValidationError} with
|
||||
* `code === SYSTEM_FONT_USED` and the offending family in the message.
|
||||
*
|
||||
* Inspects the FIRST entry of each font-family declaration: that's the
|
||||
* family the browser tries to use. Subsequent entries are CSS fallbacks,
|
||||
* and a generic fallback is fine and conventional — so
|
||||
* `font-family: "Inter", -apple-system, sans-serif` passes and
|
||||
* `font-family: -apple-system, BlinkMacSystemFont, "Segoe UI"` fails.
|
||||
*
|
||||
* Reads font-family surfaces via `iterateFontFamilyDeclarations` so the
|
||||
* @font-face injector and this validator scan the same regions.
|
||||
*/
|
||||
export function validateNoSystemFonts(compiledHtml: string): void {
|
||||
for (const { surface, declaration, families } of iterateFontFamilyDeclarations(compiledHtml)) {
|
||||
if (families.length === 0) continue;
|
||||
const primaryRaw = families[0]!;
|
||||
if (!GENERIC_FAMILIES.has(primaryRaw.toLowerCase())) continue;
|
||||
throw new PlanValidationError(
|
||||
SYSTEM_FONT_USED,
|
||||
`[planValidation] Composition declares a host-OS / generic primary ${surface}: ` +
|
||||
`${JSON.stringify(primaryRaw)} (full declaration: ${JSON.stringify(declaration.trim())}). ` +
|
||||
`Distributed chunk workers render in a Linux container and cannot produce byte-identical ` +
|
||||
`output for fonts that resolve to host system installations. Use a deterministic web font ` +
|
||||
`(e.g. Inter, Montserrat, or another @fontsource family) as the primary family; generic ` +
|
||||
`names like "sans-serif" / "-apple-system" / "system-ui" are only allowed as fallbacks.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user