fix(producer): keep large local fonts file-backed (#2864)

* fix(producer): keep large local fonts file-backed

* fix(producer): avoid local font file races

* fix(producer): bound local font stream reads

* fix(producer): cache large font file-backed decisions
This commit is contained in:
Miguel Ángel
2026-07-29 22:11:54 +02:00
committed by GitHub
parent b7160f69bb
commit 87791fd01d
2 changed files with 90 additions and 5 deletions
@@ -892,6 +892,57 @@ describe("local font embedding", () => {
expect(embeddedMessages).toHaveLength(1);
});
it("keeps large local font collections file-backed instead of expanding them into HTML", async () => {
const projectDir = mkdtempSync(join(tmpdir(), "hf-large-local-font-"));
const assetsDir = join(projectDir, "assets");
mkdirSync(assetsDir, { recursive: true });
writeFileSync(join(assetsDir, "large.ttc"), Buffer.alloc(6 * 1024 * 1024, 0x41));
writeFileSync(
join(projectDir, "index.html"),
`<!DOCTYPE html>
<html><head><style>
@font-face {
font-family: "LargeLocal";
src: url("assets/large.ttc") format("collection");
}
@font-face {
font-family: "LargeLocalAlias";
src: url("./assets/large.ttc") format("collection");
}
@font-face {
font-family: "LargeLocalNormalized";
src: url("assets/../assets/large.ttc") format("collection");
}
</style></head><body>
<div data-composition-id="root" data-width="640" data-height="360" data-duration="1">
Text
</div>
</body></html>`,
);
const originalInfo = defaultLogger.info;
const fileBackedMessages: string[] = [];
defaultLogger.info = (message) => {
if (message.includes("Kept large local font file-backed")) {
fileBackedMessages.push(message);
}
};
let compiled: Awaited<ReturnType<typeof compileForRender>>;
try {
compiled = await compileForRender(projectDir, join(projectDir, "index.html"), projectDir);
} finally {
defaultLogger.info = originalInfo;
}
expect(compiled.html).toContain('url("assets/large.ttc")');
expect(compiled.html).toContain('url("./assets/large.ttc")');
expect(compiled.html).toContain('url("assets/../assets/large.ttc")');
expect(compiled.html).not.toContain("data:font/collection;base64,");
expect(Buffer.byteLength(compiled.html)).toBeLessThan(1024 * 1024);
expect(fileBackedMessages).toHaveLength(1);
});
});
describe("template-wrapped sub-composition media offsets", () => {
+39 -5
View File
@@ -10,7 +10,7 @@
* recursively extracting nested media from sub-sub-compositions.
*/
import { readFileSync, existsSync, mkdirSync } from "fs";
import { createReadStream, existsSync, mkdirSync, readFileSync } from "fs";
import { join, dirname, resolve, basename } from "path";
import { parseHTML } from "linkedom";
import {
@@ -1655,6 +1655,28 @@ export async function localizeRemoteFontFaces(
}
const LOCAL_FONTFACE_URL_RE = /url\(["']?(?!data:|https?:\/\/)([^"')]+)["']?\)/gi;
// Base64 expands bytes by ~33%, then immutable HTML replacements retain more
// string copies while compiling. Files up to and including 5 MiB remain inline;
// the first byte above that stays file-backed. This conservative ceiling keeps
// verified 19 MiB+ TTC collections out of the V8 heap while both local and
// distributed file servers continue serving project assets at authored paths.
const MAX_LOCAL_FONT_DATA_URI_BYTES = 5 * 1024 * 1024;
type LocalFontRead = { kind: "file-backed" } | { kind: "inline"; buffer: Buffer };
async function readLocalFont(absPath: string): Promise<LocalFontRead> {
const chunks: Buffer[] = [];
let totalBytes = 0;
for await (const chunk of createReadStream(absPath)) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
totalBytes += buffer.length;
if (totalBytes > MAX_LOCAL_FONT_DATA_URI_BYTES) {
return { kind: "file-backed" };
}
chunks.push(buffer);
}
return { kind: "inline", buffer: Buffer.concat(chunks, totalBytes) };
}
// fallow-ignore-next-line complexity
async function embedLocalFontFaces(html: string, projectDir: string): Promise<string> {
@@ -1664,6 +1686,7 @@ async function embedLocalFontFaces(html: string, projectDir: string): Promise<st
let result = html;
const embeddedPaths = new Set<string>();
const dataUriByAbsolutePath = new Map<string, string>();
const fileBackedAbsolutePaths = new Set<string>();
let styleMatch: RegExpExecArray | null;
while ((styleMatch = styleBlockRe.exec(html)) !== null) {
@@ -1679,16 +1702,27 @@ async function embedLocalFontFaces(html: string, projectDir: string): Promise<st
if (!localPath || embeddedPaths.has(localPath)) continue;
const absPath = localPath.startsWith("/") ? localPath : resolve(projectDir, localPath);
if (!isPathInside(absPath, projectDir)) continue;
if (!existsSync(absPath)) continue;
const ext = absPath.match(/\.(woff2?|ttf|otf|ttc)$/i)?.[1]?.toLowerCase() ?? "ttf";
try {
if (fileBackedAbsolutePaths.has(absPath)) {
embeddedPaths.add(localPath);
continue;
}
let dataUri = dataUriByAbsolutePath.get(absPath);
if (!dataUri) {
const buffer = readFileSync(absPath);
dataUri = await toDataUri(buffer, ext);
const font = await readLocalFont(absPath);
if (font.kind === "file-backed") {
fileBackedAbsolutePaths.add(absPath);
defaultLogger.info(
`[Compiler] Kept large local font file-backed: ${localPath} (> ${(MAX_LOCAL_FONT_DATA_URI_BYTES / 1024 / 1024).toFixed(1)} MB)`,
);
embeddedPaths.add(localPath);
continue;
}
dataUri = await toDataUri(font.buffer, ext);
dataUriByAbsolutePath.set(absPath, dataUri);
defaultLogger.info(
`[Compiler] Embedded local font file: ${localPath} (${(buffer.length / 1024).toFixed(0)} KB → data URI)`,
`[Compiler] Embedded local font file: ${localPath} (${(font.buffer.length / 1024).toFixed(0)} KB → data URI)`,
);
}
result = result.replaceAll(localPath, dataUri);