Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions packages/cli/bin/hyperframes-localize-fonts.mjs
Original file line number Diff line number Diff line change
@@ -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();
}
3 changes: 2 additions & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
86 changes: 86 additions & 0 deletions packages/cli/src/fontLocalize.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { describe, expect, it, vi } from "vitest";
import { runFontLocalize, stampFontCompilerVersion, 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("stampFontCompilerVersion", () => {
it("records the compiler version inside the document head", () => {
const stamped = stampFontCompilerVersion(
"<!doctype html><html><head><title>x</title></head><body></body></html>",
"0.8.15",
);

expect(stamped).toContain('<meta name="hyperframes-font-compiler-version" content="0.8.15">');
expect(stamped.indexOf("hyperframes-font-compiler-version")).toBeLessThan(
stamped.indexOf("</head>"),
);
});
});
50 changes: 50 additions & 0 deletions packages/cli/src/fontLocalize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
export interface FontLocalizeIo {
readInput(): Promise<string>;
writeOutput(value: string): void;
writeError(value: string): void;
}

export function stampFontCompilerVersion(html: string, version: string): string {
const safeVersion = version.replace(/[^A-Za-z0-9.+-]/g, "") || "unknown";
const tag = `<meta name="hyperframes-font-compiler-version" content="${safeVersion}">`;
const headClose = html.search(/<\/head\s*>/i);
if (headClose >= 0) return `${html.slice(0, headClose)}${tag}${html.slice(headClose)}`;
const doctype = /^\s*<!doctype[^>]*>/i.exec(html);
if (!doctype) return `${tag}${html}`;
const insertAt = doctype.index + doctype[0].length;
return `${html.slice(0, insertAt)}${tag}${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;
}
}
30 changes: 30 additions & 0 deletions packages/cli/src/fontLocalizeCli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// fallow-ignore-file unused-file
import { injectDeterministicFontFaces } from "@hyperframes/producer";
import { runFontLocalize, stampFontCompilerVersion } from "./fontLocalize.js";
import { 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 stampFontCompilerVersion(localized, VERSION);
},
);
}
1 change: 1 addition & 0 deletions packages/cli/tsup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const pkg = JSON.parse(readFileSync(new URL("./package.json", import.meta.url),
export default defineConfig({
entry: {
cli: "src/cli.ts",
fontLocalizeCli: "src/fontLocalizeCli.ts",
runtimeVersion: "src/runtimeVersion.ts",
shaderTransitionWorker: "../producer/src/services/shaderTransitionWorker.ts",
},
Expand Down
Original file line number Diff line number Diff line change
@@ -1,42 +1,71 @@
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);
}
});

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>&#x65C5;&#34892;</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") ?? "";
for (const character of new Set("YOUR KIDNEY TRANSPLANT:WHAT HAPPENS NEXT")) {
expect(text).toContain(character);
}
});

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);
});
});
22 changes: 15 additions & 7 deletions packages/producer/src/services/deterministicFonts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1193,18 +1193,26 @@ 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
const characters = [...Array.from(html), ...Array.from(decodedBodyText)];
const uniqueCharacters = new Set<string>();
for (const character of characters) {
uniqueCharacters.add(character);
// CSS text-transform can render glyphs absent from the authored source.
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;
}

Expand Down
Loading