Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions src/lib/resolve-html-images.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
'<img src="./a.png"><img src="./b.png"><img src="./c.png">',
"/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(
'<img src="./big.png"><img src="./over.png">',
"/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;
Expand Down
16 changes: 16 additions & 0 deletions src/lib/resolve-html-images.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)。
*
Expand All @@ -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,
Expand Down Expand Up @@ -86,13 +96,19 @@ export async function embedHtmlImagesAsDataUri(
// 頭打ちが出るので、queue から順に pull する worker 型を採用。
const queue = [...tasks.values()];
let next = 0;
let totalEmbeddedBytes = 0;
const worker = async (): Promise<void> => {
while (true) {
const i = next++;
if (i >= queue.length) return;
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 {
Expand Down
Loading