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);