mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
Merge pull request #3492 from heygen-com/fix/font-subset-css-case-transform
fix(fonts): unify preview and render font resolution
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { runtimeVersionError } from "../dist/runtimeVersion.js";
|
||||
|
||||
const error = runtimeVersionError(process.versions.node);
|
||||
if (error) {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
const { main } = await import("../dist/fontLocalizeCli.js");
|
||||
process.exitCode = await main();
|
||||
}
|
||||
@@ -9,7 +9,8 @@
|
||||
"directory": "packages/cli"
|
||||
},
|
||||
"bin": {
|
||||
"hyperframes": "./bin/hyperframes.mjs"
|
||||
"hyperframes": "./bin/hyperframes.mjs",
|
||||
"hyperframes-localize-fonts": "./bin/hyperframes-localize-fonts.mjs"
|
||||
},
|
||||
"files": [
|
||||
"bin",
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { runFontLocalize, stampFontVersions, type FontLocalizeIo } from "./fontLocalize.js";
|
||||
|
||||
function makeIo(input: string): {
|
||||
io: FontLocalizeIo;
|
||||
output: string[];
|
||||
errors: string[];
|
||||
} {
|
||||
const output: string[] = [];
|
||||
const errors: string[] = [];
|
||||
return {
|
||||
io: {
|
||||
readInput: async () => input,
|
||||
writeOutput: (value) => output.push(value),
|
||||
writeError: (value) => errors.push(value),
|
||||
},
|
||||
output,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
describe("runFontLocalize", () => {
|
||||
it("writes only the localized document to stdout", async () => {
|
||||
const harness = makeIo("<html>source</html>");
|
||||
const localize = vi.fn(async () => "<html>localized</html>");
|
||||
|
||||
const exitCode = await runFontLocalize(harness.io, localize);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(localize).toHaveBeenCalledWith("<html>source</html>");
|
||||
expect(harness.output).toEqual(["<html>localized</html>"]);
|
||||
expect(harness.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects blank input without calling the resolver", async () => {
|
||||
const harness = makeIo(" \n");
|
||||
const localize = vi.fn(async (html: string) => html);
|
||||
|
||||
const exitCode = await runFontLocalize(harness.io, localize);
|
||||
|
||||
expect(exitCode).toBe(2);
|
||||
expect(localize).not.toHaveBeenCalled();
|
||||
expect(harness.output).toEqual([]);
|
||||
expect(harness.errors.join(" ")).toContain("input is empty");
|
||||
});
|
||||
|
||||
it("fails without echoing source HTML or resolver details", async () => {
|
||||
const source = '<html><img src="https://signed.example/secret"></html>';
|
||||
const harness = makeIo(source);
|
||||
const localize = vi.fn(async () => {
|
||||
throw new Error(`fetch failed for ${source}`);
|
||||
});
|
||||
|
||||
const exitCode = await runFontLocalize(harness.io, localize);
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(harness.output).toEqual([]);
|
||||
expect(harness.errors.join(" ")).toContain("font localization failed (Error)");
|
||||
expect(harness.errors.join(" ")).not.toContain("signed.example");
|
||||
expect(harness.errors.join(" ")).not.toContain("<html>");
|
||||
});
|
||||
|
||||
it("fails closed when the resolver returns an empty document", async () => {
|
||||
const harness = makeIo("<html>source</html>");
|
||||
|
||||
const exitCode = await runFontLocalize(harness.io, async () => "\n");
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(harness.output).toEqual([]);
|
||||
expect(harness.errors.join(" ")).toContain("empty output");
|
||||
});
|
||||
});
|
||||
|
||||
describe("stampFontVersions", () => {
|
||||
it("records producer and localizer versions inside the document head", () => {
|
||||
const stamped = stampFontVersions(
|
||||
"<!doctype html><html><head><title>x</title></head><body></body></html>",
|
||||
{ producer: "0.8.15", localizer: "0.8.16" },
|
||||
);
|
||||
|
||||
expect(stamped).toContain('<meta name="hyperframes-font-compiler-version" content="0.8.15">');
|
||||
expect(stamped).toContain('<meta name="hyperframes-font-localizer-version" content="0.8.16">');
|
||||
expect(stamped.indexOf("hyperframes-font-compiler-version")).toBeLessThan(
|
||||
stamped.indexOf("</head>"),
|
||||
);
|
||||
});
|
||||
|
||||
it("inserts both diagnostic stamps after a doctype when no head close exists", () => {
|
||||
const stamped = stampFontVersions("<!doctype html><main>x</main>", {
|
||||
producer: "0.8.15",
|
||||
localizer: "0.8.16",
|
||||
});
|
||||
|
||||
expect(stamped).toMatch(
|
||||
/^<!doctype html><meta name="hyperframes-font-compiler-version" content="0\.8\.15"><meta name="hyperframes-font-localizer-version" content="0\.8\.16">/,
|
||||
);
|
||||
});
|
||||
|
||||
it("inserts both diagnostic stamps at the start when no head or doctype exists", () => {
|
||||
const stamped = stampFontVersions("<main>x</main>", {
|
||||
producer: "0.8.15<script>",
|
||||
localizer: "0.8.16<script>",
|
||||
});
|
||||
|
||||
expect(stamped).toBe(
|
||||
'<meta name="hyperframes-font-compiler-version" content="0.8.15script"><meta name="hyperframes-font-localizer-version" content="0.8.16script"><main>x</main>',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
export interface FontLocalizeIo {
|
||||
readInput(): Promise<string>;
|
||||
writeOutput(value: string): void;
|
||||
writeError(value: string): void;
|
||||
}
|
||||
|
||||
export interface FontVersions {
|
||||
producer: string;
|
||||
localizer: string;
|
||||
}
|
||||
|
||||
function safeVersion(version: string): string {
|
||||
return version.replace(/[^A-Za-z0-9.+-]/g, "") || "unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* Add post-hoc diagnostics for the producer resolver and the CLI wrapper that ran it.
|
||||
* These stamps are traceability metadata, not an enforcement mechanism.
|
||||
*/
|
||||
export function stampFontVersions(html: string, versions: FontVersions): string {
|
||||
const tags =
|
||||
`<meta name="hyperframes-font-compiler-version" content="${safeVersion(versions.producer)}">` +
|
||||
`<meta name="hyperframes-font-localizer-version" content="${safeVersion(versions.localizer)}">`;
|
||||
const headClose = html.search(/<\/head\s*>/i);
|
||||
if (headClose >= 0) return `${html.slice(0, headClose)}${tags}${html.slice(headClose)}`;
|
||||
const doctype = /^\s*<!doctype[^>]*>/i.exec(html);
|
||||
if (!doctype) return `${tags}${html}`;
|
||||
const insertAt = doctype.index + doctype[0].length;
|
||||
return `${html.slice(0, insertAt)}${tags}${html.slice(insertAt)}`;
|
||||
}
|
||||
|
||||
function safeErrorName(error: unknown): string {
|
||||
const name = error instanceof Error ? error.name : "UnknownError";
|
||||
return /^[A-Za-z][A-Za-z0-9]*$/.test(name) ? name : "Error";
|
||||
}
|
||||
|
||||
/**
|
||||
* Machine-only stdin/stdout boundary for deterministic font localization.
|
||||
* Source HTML and resolver messages can contain signed URLs, so failures emit
|
||||
* only a fixed category plus a sanitized error class.
|
||||
*/
|
||||
export async function runFontLocalize(
|
||||
io: FontLocalizeIo,
|
||||
localize: (html: string) => Promise<string>,
|
||||
): Promise<number> {
|
||||
const html = await io.readInput();
|
||||
if (!html.trim()) {
|
||||
io.writeError("font localization input is empty\n");
|
||||
return 2;
|
||||
}
|
||||
|
||||
try {
|
||||
const localized = await localize(html);
|
||||
if (!localized.trim()) {
|
||||
io.writeError("font localization failed (Error): empty output\n");
|
||||
return 1;
|
||||
}
|
||||
io.writeOutput(localized);
|
||||
return 0;
|
||||
} catch (error) {
|
||||
io.writeError(`font localization failed (${safeErrorName(error)})\n`);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// fallow-ignore-file unused-file
|
||||
import { injectDeterministicFontFaces } from "@hyperframes/producer";
|
||||
import { runFontLocalize, stampFontVersions } from "./fontLocalize.js";
|
||||
import { PRODUCER_VERSION, VERSION } from "./version.js";
|
||||
|
||||
async function readStdin(): Promise<string> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of process.stdin) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
}
|
||||
return Buffer.concat(chunks).toString("utf8");
|
||||
}
|
||||
|
||||
/** Standalone-entry main; the bin wrapper owns the actual process exit code. */
|
||||
export async function main(): Promise<number> {
|
||||
return runFontLocalize(
|
||||
{
|
||||
readInput: readStdin,
|
||||
writeOutput: (value) => process.stdout.write(value),
|
||||
writeError: (value) => process.stderr.write(value),
|
||||
},
|
||||
async (html) => {
|
||||
const localized = await injectDeterministicFontFaces(html, {
|
||||
failClosedFontFetch: true,
|
||||
allowSystemFontCapture: false,
|
||||
});
|
||||
return stampFontVersions(localized, {
|
||||
producer: PRODUCER_VERSION,
|
||||
localizer: VERSION,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,2 +1,5 @@
|
||||
declare const __CLI_VERSION__: string | undefined;
|
||||
declare const __PRODUCER_VERSION__: string | undefined;
|
||||
export const VERSION = typeof __CLI_VERSION__ !== "undefined" ? __CLI_VERSION__ : "0.0.0-dev";
|
||||
export const PRODUCER_VERSION =
|
||||
typeof __PRODUCER_VERSION__ !== "undefined" ? __PRODUCER_VERSION__ : "0.0.0-dev";
|
||||
|
||||
@@ -6,10 +6,14 @@ import { sourceAliases } from "../../scripts/package-subpaths.mjs";
|
||||
const pkg = JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf-8")) as {
|
||||
version: string;
|
||||
};
|
||||
const producerPkg = JSON.parse(
|
||||
readFileSync(new URL("../producer/package.json", import.meta.url), "utf-8"),
|
||||
) as { version: string };
|
||||
|
||||
export default defineConfig({
|
||||
entry: {
|
||||
cli: "src/cli.ts",
|
||||
fontLocalizeCli: "src/fontLocalizeCli.ts",
|
||||
runtimeVersion: "src/runtimeVersion.ts",
|
||||
shaderTransitionWorker: "../producer/src/services/shaderTransitionWorker.ts",
|
||||
},
|
||||
@@ -79,6 +83,7 @@ var __dirname = __hf_dirname(__filename);`,
|
||||
],
|
||||
define: {
|
||||
__CLI_VERSION__: JSON.stringify(pkg.version),
|
||||
__PRODUCER_VERSION__: JSON.stringify(producerPkg.version),
|
||||
},
|
||||
esbuildOptions(options) {
|
||||
options.alias = {
|
||||
|
||||
@@ -198,6 +198,31 @@ describe("injectDeterministicFontFaces — failClosedFontFetch: true", () => {
|
||||
expect((caught as FontFetchError).code).toBe(FONT_FETCH_FAILED);
|
||||
});
|
||||
|
||||
it("fails closed when a secondary family in the authored cascade is unresolved", async () => {
|
||||
const html = `<!doctype html><html><head><style>
|
||||
body { font-family: "Inter", "Author Custom Fallback", sans-serif; }
|
||||
</style></head><body><p>hello</p></body></html>`;
|
||||
const fetchImpl = (async (input: string | URL | Request) => {
|
||||
const family = new URL(input instanceof Request ? input.url : String(input)).searchParams.get(
|
||||
"family",
|
||||
);
|
||||
return family?.startsWith("Inter:")
|
||||
? new Response("/* bundled Inter is sufficient */", { status: 200 })
|
||||
: new Response("", { status: 400 });
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const caught = await rejectedError(
|
||||
injectDeterministicFontFaces(html, {
|
||||
failClosedFontFetch: true,
|
||||
allowSystemFontCapture: false,
|
||||
fetchImpl,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(caught).toBeInstanceOf(FontFetchError);
|
||||
expect((caught as FontFetchError).familyName).toContain("Author Custom Fallback");
|
||||
});
|
||||
|
||||
it("throws FontFetchUnavailableError on an exhausted 5xx response", async () => {
|
||||
const caught = await rejectedError(
|
||||
injectDeterministicFontFaces(HTML_REQUESTING_UNRESOLVED_FONT, {
|
||||
|
||||
@@ -1,22 +1,28 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { injectDeterministicFontFaces } from "./deterministicFonts.js";
|
||||
|
||||
async function requestedGoogleFontUrl(html: string): Promise<URL> {
|
||||
let requestedUrl = "";
|
||||
const fetchImpl = (async (input: unknown) => {
|
||||
requestedUrl = String(input);
|
||||
return new Response("", { status: 400 });
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
await injectDeterministicFontFaces(html, {
|
||||
fetchImpl,
|
||||
allowSystemFontCapture: false,
|
||||
});
|
||||
return new URL(requestedUrl);
|
||||
}
|
||||
|
||||
describe("Google Fonts text subsetting", () => {
|
||||
it("sends the composition character set to the CSS API", async () => {
|
||||
let requestedUrl = "";
|
||||
const fetchImpl = (async (input: unknown) => {
|
||||
requestedUrl = String(input);
|
||||
return new Response("", { status: 400 });
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
await injectDeterministicFontFaces(
|
||||
const url = await requestedGoogleFontUrl(
|
||||
`<!doctype html><html><head><style>
|
||||
h1 { font-family: "Noto Performance Test", sans-serif; }
|
||||
</style></head><body><h1>旅行ランキング</h1></body></html>`,
|
||||
{ fetchImpl, allowSystemFontCapture: false },
|
||||
);
|
||||
|
||||
const url = new URL(requestedUrl);
|
||||
const text = url.searchParams.get("text") ?? "";
|
||||
for (const character of new Set("旅行ランキング")) {
|
||||
expect(text).toContain(character);
|
||||
@@ -24,19 +30,55 @@ describe("Google Fonts text subsetting", () => {
|
||||
});
|
||||
|
||||
it("includes decoded HTML entities from visible composition text", async () => {
|
||||
let requestedUrl = "";
|
||||
const fetchImpl = (async (input: unknown) => {
|
||||
requestedUrl = String(input);
|
||||
return new Response("", { status: 400 });
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
await injectDeterministicFontFaces(
|
||||
const url = await requestedGoogleFontUrl(
|
||||
`<!doctype html><html><head><style>
|
||||
h1 { font-family: "Noto Performance Test", sans-serif; }
|
||||
</style></head><body><h1>旅行</h1></body></html>`,
|
||||
{ fetchImpl, allowSystemFontCapture: false },
|
||||
);
|
||||
|
||||
expect(new URL(requestedUrl).searchParams.get("text")).toContain("旅行");
|
||||
expect(url.searchParams.get("text")).toContain("旅行");
|
||||
});
|
||||
|
||||
it("includes case variants for transformed supplemental alias weights", async () => {
|
||||
const url = await requestedGoogleFontUrl(
|
||||
`<!doctype html><html><head><style>
|
||||
h1 { font-family: "Inter", sans-serif; font-weight: 800; text-transform: uppercase; }
|
||||
</style></head><body><h1>Your Kidney Transplant:<br/>What Happens Next</h1></body></html>`,
|
||||
);
|
||||
|
||||
const text = url.searchParams.get("text") ?? "";
|
||||
expect(encodeURIComponent(text).length).toBeLessThan(700);
|
||||
for (const character of new Set("YOUR KIDNEY TRANSPLANT:WHAT HAPPENS NEXT")) {
|
||||
expect(text).toContain(character);
|
||||
}
|
||||
});
|
||||
|
||||
it("covers capitalized words through the same case closure", async () => {
|
||||
const url = await requestedGoogleFontUrl(
|
||||
`<!doctype html><html><head><style>
|
||||
h1 { font-family: "Inter", sans-serif; font-weight: 800; text-transform: capitalize; }
|
||||
</style></head><body><h1>hello world</h1></body></html>`,
|
||||
);
|
||||
|
||||
const text = url.searchParams.get("text") ?? "";
|
||||
expect(text).toContain("H");
|
||||
expect(text).toContain("W");
|
||||
});
|
||||
|
||||
it("falls back to the full font when case closure exceeds the text URL budget", async () => {
|
||||
const caseChangingCharacters = Array.from({ length: 0x500 }, (_, index) =>
|
||||
String.fromCodePoint(index),
|
||||
)
|
||||
.filter((character) => character.toUpperCase() !== character.toLowerCase())
|
||||
.slice(0, 300)
|
||||
.join("");
|
||||
|
||||
const url = await requestedGoogleFontUrl(
|
||||
`<!doctype html><html><head><style>
|
||||
p { font-family: "Inter", sans-serif; font-weight: 800; text-transform: uppercase; }
|
||||
</style></head><body><p>${caseChangingCharacters}</p></body></html>`,
|
||||
);
|
||||
|
||||
expect(url.searchParams.has("text")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1193,18 +1193,30 @@ export interface InjectDeterministicFontFacesOptions {
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Using unique source/decoded characters plus deterministic case variants covers
|
||||
// static text, strings authored in scripts, and CSS case transforms 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
|
||||
// Source + decoded text is an intentional over-approximation: base64, scripts, and class names
|
||||
// collapse in the Set, while decoded entities contribute the glyphs the browser actually paints.
|
||||
const characters = [...Array.from(html), ...Array.from(decodedBodyText)];
|
||||
const uniqueCharacters = new Set<string>();
|
||||
for (const character of characters) {
|
||||
uniqueCharacters.add(character);
|
||||
// This closes locale-independent Unicode casing, including multi-code-point expansions such as
|
||||
// ß -> SS. Locale/context transforms (for example Turkish İ) and CSS full-width/full-size-kana
|
||||
// need a transform-aware follow-up rather than pretending this code-point closure is exhaustive.
|
||||
for (const variant of `${character.toUpperCase()}${character.toLowerCase()}`) {
|
||||
uniqueCharacters.add(variant);
|
||||
}
|
||||
}
|
||||
const fontText = [...uniqueCharacters].join("");
|
||||
return encodeURIComponent(fontText).length <= GOOGLE_FONTS_TEXT_MAX_ENCODED_LENGTH
|
||||
? fontText
|
||||
: undefined;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user