diff --git a/src/lib/resolve-html-images.test.ts b/src/lib/resolve-html-images.test.ts index b35c110..8f93720 100644 --- a/src/lib/resolve-html-images.test.ts +++ b/src/lib/resolve-html-images.test.ts @@ -163,6 +163,38 @@ describe("embedHtmlImagesAsDataUri", () => { expect(matches?.length).toBe(3); }); + it("skips remaining images once total embedded bytes exceed the cap (#354)", async () => { + readFileBase64.mockClear(); + // TOTAL_EMBED_BYTES_LIMIT = 256MB。3 個の 100MB base64 を投げれば 3 個目で超過する想定 + const CHUNK = "x".repeat(100 * 1024 * 1024); + readFileBase64 + .mockResolvedValueOnce(CHUNK) + .mockResolvedValueOnce(CHUNK) + .mockResolvedValueOnce(CHUNK); + const out = await embedHtmlImagesAsDataUri( + '', + "/workspace/deck.md", + ); + // 1〜2 個目は data URI 化される (累積 200MB < 256MB) → data URI が 2 箇所出現 + expect(out.split("data:image/png;base64,").length - 1).toBe(2); + // 3 個目は上限超過で元 src を維持 + expect(out.includes('src="./c.png"')).toBe(true); + }); + + it("keeps original src when a single image would push over the total cap (#354)", async () => { + readFileBase64.mockClear(); + // 1 個目で 200MB を使い、2 個目 (100MB) で累積 300MB > 256MB になり skip + readFileBase64 + .mockResolvedValueOnce("y".repeat(200 * 1024 * 1024)) + .mockResolvedValueOnce("z".repeat(100 * 1024 * 1024)); + const out = await embedHtmlImagesAsDataUri( + '', + "/workspace/deck.md", + ); + expect(out.split("data:image/png;base64,").length - 1).toBe(1); + expect(out.includes('src="./over.png"')).toBe(true); + }); + it("caps in-flight readFileBase64 to EMBED_CONCURRENCY (bounded concurrency)", async () => { readFileBase64.mockClear(); let inFlight = 0; diff --git a/src/lib/resolve-html-images.ts b/src/lib/resolve-html-images.ts index 2e2d24e..df4b421 100644 --- a/src/lib/resolve-html-images.ts +++ b/src/lib/resolve-html-images.ts @@ -37,6 +37,13 @@ function extname(p: string): string { // ほぼ改善しない (K=4 で十分)。 const EMBED_CONCURRENCY = 4; +// exportAsHtml 経路で HTML 内に埋め込む base64 文字列の合計サイズ上限 (#354)。 +// 個別ファイルは main 側で 64MB に bound しているが、極端に画像が多い文書 (例: 500 +// 個の 63MB PNG) で累積が V8 heap を圧迫し renderer OOM を招くリスクが残っていた。 +// 累積が超過した以降の画像は data URI 化を skip し、元 src (scripta-asset URL 等) を +// 維持する。上限は base64 文字列長で 256MB (実画像換算 ~192MB)。 +const TOTAL_EMBED_BYTES_LIMIT = 256 * 1024 * 1024; + /** * `resolveHtmlImageSrcs` の async 版。ローカル画像を data URI で HTML に埋め込む (#314)。 * @@ -50,6 +57,9 @@ const EMBED_CONCURRENCY = 4; * 元の src を残す (broken image になるが export 自体は失敗させない) * - 同一 osPath は 1 回だけ read し、複数 img に data URI を再利用 (メモリ + IPC 節約) * - 並列度は EMBED_CONCURRENCY で bound (multi-image 悪意 md による OOM 抑止) + * - 埋め込み base64 の累積長が TOTAL_EMBED_BYTES_LIMIT を超えた以降は data URI 化せず + * 元 src を維持 (#354 renderer OOM 対策)。cap 到達時にどの画像が skip されるかは + * I/O 完了順に依存する */ export async function embedHtmlImagesAsDataUri( html: string, @@ -86,6 +96,7 @@ export async function embedHtmlImagesAsDataUri( // 頭打ちが出るので、queue から順に pull する worker 型を採用。 const queue = [...tasks.values()]; let next = 0; + let totalEmbeddedBytes = 0; const worker = async (): Promise => { while (true) { const i = next++; @@ -93,6 +104,11 @@ export async function embedHtmlImagesAsDataUri( const task = queue[i]; try { const b64 = await readFileBase64(task.osPath); + // 累積上限を超える場合は data URI 化せず元 src を維持 (broken image と同じ扱いで + // export 自体は完遂)。JS は単スレッドで check → += の間に await が無いため + // 他 worker が割り込む余地は無く、実オーバーシュートは発生しない。 + if (totalEmbeddedBytes + b64.length > TOTAL_EMBED_BYTES_LIMIT) continue; + totalEmbeddedBytes += b64.length; const dataUri = `data:${task.mime};base64,${b64}`; for (const target of task.targets) target.setAttribute("src", dataUri); } catch {