mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +00:00
fix(producer): normalize system-primary font stacks (#1857)
This commit is contained in:
@@ -192,11 +192,9 @@ describe("injectDeterministicFontFaces — failClosedFontFetch: true", () => {
|
|||||||
expect(result).toContain("data-hyperframes-deterministic-fonts");
|
expect(result).toContain("data-hyperframes-deterministic-fonts");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does NOT throw when font-family uses CSS var() references", async () => {
|
it("does NOT throw when font-family uses unresolved CSS var() references", async () => {
|
||||||
const html = `<!doctype html><html><head><style>
|
const html = `<!doctype html><html><head><style>
|
||||||
:root { --ui-font: "Inter"; --vowel-font: "Montserrat"; }
|
body { font-family: var(--missing-font), sans-serif; }
|
||||||
body { font-family: var(--ui-font), sans-serif; }
|
|
||||||
h1 { font-family: var(--vowel-font), serif; }
|
|
||||||
</style></head><body><h1>hello</h1></body></html>`;
|
</style></head><body><h1>hello</h1></body></html>`;
|
||||||
const result = await injectDeterministicFontFaces(html, {
|
const result = await injectDeterministicFontFaces(html, {
|
||||||
failClosedFontFetch: true,
|
failClosedFontFetch: true,
|
||||||
@@ -205,6 +203,21 @@ describe("injectDeterministicFontFaces — failClosedFontFetch: true", () => {
|
|||||||
expect(result).toBe(html);
|
expect(result).toBe(html);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("resolves simple CSS var() font aliases when injecting deterministic fonts", async () => {
|
||||||
|
const html = `<!doctype html><html><head><style>
|
||||||
|
:root { --ui-font: "Inter"; --vowel-font: "Montserrat"; }
|
||||||
|
body { font-family: var(--ui-font), sans-serif; }
|
||||||
|
h1 { font-family: var(--vowel-font), serif; }
|
||||||
|
</style></head><body><h1>hello</h1></body></html>`;
|
||||||
|
const fetchImpl = (async () =>
|
||||||
|
new Response("/* no extra faces */", { status: 200 })) as unknown as typeof fetch;
|
||||||
|
const result = await injectDeterministicFontFaces(html, {
|
||||||
|
failClosedFontFetch: true,
|
||||||
|
fetchImpl,
|
||||||
|
});
|
||||||
|
expect(result).toContain("data-hyperframes-deterministic-fonts");
|
||||||
|
});
|
||||||
|
|
||||||
it("still resolves concrete fonts alongside var() in mixed declarations", async () => {
|
it("still resolves concrete fonts alongside var() in mixed declarations", async () => {
|
||||||
const html = `<!doctype html><html><head><style>
|
const html = `<!doctype html><html><head><style>
|
||||||
body { font-family: var(--ui-font), "Inter", sans-serif; }
|
body { font-family: var(--ui-font), "Inter", sans-serif; }
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
SYSTEM_FONT_SIZE_LIMIT,
|
SYSTEM_FONT_SIZE_LIMIT,
|
||||||
} from "@hyperframes/core/fonts/system-locator";
|
} from "@hyperframes/core/fonts/system-locator";
|
||||||
import { parseHTML } from "linkedom";
|
import { parseHTML } from "linkedom";
|
||||||
|
import postcss, { type AtRule, type Declaration, type Rule } from "postcss";
|
||||||
import { EMBEDDED_FONT_DATA } from "./fontData.generated.js";
|
import { EMBEDDED_FONT_DATA } from "./fontData.generated.js";
|
||||||
import { fontToDataUri } from "./fontCompression.js";
|
import { fontToDataUri } from "./fontCompression.js";
|
||||||
|
|
||||||
@@ -61,9 +62,217 @@ export function parseFontFamilyValue(value: string): string[] {
|
|||||||
.filter((piece) => piece.length > 0);
|
.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. */
|
/** Surfaces font-family is declared on in served HTML. */
|
||||||
export type FontFamilySurface = "font-family" | "data-font-family";
|
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
|
* Iterate every font-family declaration in a compiled HTML document. Yields
|
||||||
* each declaration's surface (CSS property vs HTML attribute), raw value,
|
* each declaration's surface (CSS property vs HTML attribute), raw value,
|
||||||
@@ -72,16 +281,20 @@ export type FontFamilySurface = "font-family" | "data-font-family";
|
|||||||
*/
|
*/
|
||||||
export function* iterateFontFamilyDeclarations(
|
export function* iterateFontFamilyDeclarations(
|
||||||
html: string,
|
html: string,
|
||||||
): Generator<{ surface: FontFamilySurface; declaration: string; families: string[] }, void, void> {
|
): Generator<FontFamilyDeclaration, void, void> {
|
||||||
const sources: ReadonlyArray<readonly [RegExp, FontFamilySurface]> = [
|
const { document } = parseHTML(html);
|
||||||
[/font-family\s*:\s*([^;}{]+)[;}]?/gi, "font-family"],
|
|
||||||
[/data-font-family=["']([^"']+)["']/gi, "data-font-family"],
|
for (const styleEl of Array.from(document.querySelectorAll("style"))) {
|
||||||
];
|
yield* iterateCssFontFamilyDeclarations(styleEl.textContent ?? "");
|
||||||
for (const [regex, surface] of sources) {
|
}
|
||||||
for (const match of html.matchAll(regex)) {
|
|
||||||
const declaration = match[1] ?? "";
|
for (const el of Array.from(document.querySelectorAll("[style]"))) {
|
||||||
yield { surface, declaration, families: parseFontFamilyValue(declaration) };
|
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) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,8 +417,12 @@ function extractExistingFontFaces(html: string): Set<string> {
|
|||||||
|
|
||||||
function extractRequestedFontFamilies(html: string): Map<string, string> {
|
function extractRequestedFontFamilies(html: string): Map<string, string> {
|
||||||
const requested = new Map<string, string>();
|
const requested = new Map<string, string>();
|
||||||
for (const { families } of iterateFontFamilyDeclarations(html)) {
|
const customProperties = collectFontFamilyCustomProperties(html);
|
||||||
for (const originalCase of families) {
|
for (const { declaration } of iterateFontFamilyDeclarations(html)) {
|
||||||
|
for (const originalCase of resolveFontFamilyDeclarationFamilies(
|
||||||
|
declaration,
|
||||||
|
customProperties,
|
||||||
|
)) {
|
||||||
const normalized = originalCase.toLowerCase();
|
const normalized = originalCase.toLowerCase();
|
||||||
if (!normalized || GENERIC_FAMILIES.has(normalized)) continue;
|
if (!normalized || GENERIC_FAMILIES.has(normalized)) continue;
|
||||||
if (normalized.startsWith("var(")) continue;
|
if (normalized.startsWith("var(")) continue;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
localizeRemoteFontFaces,
|
localizeRemoteFontFaces,
|
||||||
recompileWithResolutions,
|
recompileWithResolutions,
|
||||||
} from "./htmlCompiler.js";
|
} from "./htmlCompiler.js";
|
||||||
|
import { validateNoSystemFonts } from "./render/planValidation.js";
|
||||||
|
|
||||||
// ── collectExternalAssets ──────────────────────────────────────────────────
|
// ── collectExternalAssets ──────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -678,6 +679,56 @@ describe("detectShaderTransitionUsage", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("system-primary font normalization", () => {
|
||||||
|
it("promotes Inter before system/generic primary stacks before distributed plan validation", async () => {
|
||||||
|
const projectDir = mkdtempSync(join(tmpdir(), "hf-system-primary-font-"));
|
||||||
|
writeFileSync(
|
||||||
|
join(projectDir, "index.html"),
|
||||||
|
`<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
:root { --system-font: -apple-system, BlinkMacSystemFont, sans-serif; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, sans-serif; }
|
||||||
|
.system-ui { font-family: system-ui, sans-serif; }
|
||||||
|
.var-font { font-family: var(--system-font), sans-serif; }
|
||||||
|
.deterministic { font-family: "Montserrat", system-ui, sans-serif; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div
|
||||||
|
data-composition-id="root"
|
||||||
|
data-width="640"
|
||||||
|
data-height="360"
|
||||||
|
data-duration="1"
|
||||||
|
data-font-family="ui-monospace, monospace"
|
||||||
|
style="--inline-system-font: system-ui, sans-serif; font-family: sans-serif"
|
||||||
|
>
|
||||||
|
<span class="var-font">Hello</span>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const compiled = await compileForRender(projectDir, join(projectDir, "index.html"), projectDir);
|
||||||
|
|
||||||
|
expect(() => validateNoSystemFonts(compiled.html)).not.toThrow();
|
||||||
|
const compact = compiled.html.replace(/\s+/g, "");
|
||||||
|
expect(compact).toContain("--system-font:Inter,-apple-system,BlinkMacSystemFont,sans-serif");
|
||||||
|
expect(compact).toContain("font-family:Inter,-apple-system,BlinkMacSystemFont,sans-serif");
|
||||||
|
expect(compact).toContain("font-family:Inter,system-ui,sans-serif");
|
||||||
|
expect(compact).toContain("font-family:var(--system-font),sans-serif");
|
||||||
|
expect(compact).toContain('font-family:"Montserrat",system-ui,sans-serif');
|
||||||
|
expect(compact).toContain('data-font-family="Inter,ui-monospace,monospace"');
|
||||||
|
expect(compact).toContain('data-hyperframes-deterministic-fonts="true"');
|
||||||
|
|
||||||
|
const { document } = parseHTML(compiled.html);
|
||||||
|
const rootStyle = document.querySelector('[data-composition-id="root"]')?.getAttribute("style");
|
||||||
|
expect(rootStyle).toContain("--inline-system-font: Inter, system-ui, sans-serif");
|
||||||
|
expect(rootStyle).toContain("font-family: Inter, sans-serif");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("template-wrapped sub-composition media offsets", () => {
|
describe("template-wrapped sub-composition media offsets", () => {
|
||||||
function writeTemplateWrappedProject(
|
function writeTemplateWrappedProject(
|
||||||
hostAttrs: string,
|
hostAttrs: string,
|
||||||
|
|||||||
@@ -42,7 +42,10 @@ import {
|
|||||||
} from "@hyperframes/engine";
|
} from "@hyperframes/engine";
|
||||||
import { assertPublicHttpsUrl, downloadToTemp, isHttpUrl } from "../utils/urlDownloader.js";
|
import { assertPublicHttpsUrl, downloadToTemp, isHttpUrl } from "../utils/urlDownloader.js";
|
||||||
import type { Page } from "puppeteer-core";
|
import type { Page } from "puppeteer-core";
|
||||||
import { injectDeterministicFontFaces } from "./deterministicFonts.js";
|
import {
|
||||||
|
injectDeterministicFontFaces,
|
||||||
|
normalizeSystemFontPrimaryFamilies,
|
||||||
|
} from "./deterministicFonts.js";
|
||||||
import { prepareAnimatedGifInputs } from "./animatedGifPrep.js";
|
import { prepareAnimatedGifInputs } from "./animatedGifPrep.js";
|
||||||
import { createStudioPositionSeekReapplyScript } from "@hyperframes/studio-server/manual-edits-render-script";
|
import { createStudioPositionSeekReapplyScript } from "@hyperframes/studio-server/manual-edits-render-script";
|
||||||
import { defaultLogger, type ProducerLogger } from "../logger.js";
|
import { defaultLogger, type ProducerLogger } from "../logger.js";
|
||||||
@@ -1548,16 +1551,17 @@ export async function compileForRender(
|
|||||||
const renderModeHints = detectRenderModeHints(sanitizedHtml);
|
const renderModeHints = detectRenderModeHints(sanitizedHtml);
|
||||||
const hasShaderTransitions = detectShaderTransitionUsage(sanitizedHtml);
|
const hasShaderTransitions = detectShaderTransitionUsage(sanitizedHtml);
|
||||||
|
|
||||||
const coalescedHtml = await injectDeterministicFontFaces(
|
const normalizedFontHtml = normalizeSystemFontPrimaryFamilies(
|
||||||
injectTextRenderingRule(
|
injectTextRenderingRule(
|
||||||
coalesceHeadStylesAndBodyScripts(promoteCssImportsToLinkTags(sanitizedHtml)),
|
coalesceHeadStylesAndBodyScripts(promoteCssImportsToLinkTags(sanitizedHtml)),
|
||||||
),
|
),
|
||||||
{
|
|
||||||
failClosedFontFetch: options.failClosedFontFetch === true,
|
|
||||||
allowSystemFontCapture: options.allowSystemFontCapture,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const coalescedHtml = await injectDeterministicFontFaces(normalizedFontHtml, {
|
||||||
|
failClosedFontFetch: options.failClosedFontFetch === true,
|
||||||
|
allowSystemFontCapture: options.allowSystemFontCapture,
|
||||||
|
});
|
||||||
|
|
||||||
// Download CDN scripts and inline them AFTER coalescing. This order matters:
|
// Download CDN scripts and inline them AFTER coalescing. This order matters:
|
||||||
// coalesceHeadStylesAndBodyScripts merges inline scripts and appends them at
|
// coalesceHeadStylesAndBodyScripts merges inline scripts and appends them at
|
||||||
// the end of <body>. If we inlined CDN scripts first, the GSAP library would
|
// the end of <body>. If we inlined CDN scripts first, the GSAP library would
|
||||||
|
|||||||
@@ -258,4 +258,28 @@ describe("validateNoSystemFonts", () => {
|
|||||||
const ok = `<style>body { font-family: "Inter", -apple-system, BlinkMacSystemFont, sans-serif; }</style>`;
|
const ok = `<style>body { font-family: "Inter", -apple-system, BlinkMacSystemFont, sans-serif; }</style>`;
|
||||||
expect(() => validateNoSystemFonts(ok)).not.toThrow();
|
expect(() => validateNoSystemFonts(ok)).not.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("resolves simple CSS var() primary aliases before rejecting system fonts", () => {
|
||||||
|
const offending = `<style>
|
||||||
|
:root { --ui-font: -apple-system, BlinkMacSystemFont, sans-serif; }
|
||||||
|
body { font-family: var(--ui-font), 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(`"-apple-system"`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts CSS var() primary aliases that resolve to deterministic fonts", () => {
|
||||||
|
const ok = `<style>
|
||||||
|
:root { --ui-font: "Inter", -apple-system, sans-serif; }
|
||||||
|
body { font-family: var(--ui-font), sans-serif; }
|
||||||
|
</style>`;
|
||||||
|
expect(() => validateNoSystemFonts(ok)).not.toThrow();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { BROWSER_GPU_NOT_SOFTWARE } from "@hyperframes/engine";
|
import { BROWSER_GPU_NOT_SOFTWARE } from "@hyperframes/engine";
|
||||||
import { GENERIC_FAMILIES, iterateFontFamilyDeclarations } from "../deterministicFonts.js";
|
import {
|
||||||
|
collectFontFamilyCustomProperties,
|
||||||
|
GENERIC_FAMILIES,
|
||||||
|
iterateFontFamilyDeclarations,
|
||||||
|
resolveFontFamilyDeclarationFamilies,
|
||||||
|
} from "../deterministicFonts.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Re-export the BROWSER_GPU_NOT_SOFTWARE code so distributed adapters and
|
* Re-export the BROWSER_GPU_NOT_SOFTWARE code so distributed adapters and
|
||||||
@@ -118,12 +123,13 @@ export function validateNoGpuEncode(config: ValidateNoGpuEncodeInput): void {
|
|||||||
* @font-face injector and this validator scan the same regions.
|
* @font-face injector and this validator scan the same regions.
|
||||||
*/
|
*/
|
||||||
export function validateNoSystemFonts(compiledHtml: string): void {
|
export function validateNoSystemFonts(compiledHtml: string): void {
|
||||||
for (const { surface, declaration, families } of iterateFontFamilyDeclarations(compiledHtml)) {
|
const customProperties = collectFontFamilyCustomProperties(compiledHtml);
|
||||||
|
for (const { surface, declaration } of iterateFontFamilyDeclarations(compiledHtml)) {
|
||||||
|
const families = resolveFontFamilyDeclarationFamilies(declaration, customProperties);
|
||||||
if (families.length === 0) continue;
|
if (families.length === 0) continue;
|
||||||
const primaryRaw = families[0]!;
|
const primaryRaw = families[0]!;
|
||||||
// TODO(#1654): var() as primary bypasses this check — the resolved value
|
// Unresolved var() primaries are left to the browser; resolved custom
|
||||||
// could be system-ui or undefined. Consider resolving :root definitions
|
// properties are checked above so common `--font: system-ui` aliases fail.
|
||||||
// or emitting a warning when primary is var().
|
|
||||||
if (!GENERIC_FAMILIES.has(primaryRaw.toLowerCase())) continue;
|
if (!GENERIC_FAMILIES.has(primaryRaw.toLowerCase())) continue;
|
||||||
throw new PlanValidationError(
|
throw new PlanValidationError(
|
||||||
SYSTEM_FONT_USED,
|
SYSTEM_FONT_USED,
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1,3 +1,3 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:a157fb1d4248a4661e5b6510b949a652f8d7d2e73af431175f6fff3911d7e647
|
oid sha256:0c2ee1b67cc6665a4b9a55da5e35a0b71c6b16acfe388c2104e01f5ffcf54807
|
||||||
size 355564
|
size 353004
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1,3 +1,3 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:2b84069be35430d72eced09c9b1e6cfd2c3a750c1a42f4332b99dfa54c516ddf
|
oid sha256:29f4050ec978ff7c8c35776d047c1f5474f4ac0a59f37c389c4358e4fd8370f0
|
||||||
size 1147085
|
size 1169572
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1,3 +1,3 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:34946fc5e0166dfefff4f278788e06b07a45855b5bd78aad1199283ccd9a6df8
|
oid sha256:eb3a6b30836d6fc79c1e9c55ad78baf663f5f5cf32d966343b5068bb84d16043
|
||||||
size 9684356
|
size 9691483
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1,3 +1,3 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:8014db458d78b0604f1175fb322839508207ff18d101e653b73cf5b4fd49ace1
|
oid sha256:b2344fe8cbd76870b11b5ec8fa3affc3d7ec0f52d82f5c9d4d01620f1caad0a3
|
||||||
size 10976927
|
size 10991291
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1,3 +1,3 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:c5e8c2e0e78255e039b4a032a9ffa11a40aaef32a84b8e7bf959ff91b8f030ce
|
oid sha256:dce9fac7c66c2792893b865a37866535ec282599e265e8d094349da30db573c4
|
||||||
size 30465373
|
size 30493913
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:47b2134791c4103e1e1feffcf968b3bc8a75cfe85b617a284ab37fc065b6949c
|
oid sha256:4bdabaa9c95d7999f40fc2090cd4928dd9549741b8ec1bec252183dc4f2ff118
|
||||||
size 161445
|
size 161400
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1,3 +1,3 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:4d92fb71390142fc666b94f4e3493722bf6461384796a1ebb13265d0a420238b
|
oid sha256:8f6d5ff248eb9bad32621466549fa46109cef4baeb31677e473dab51b197dacc
|
||||||
size 20997
|
size 29607
|
||||||
|
|||||||
Reference in New Issue
Block a user