mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(fonts): consolidate composition-aware compilation (#2406)
* fix(fonts): subset Google Fonts to composition text * fix(producer): skip slow TTC recompression * fix(fonts): include decoded composition text in subsets
This commit is contained in:
@@ -12,7 +12,7 @@
|
||||
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { existsSync } from "node:fs";
|
||||
import { injectDeterministicFontFaces } from "./deterministicFonts.js";
|
||||
import { fontFormatHint, injectDeterministicFontFaces } from "./deterministicFonts.js";
|
||||
|
||||
// A font family that is NOT in FONT_ALIASES but exists on macOS as
|
||||
// /System/Library/Fonts/Supplemental/Impact.ttf. When Google Fonts
|
||||
@@ -36,6 +36,11 @@ function makeHttp400Fetch(): typeof fetch {
|
||||
}
|
||||
|
||||
describe("system font capture — allowSystemFontCapture option", () => {
|
||||
it("uses the CSS collection hint for raw TTC data URIs", () => {
|
||||
expect(fontFormatHint("data:font/collection;base64,AA==")).toBe("collection");
|
||||
expect(fontFormatHint("data:font/woff2;base64,AA==")).toBe("woff2");
|
||||
});
|
||||
|
||||
it("does not attempt system font capture when allowSystemFontCapture is false", async () => {
|
||||
const html = makeHtml("NotARealFontFamilyForTest");
|
||||
const result = await injectDeterministicFontFaces(html, {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { injectDeterministicFontFaces } from "./deterministicFonts.js";
|
||||
|
||||
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(
|
||||
`<!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(
|
||||
`<!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("旅行");
|
||||
});
|
||||
});
|
||||
@@ -432,6 +432,10 @@ function extractRequestedFontFamilies(html: string): Map<string, string> {
|
||||
return requested;
|
||||
}
|
||||
|
||||
export function fontFormatHint(src: string): "collection" | "woff2" {
|
||||
return src.startsWith("data:font/collection;") ? "collection" : "woff2";
|
||||
}
|
||||
|
||||
function buildFontFaceRule(
|
||||
familyName: string,
|
||||
src: string,
|
||||
@@ -442,7 +446,7 @@ function buildFontFaceRule(
|
||||
return [
|
||||
"@font-face {",
|
||||
` font-family: "${familyName}";`,
|
||||
` src: url("${src}") format("woff2");`,
|
||||
` src: url("${src}") format("${fontFormatHint(src)}");`,
|
||||
` font-style: ${style};`,
|
||||
` font-weight: ${weight};`,
|
||||
" font-display: block;",
|
||||
@@ -456,6 +460,7 @@ function buildFontFaceRule(
|
||||
async function buildFontFaceCss(
|
||||
requestedFamilies: Map<string, string>,
|
||||
options: InternalFontFetchOptions,
|
||||
fontText?: string,
|
||||
): Promise<{
|
||||
css: string;
|
||||
unresolved: string[];
|
||||
@@ -483,7 +488,7 @@ async function buildFontFaceCss(
|
||||
// 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.
|
||||
const googleFaces = await fetchGoogleFont(originalCaseFamily, options);
|
||||
const googleFaces = await fetchGoogleFont(originalCaseFamily, options, fontText);
|
||||
for (const face of googleFaces) {
|
||||
// A weight covered by the embedded bundle is already full-coverage —
|
||||
// skip it. For weights the bundle lacks, add EVERY subset face (a
|
||||
@@ -503,7 +508,7 @@ async function buildFontFaceCss(
|
||||
}
|
||||
|
||||
// Path 2: fetch from Google Fonts (with local cache)
|
||||
const googleFaces = await fetchGoogleFont(originalCaseFamily, options);
|
||||
const googleFaces = await fetchGoogleFont(originalCaseFamily, options, fontText);
|
||||
if (googleFaces.length > 0) {
|
||||
for (const face of googleFaces) {
|
||||
rules.push(
|
||||
@@ -735,10 +740,12 @@ async function ensureWoff2DataUri(
|
||||
async function fetchGoogleFont(
|
||||
familyName: string,
|
||||
options: InternalFontFetchOptions,
|
||||
fontText?: string,
|
||||
): Promise<GoogleFontFace[]> {
|
||||
const slug = fontSlug(familyName);
|
||||
const encodedFamily = encodeURIComponent(familyName);
|
||||
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`;
|
||||
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 {
|
||||
@@ -844,6 +851,22 @@ export interface InjectDeterministicFontFacesOptions {
|
||||
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;
|
||||
}
|
||||
|
||||
export async function injectDeterministicFontFaces(
|
||||
html: string,
|
||||
options: InjectDeterministicFontFacesOptions = {},
|
||||
@@ -871,7 +894,11 @@ export async function injectDeterministicFontFaces(
|
||||
return html;
|
||||
}
|
||||
|
||||
const { css, unresolved } = await buildFontFaceCss(pendingFamilies, fetchOptions);
|
||||
const { css, unresolved } = await buildFontFaceCss(
|
||||
pendingFamilies,
|
||||
fetchOptions,
|
||||
extractGoogleFontsText(html),
|
||||
);
|
||||
if (unresolved.length > 0 && options.failClosedFontFetch) {
|
||||
throw new FontFetchError(
|
||||
unresolved.join(", "),
|
||||
|
||||
@@ -94,9 +94,17 @@ describe("fontToDataUri", () => {
|
||||
expect(uri).toMatch(/^data:font\/otf;base64,/);
|
||||
});
|
||||
|
||||
it("falls back with correct MIME type for ttc", async () => {
|
||||
const garbage = Buffer.from("not a font");
|
||||
const uri = await fontToDataUri(garbage, "ttc");
|
||||
expect(uri).toMatch(/^data:font\/collection;base64,/);
|
||||
it("embeds ttc collections raw without attempting slow woff2 compression", async () => {
|
||||
const warnings: unknown[][] = [];
|
||||
const originalWarn = console.warn;
|
||||
console.warn = (...args: unknown[]) => warnings.push(args);
|
||||
try {
|
||||
const raw = Buffer.from("ttc-bytes");
|
||||
const uri = await fontToDataUri(raw, "ttc");
|
||||
expect(uri).toBe(`data:font/collection;base64,${raw.toString("base64")}`);
|
||||
expect(warnings).toHaveLength(0);
|
||||
} finally {
|
||||
console.warn = originalWarn;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -82,6 +82,9 @@ export async function fontToDataUri(
|
||||
if (originalFormat === "woff2") {
|
||||
return `data:font/woff2;base64,${input.toString("base64")}`;
|
||||
}
|
||||
if (originalFormat === "ttc") {
|
||||
return `data:font/collection;base64,${input.toString("base64")}`;
|
||||
}
|
||||
try {
|
||||
const cachePath = cachedCompressionPath(
|
||||
input,
|
||||
|
||||
Reference in New Issue
Block a user