diff --git a/e2e/helpers/electron-api-mock.ts b/e2e/helpers/electron-api-mock.ts index ee538dd..1a41047 100644 --- a/e2e/helpers/electron-api-mock.ts +++ b/e2e/helpers/electron-api-mock.ts @@ -416,6 +416,10 @@ function installApiMock(opts: { if (path in store.files) return store.files[path]; throw Object.assign(new Error(`File not found: ${path}`), { kind: "ENOENT" }); }, + readFileBase64: async (path: string): Promise => { + track("readFileBase64", [path]); + throw Object.assign(new Error(`File not found: ${path}`), { kind: "ENOENT" }); + }, writeFile: async (path: string, content: string): Promise => { track("writeFile", [path, content]); store.files[path] = content; diff --git a/electron/main/ipc/fs.test.ts b/electron/main/ipc/fs.test.ts index 4fdee7e..ff812db 100644 --- a/electron/main/ipc/fs.test.ts +++ b/electron/main/ipc/fs.test.ts @@ -27,6 +27,7 @@ const OTHER_WIN = 2; const { readFileImpl, + readFileBase64Impl, readFileBoundedFromHandle, writeFileImpl, writeNewFileImpl, @@ -108,6 +109,73 @@ describe("readFileImpl", () => { }); }); +describe("readFileBase64Impl (#314 data URI 埋め込み用)", () => { + it("reads a png as base64", async () => { + const path = join(workspaceDir, "hero.png"); + // 8-byte PNG magic + minimal payload + const bytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + await writeFile(path, bytes); + const b64 = await readFileBase64Impl(TEST_WIN, path); + expect(b64).toBe(bytes.toString("base64")); + expect(Buffer.from(b64, "base64")).toEqual(bytes); + }); + + it("reads a jpeg as base64", async () => { + const path = join(workspaceDir, "photo.jpg"); + const bytes = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]); + await writeFile(path, bytes); + const b64 = await readFileBase64Impl(TEST_WIN, path); + expect(Buffer.from(b64, "base64")).toEqual(bytes); + }); + + it("rejects non-image extensions (mp4)", async () => { + const path = join(workspaceDir, "video.mp4"); + await writeFile(path, Buffer.from([0x00])); + await expect(readFileBase64Impl(TEST_WIN, path)).rejects.toThrow(/unsupported extension/); + }); + + it("rejects extensionless files", async () => { + const path = join(workspaceDir, "README"); + await writeFile(path, Buffer.from([0x00])); + await expect(readFileBase64Impl(TEST_WIN, path)).rejects.toThrow(/unsupported extension/); + }); + + it("case-insensitive extension check (PNG)", async () => { + const path = join(workspaceDir, "A.PNG"); + const bytes = Buffer.from([0x89, 0x50]); + await writeFile(path, bytes); + const b64 = await readFileBase64Impl(TEST_WIN, path); + expect(Buffer.from(b64, "base64")).toEqual(bytes); + }); + + it("rejects paths outside workspace (path-guard)", async () => { + const outside = join(tmpdir(), "scripta-outside.png"); + await writeFile(outside, Buffer.from([0x00])); + try { + await expect(readFileBase64Impl(TEST_WIN, outside)).rejects.toThrow(/outside workspace/); + } finally { + await rm(outside, { force: true }); + } + }); + + it("rejects paths not registered by this window (window-scoped)", async () => { + const path = join(workspaceDir, "hero.png"); + await writeFile(path, Buffer.from([0x89, 0x50])); + await expect(readFileBase64Impl(OTHER_WIN, path)).rejects.toThrow(/outside workspace/); + }); + + it("rejects files exceeding MAX_READ_FILE_BYTES", async () => { + const path = join(workspaceDir, "huge.png"); + await createSparseFile(path, MAX_READ_FILE_BYTES + 1); + await expect(readFileBase64Impl(TEST_WIN, path)).rejects.toThrow(/too large|FILE_TOO_LARGE/i); + }); + + it("throws ENOENT for missing file", async () => { + const path = join(workspaceDir, "missing.png"); + await expect(readFileBase64Impl(TEST_WIN, path)).rejects.toThrow(/ENOENT/); + }); +}); + describe("readFileBoundedFromHandle (stat の信頼性に依存しない bounded read)", () => { // stat 申告と実 read を独立に制御する fake FileHandle。 // stat.size と read の data を別個に指定できるので、外部書込(file watcher 経路) diff --git a/electron/main/ipc/fs.ts b/electron/main/ipc/fs.ts index 6613eeb..47f6180 100644 --- a/electron/main/ipc/fs.ts +++ b/electron/main/ipc/fs.ts @@ -1,6 +1,7 @@ import { promises as fsp } from "node:fs"; -import { dirname, join, resolve } from "node:path"; +import { dirname, extname, join, resolve } from "node:path"; import { shell } from "electron"; +import { mimeForImageExt } from "../../../src/types/image"; import type { FileEntry } from "../../../src/types/workspace"; import { createEntryFilter } from "../utils/entry-filter"; import { FsError, isErrnoCode } from "../utils/fs-errors"; @@ -11,6 +12,7 @@ import { consumeTransientWritePath, findContainingWorkspaceRoot, } from "../utils/path-guard"; +import { StructuredError } from "../utils/structured-error"; import { getFileTreeFilterOptions } from "./settings"; // fs:read のサイズ上限。`.md` は通常 1MB 未満なので 64MB は十分なマージン。 @@ -87,6 +89,40 @@ async function readFileImpl(senderId: number, path: string): Promise { } } +// exportAsHtml の data URI 埋め込み用に、workspace 内の画像を base64 で読む (#314)。 +// - path-guard で workspace 外 read を拒否する (fs:read と同じ保証) +// - 拡張子を image ホワイトリストで絞る (mimeForImageExt が null → reject)。 +// 任意の binary を base64 で吸い上げられる能力を封じ、能力最小化する +// - サイズ上限は fs:read と共通 (64MB)。巨大画像は data URI 化しても外部ブラウザで +// 持たない (HTML file 自体が肥大化) ため、fail-loud で拒否するのが正解 +async function readFileBase64Impl(senderId: number, path: string): Promise { + const canonical = await assertPathAllowed(senderId, path); + if (mimeForImageExt(extname(canonical)) === null) { + throw new StructuredError( + "INVALID_PATH", + `readFileBase64: unsupported extension: ${extname(canonical) || "(none)"}`, + { path: canonical }, + ); + } + const fh = await fsp.open(canonical, "r"); + try { + const stat = await fh.stat(); + if (stat.size > MAX_READ_FILE_BYTES) { + throw FsError.tooLarge(canonical, stat.size, MAX_READ_FILE_BYTES); + } + const buf = Buffer.alloc(stat.size); + let total = 0; + while (total < stat.size) { + const { bytesRead } = await fh.read(buf, total, stat.size - total); + if (bytesRead === 0) break; + total += bytesRead; + } + return buf.subarray(0, total).toString("base64"); + } finally { + await fh.close(); + } +} + async function writeFileImpl(senderId: number, path: string, content: string): Promise { const canonical = await assertWritePathAllowed(senderId, path); await fsp.mkdir(dirname(canonical), { recursive: true }); @@ -212,6 +248,7 @@ async function deleteEntryImpl(senderId: number, path: string): Promise { export function registerFsIpc(): void { handle("fs:read", (event, path: string) => readFileImpl(event.sender.id, path)); + handle("fs:read-base64", (event, path: string) => readFileBase64Impl(event.sender.id, path)); handle("fs:write", (event, path: string, content: string) => writeFileImpl(event.sender.id, path, content), ); @@ -235,6 +272,7 @@ export function registerFsIpc(): void { export const __testing = { readFileImpl, + readFileBase64Impl, readFileBoundedFromHandle, writeFileImpl, writeNewFileImpl, diff --git a/electron/preload/api.ts b/electron/preload/api.ts index d52152e..2229283 100644 --- a/electron/preload/api.ts +++ b/electron/preload/api.ts @@ -38,6 +38,7 @@ export type Api = Readonly<{ workspaceSet: (path: string | null) => Promise; readFile: (path: string) => Promise; + readFileBase64: (path: string) => Promise; writeFile: (path: string, content: string) => Promise; writeNewFile: (path: string, content: string) => Promise; listDirectory: (path: string, opts?: ListDirectoryOptions) => Promise; diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 3894ebe..d9ab461 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -53,6 +53,7 @@ const api: Api = Object.freeze({ workspaceSet: (path) => invoke(ipcRenderer.invoke("workspace:set", path)), readFile: (path) => invoke(ipcRenderer.invoke("fs:read", path)), + readFileBase64: (path) => invoke(ipcRenderer.invoke("fs:read-base64", path)), writeFile: (path, content) => invoke(ipcRenderer.invoke("fs:write", path, content)), writeNewFile: (path, content) => invoke(ipcRenderer.invoke("fs:write-new", path, content)), listDirectory: (path, opts) => invoke(ipcRenderer.invoke("fs:list", path, opts)), diff --git a/src/__test-utils__/api-mock.ts b/src/__test-utils__/api-mock.ts index 3c90bf9..f2dd774 100644 --- a/src/__test-utils__/api-mock.ts +++ b/src/__test-utils__/api-mock.ts @@ -25,6 +25,7 @@ export function createApiMock(): Api { workspaceSet: vi.fn(async () => {}), readFile: vi.fn(async () => ""), + readFileBase64: vi.fn(async () => ""), writeFile: vi.fn(async () => {}), writeNewFile: vi.fn(async () => {}), listDirectory: vi.fn(async () => []), diff --git a/src/lib/commands.ts b/src/lib/commands.ts index 43c64a9..b178758 100644 --- a/src/lib/commands.ts +++ b/src/lib/commands.ts @@ -49,6 +49,14 @@ export function readFile(path: string): Promise { return withRetry(() => window.api.readFile(path)); } +/** + * ワークスペース内の画像ファイルを base64 で読む (exportAsHtml の data URI 埋め込み用、#314)。 + * main 側で拡張子ホワイトリスト + workspace 内 path guard により、任意 binary の吸い上げは不可。 + */ +export function readFileBase64(path: string): Promise { + return withRetry(() => window.api.readFileBase64(path)); +} + export function writeFile(path: string, content: string): Promise { return withRetry(() => window.api.writeFile(path, content)); } diff --git a/src/lib/export.test.ts b/src/lib/export.test.ts index da6e3f6..79d89ad 100644 --- a/src/lib/export.test.ts +++ b/src/lib/export.test.ts @@ -9,6 +9,9 @@ vi.mock("./commands", async () => { // resolveHtmlImageSrcs 経由の resolveImageSrc が呼ぶ。既存 image-src.test.ts と // 同一の production 実装で mock (mock ドリフト防止)。 buildAssetUrl: (path: string) => buildScriptaAssetUrl(path), + // exportAsHtml が呼ぶ #314 data URI 埋め込み経路。default は「常に失敗」で + // 元の src を残し、必要なテストだけ mockImplementation で成功挙動を上書きする。 + readFileBase64: vi.fn().mockRejectedValue(new Error("mock: not stubbed")), }; }); @@ -20,7 +23,7 @@ vi.mock("./svg-rasterize", () => ({ svgToPng: vi.fn(async () => "data:image/png;base64,MOCK"), })); -const { writeFile, exportPdf, showSaveDialog } = await import("./commands"); +const { writeFile, exportPdf, showSaveDialog, readFileBase64 } = await import("./commands"); const { buildHtmlDocument, buildPromptFromTemplate, @@ -39,6 +42,7 @@ const { const mockedSave = showSaveDialog as Mock; const mockedWriteFile = writeFile as Mock; const mockedExportPdf = exportPdf as Mock; +const mockedReadFileBase64 = readFileBase64 as Mock; describe("exportAsHtml", () => { beforeEach(() => { @@ -122,6 +126,38 @@ describe("exportAsHtml", () => { expect(html).toContain("mermaid-diagram"); expect(html).not.toContain("```mermaid"); }); + + it("embeds local relative images as data URI (#314)", async () => { + mockedSave.mockResolvedValue("/output/test.html"); + mockedReadFileBase64.mockImplementation(async (path: string) => { + expect(path).toBe("/workspace/img/hero.png"); + return "AAAA"; + }); + await exportAsHtml("![alt](./img/hero.png)", "/workspace/test.md"); + const html = mockedWriteFile.mock.calls[0][1] as string; + expect(html).toContain('src="data:image/png;base64,AAAA"'); + expect(html).not.toContain('src="./img/hero.png"'); + // 外部ブラウザで解決できない scripta-asset:// も混入しないこと + expect(html).not.toContain("scripta-asset://"); + }); + + it("leaves http(s) src untouched (no fetch attempt)", async () => { + mockedSave.mockResolvedValue("/output/test.html"); + await exportAsHtml("![](https://example.com/x.png)", "/workspace/test.md"); + const html = mockedWriteFile.mock.calls[0][1] as string; + expect(html).toContain('src="https://example.com/x.png"'); + expect(mockedReadFileBase64).not.toHaveBeenCalled(); + }); + + it("keeps original src when readFileBase64 fails (broken image, but export succeeds)", async () => { + mockedSave.mockResolvedValue("/output/test.html"); + mockedReadFileBase64.mockRejectedValue(new Error("EACCES")); + const result = await exportAsHtml("![](./missing.png)", "/workspace/test.md"); + expect(result).toBe(true); + const html = mockedWriteFile.mock.calls[0][1] as string; + // data: に置換されず元 src が残る (browser は broken image を表示するが export は完遂) + expect(html).toContain('src="./missing.png"'); + }); }); describe("exportAsPrompt", () => { diff --git a/src/lib/export.ts b/src/lib/export.ts index 663e387..7c71204 100644 --- a/src/lib/export.ts +++ b/src/lib/export.ts @@ -14,7 +14,7 @@ import { collectRawCodeRanges, isInsideRanges, markdownToHtml } from "./markdown // この経路を共有する)。既存 API 互換のため下記で re-export する。 import { preprocessMermaidBlocks } from "./mermaid-preprocess"; import { basename } from "./path"; -import { resolveHtmlImageSrcs } from "./resolve-html-images"; +import { embedHtmlImagesAsDataUri, resolveHtmlImageSrcs } from "./resolve-html-images"; import { extractSlideFrontmatterTheme, parseSlides } from "./slide-parser"; import { renderSlideHtmlWithMermaid } from "./slide-render"; @@ -320,7 +320,10 @@ export async function exportAsHtml( const mermaidTheme = resolveMermaidTheme(options?.theme); const withMarkers = preprocessPageBreakMarkers(markdown); const preprocessed = await preprocessMermaidBlocks(withMarkers, mermaidTheme); - const bodyHtml = markdownToHtml(preprocessed); + // 相対 / 絶対 workspace パスのローカル画像を data URI として埋め込む (#314)。 + // scripta-asset:// では外部ブラウザから解決不能なため、HTML 単体で self-contained + // にするにはインライン化が必要。activeTabPath は書き出し元の md path で代用する。 + const bodyHtml = await embedHtmlImagesAsDataUri(markdownToHtml(preprocessed), filePath); // Mermaid SVG は固定テーマでレンダリングされるため、 // system の場合も解決済みテーマで HTML 全体を統一する const htmlTheme = options?.theme === "system" || !options?.theme ? mermaidTheme : options.theme; diff --git a/src/lib/image-src.test.ts b/src/lib/image-src.test.ts index caf4b5d..7808722 100644 --- a/src/lib/image-src.test.ts +++ b/src/lib/image-src.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { buildScriptaAssetUrl } from "../../electron/preload/scripta-asset-url"; -import { parentDir, resolveImageSrc } from "./image-src"; +import { parentDir, resolveImageSrc, resolveImageToOsPath } from "./image-src"; vi.mock("./commands", () => ({ // production と同一ロジックでモックする。preload の `buildAssetUrl` も同じ helper を @@ -136,3 +136,29 @@ describe("resolveImageSrc", () => { expect(resolveImageSrc("image.png", "note.md")).toBe("image.png"); }); }); + +describe("resolveImageToOsPath", () => { + it("returns null for http(s)", () => { + expect(resolveImageToOsPath("http://a.example/x.png", null)).toBeNull(); + expect(resolveImageToOsPath("https://a.example/x.png", null)).toBeNull(); + }); + + it("returns null for data:/blob:", () => { + expect(resolveImageToOsPath("data:image/png;base64,AAAA", null)).toBeNull(); + expect(resolveImageToOsPath("blob:file:///abc", null)).toBeNull(); + }); + + it("returns absolute unix path unchanged", () => { + expect(resolveImageToOsPath("/workspace/logo.svg", null)).toBe("/workspace/logo.svg"); + }); + + it("resolves relative path against activeTabPath", () => { + expect(resolveImageToOsPath("./img/hero.png", "/workspace/notes/deck.md")).toBe( + "/workspace/notes/img/hero.png", + ); + }); + + it("returns null when activeTabPath is null and src is relative", () => { + expect(resolveImageToOsPath("image.png", null)).toBeNull(); + }); +}); diff --git a/src/lib/image-src.ts b/src/lib/image-src.ts index 6c4f8e8..4e0562e 100644 --- a/src/lib/image-src.ts +++ b/src/lib/image-src.ts @@ -22,31 +22,34 @@ function detectSeparator(filePath: string): string { return lastBackslash > lastSlash ? "\\" : "/"; } -export function resolveImageSrc( - rawUrl: string, - activeTabPath: string | null = useWorkspaceStore.getState().activeTabPath, -): string { - if (rawUrl.startsWith("http://") || rawUrl.startsWith("https://")) { - return rawUrl; - } - // data: / blob: は既に自己完結の URL なのでそのまま返す。 - // これらを相対パス分岐に落とすと activeTabPath 配下として scripta-asset URL に - // 巻き取られ、画像が壊れる。 - if (rawUrl.startsWith("data:") || rawUrl.startsWith("blob:")) { - return rawUrl; - } +/** + * markdown img の src (相対 / 絶対 / http(s) / data / blob) を OS 絶対パスに解決する。 + * 解決できない (remote scheme / self-contained scheme / activeTabPath 不在で相対) 場合は + * null。exportAsHtml の data URI 埋め込み経路 (#314) と `resolveImageSrc` の共通コア。 + */ +export function resolveImageToOsPath(rawUrl: string, activeTabPath: string | null): string | null { + if (rawUrl.startsWith("http://") || rawUrl.startsWith("https://")) return null; + if (rawUrl.startsWith("data:") || rawUrl.startsWith("blob:")) return null; if (rawUrl.startsWith("/") || rawUrl.startsWith("\\\\") || /^[A-Za-z]:[\\/]/.test(rawUrl)) { - return buildAssetUrl(rawUrl); + return rawUrl; } - if (!activeTabPath) return rawUrl; + if (!activeTabPath) return null; const dir = parentDir(activeTabPath); - if (!dir) return rawUrl; + if (!dir) return null; let normalized = rawUrl; if (normalized.startsWith("./") || normalized.startsWith(".\\")) { normalized = normalized.slice(2); } const sep = detectSeparator(activeTabPath); const needsSep = !dir.endsWith("/") && !dir.endsWith("\\"); - const resolved = needsSep ? `${dir}${sep}${normalized}` : `${dir}${normalized}`; - return buildAssetUrl(resolved); + return needsSep ? `${dir}${sep}${normalized}` : `${dir}${normalized}`; +} + +export function resolveImageSrc( + rawUrl: string, + activeTabPath: string | null = useWorkspaceStore.getState().activeTabPath, +): string { + const osPath = resolveImageToOsPath(rawUrl, activeTabPath); + if (osPath === null) return rawUrl; + return buildAssetUrl(osPath); } diff --git a/src/lib/resolve-html-images.test.ts b/src/lib/resolve-html-images.test.ts index 2deb357..b35c110 100644 --- a/src/lib/resolve-html-images.test.ts +++ b/src/lib/resolve-html-images.test.ts @@ -1,9 +1,12 @@ import { describe, expect, it, vi } from "vitest"; import { buildScriptaAssetUrl } from "../../electron/preload/scripta-asset-url"; -import { resolveHtmlImageSrcs } from "./resolve-html-images"; +import { embedHtmlImagesAsDataUri, resolveHtmlImageSrcs } from "./resolve-html-images"; + +const readFileBase64 = vi.fn(async (_path: string) => "AAAA"); vi.mock("./commands", () => ({ buildAssetUrl: (path: string) => buildScriptaAssetUrl(path), + readFileBase64: (path: string) => readFileBase64(path), })); vi.mock("../stores/workspace", () => ({ @@ -51,3 +54,130 @@ describe("resolveHtmlImageSrcs", () => { expect(out).toContain('src="scripta-asset://localhost/workspace/a.png"'); }); }); + +describe("embedHtmlImagesAsDataUri", () => { + it("returns identical html when no img tag present (fast path)", async () => { + const html = "

plain text

"; + expect(await embedHtmlImagesAsDataUri(html, "/workspace/deck.md")).toBe(html); + }); + + it("replaces relative img src with data URI", async () => { + readFileBase64.mockResolvedValueOnce("BASE64PNG"); + const out = await embedHtmlImagesAsDataUri( + '', + "/workspace/notes/deck.md", + ); + expect(readFileBase64).toHaveBeenCalledWith("/workspace/notes/img/hero.png"); + expect(out).toContain('src="data:image/png;base64,BASE64PNG"'); + }); + + it("replaces absolute workspace path with data URI (svg mime)", async () => { + readFileBase64.mockResolvedValueOnce("SVGDATA"); + const out = await embedHtmlImagesAsDataUri( + '', + "/workspace/notes/deck.md", + ); + expect(readFileBase64).toHaveBeenCalledWith("/workspace/logo.svg"); + expect(out).toContain('src="data:image/svg+xml;base64,SVGDATA"'); + }); + + it("leaves http(s) src untouched without calling readFileBase64", async () => { + readFileBase64.mockClear(); + const out = await embedHtmlImagesAsDataUri( + '', + "/workspace/deck.md", + ); + expect(readFileBase64).not.toHaveBeenCalled(); + expect(out).toContain('src="https://example.com/x.png"'); + }); + + it("leaves data: src untouched", async () => { + readFileBase64.mockClear(); + const out = await embedHtmlImagesAsDataUri( + '', + "/workspace/deck.md", + ); + expect(readFileBase64).not.toHaveBeenCalled(); + expect(out).toContain('src="data:image/png;base64,AAAA"'); + }); + + it("skips unsupported extensions (mp4 as img is nonsensical, leave untouched)", async () => { + readFileBase64.mockClear(); + const out = await embedHtmlImagesAsDataUri('', "/workspace/deck.md"); + expect(readFileBase64).not.toHaveBeenCalled(); + expect(out).toContain('src="./video.mp4"'); + }); + + it("keeps original src on readFileBase64 failure", async () => { + readFileBase64.mockRejectedValueOnce(new Error("EACCES")); + const out = await embedHtmlImagesAsDataUri('', "/workspace/deck.md"); + expect(out).toContain('src="./missing.png"'); + expect(out).not.toContain("data:image/png"); + }); + + it("handles multiple images (Promise.all parallel path)", async () => { + readFileBase64 + .mockResolvedValueOnce("AAA") + .mockResolvedValueOnce("BBB") + .mockResolvedValueOnce("CCC"); + const out = await embedHtmlImagesAsDataUri( + '', + "/workspace/deck.md", + ); + expect(out).toContain("data:image/png;base64,AAA"); + expect(out).toContain("data:image/jpeg;base64,BBB"); + expect(out).toContain("data:image/gif;base64,CCC"); + }); + + it("mixes success and failure per image (partial fallback)", async () => { + readFileBase64.mockResolvedValueOnce("OK").mockRejectedValueOnce(new Error("ENOENT")); + const out = await embedHtmlImagesAsDataUri( + '', + "/workspace/deck.md", + ); + expect(out).toContain("data:image/png;base64,OK"); + expect(out).toContain('src="./bad.png"'); + }); + + it("case-insensitive extension matching (PNG, JPEG)", async () => { + readFileBase64.mockResolvedValueOnce("UP1").mockResolvedValueOnce("UP2"); + const out = await embedHtmlImagesAsDataUri( + '', + "/workspace/deck.md", + ); + expect(out).toContain("data:image/png;base64,UP1"); + expect(out).toContain("data:image/jpeg;base64,UP2"); + }); + + it("deduplicates identical osPath: single read for N references", async () => { + readFileBase64.mockClear(); + readFileBase64.mockResolvedValue("DUP"); + const out = await embedHtmlImagesAsDataUri( + '', + "/workspace/deck.md", + ); + // 3 img 参照でも readFileBase64 は 1 回だけ + expect(readFileBase64).toHaveBeenCalledTimes(1); + // 3 箇所全てに data URI が反映される + const matches = out.match(/data:image\/png;base64,DUP/g); + expect(matches?.length).toBe(3); + }); + + it("caps in-flight readFileBase64 to EMBED_CONCURRENCY (bounded concurrency)", async () => { + readFileBase64.mockClear(); + let inFlight = 0; + let peak = 0; + readFileBase64.mockImplementation(async () => { + inFlight++; + peak = Math.max(peak, inFlight); + await new Promise((r) => setTimeout(r, 5)); + inFlight--; + return "X"; + }); + const html = Array.from({ length: 20 }, (_, i) => ``).join(""); + await embedHtmlImagesAsDataUri(html, "/workspace/deck.md"); + expect(readFileBase64).toHaveBeenCalledTimes(20); + // EMBED_CONCURRENCY = 4。上限を超えた並列度が観測されないことを検証 + expect(peak).toBeLessThanOrEqual(4); + }); +}); diff --git a/src/lib/resolve-html-images.ts b/src/lib/resolve-html-images.ts index 03c0090..2e2d24e 100644 --- a/src/lib/resolve-html-images.ts +++ b/src/lib/resolve-html-images.ts @@ -5,7 +5,9 @@ // DOMParser で再パースし img 要素の src 属性だけを書き換えるため、既存 sanitize // 保証は維持される (テキストノード / 他属性 / KaTeX HTML 等には触らない)。 -import { resolveImageSrc } from "./image-src"; +import { mimeForImageExt } from "../types/image"; +import { readFileBase64 } from "./commands"; +import { resolveImageSrc, resolveImageToOsPath } from "./image-src"; export function resolveHtmlImageSrcs(html: string, activeTabPath: string | null): string { if (!html.includes(" { + if (!html.includes("(); + for (const img of imgs) { + const src = img.getAttribute("src"); + if (!src) continue; + const osPath = resolveImageToOsPath(src, activeTabPath); + if (osPath === null) continue; + const mime = mimeForImageExt(extname(osPath)); + if (mime === null) continue; + const key = `${mime}:${osPath}`; + const existing = tasks.get(key); + if (existing) { + existing.targets.push(img); + } else { + tasks.set(key, { osPath, mime, targets: [img] }); + } + } + if (tasks.size === 0) return html; + + // 2nd pass: worker pool で bounded concurrency の Task 消化。 + // 単純な batched Promise.all だと slow-image が同 batch の fast-image を待たせる + // 頭打ちが出るので、queue から順に pull する worker 型を採用。 + const queue = [...tasks.values()]; + let next = 0; + const worker = async (): Promise => { + while (true) { + const i = next++; + if (i >= queue.length) return; + const task = queue[i]; + try { + const b64 = await readFileBase64(task.osPath); + const dataUri = `data:${task.mime};base64,${b64}`; + for (const target of task.targets) target.setAttribute("src", dataUri); + } catch { + // 権限拒否 / 未存在 / サイズ超は broken image として通過させる (HTML 出力自体は完遂) + } + } + }; + await Promise.all( + Array.from({ length: Math.min(EMBED_CONCURRENCY, queue.length) }, () => worker()), + ); + return doc.body.innerHTML; +} diff --git a/src/types/image.ts b/src/types/image.ts new file mode 100644 index 0000000..d1c04ee --- /dev/null +++ b/src/types/image.ts @@ -0,0 +1,21 @@ +// 拡張子 (lower-case、先頭ドットなし) → MIME マッピング。 +// exportAsHtml の data URI 埋め込み経路 (renderer 側 embedHtmlImagesAsDataUri) と、 +// fs:read-base64 handler (main 側 ext 検証) の両方から参照する 1 SOT。 +// 増やす時は「HTML img として意味を持つ画像形式」であることを確認する +// (mp4 等の video / audio / pdf は img で表示できないので追加しない)。 +export const IMAGE_MIMES: Readonly> = Object.freeze({ + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + gif: "image/gif", + webp: "image/webp", + svg: "image/svg+xml", + bmp: "image/bmp", + avif: "image/avif", +}); + +/** 拡張子 (先頭ドット有無どちらでも可) から MIME を返す。未知拡張子は null。 */ +export function mimeForImageExt(ext: string): string | null { + const normalized = ext.startsWith(".") ? ext.slice(1) : ext; + return IMAGE_MIMES[normalized.toLowerCase()] ?? null; +}