From 87791fd01d93ee82226bf09d98a4dfe339c42307 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 29 Jul 2026 22:11:54 +0200 Subject: [PATCH] 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 --- .../src/services/htmlCompiler.test.ts | 51 +++++++++++++++++++ .../producer/src/services/htmlCompiler.ts | 44 ++++++++++++++-- 2 files changed, 90 insertions(+), 5 deletions(-) diff --git a/packages/producer/src/services/htmlCompiler.test.ts b/packages/producer/src/services/htmlCompiler.test.ts index 443c509ce..8e24f92c9 100644 --- a/packages/producer/src/services/htmlCompiler.test.ts +++ b/packages/producer/src/services/htmlCompiler.test.ts @@ -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"), + ` + +
+ Text +
+`, + ); + + 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>; + 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", () => { diff --git a/packages/producer/src/services/htmlCompiler.ts b/packages/producer/src/services/htmlCompiler.ts index cf9157806..3e8736e44 100644 --- a/packages/producer/src/services/htmlCompiler.ts +++ b/packages/producer/src/services/htmlCompiler.ts @@ -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 { + 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 { @@ -1664,6 +1686,7 @@ async function embedLocalFontFaces(html: string, projectDir: string): Promise(); const dataUriByAbsolutePath = new Map(); + const fileBackedAbsolutePaths = new Set(); let styleMatch: RegExpExecArray | null; while ((styleMatch = styleBlockRe.exec(html)) !== null) { @@ -1679,16 +1702,27 @@ async function embedLocalFontFaces(html: string, projectDir: string): Promise ${(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);