From 778b480ea25b4dd4fa50a4a709b8581c83290aee Mon Sep 17 00:00:00 2001 From: avi Date: Tue, 18 Aug 2026 10:11:06 -0500 Subject: [PATCH] Export Full Book as PDF with automatic chapter page breaks Print the Full Book window via webContents.printToPDF (A4, 1in margins), reusing the existing print stylesheet so every chapter after the first starts on its own page and all app chrome is stripped from the output. Expose it as an Export PDF button in the Full Book header and a File menu item, save via a native save dialog (self-test writes to FOLIO_TEST_PDF), and force light-on-white colors in print so dark-mode exports stay readable. Add e2e coverage that exports through the real renderer->IPC->print chain and verifies a valid non-empty PDF is written. --- src/main/index.ts | 35 +++++++++++++++++++++++++++++++++++ src/main/menu.ts | 6 ++++++ src/main/preload.ts | 3 +++ src/renderer/book.ts | 28 ++++++++++++++++++++++++++++ src/renderer/index.css | 31 +++++++++++++++++++++++++++++++ src/renderer/index.html | 1 + tests/run-e2e.sh | 1 + tests/selftest-scenario.js | 32 ++++++++++++++++++++++++++++++++ 8 files changed, 137 insertions(+) diff --git a/src/main/index.ts b/src/main/index.ts index cd83f92..18e39dc 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -321,6 +321,41 @@ ipcMain.handle("folio:exportZip", async () => { return { ok: true, filePath }; }); +// Export the Full Book view as a PDF. The renderer shows only the assembled +// manuscript in this view, and the print stylesheet hides every piece of app +// chrome, so printing the window produces a clean, book-only PDF with an +// automatic page break before each chapter after the first. Chapter files and +// the assembled Markdown are never touched. +ipcMain.handle("folio:exportPdf", async () => { + try { + requireBook(); + let filePath: string; + if (process.env.FOLIO_SELF_TEST === "1" && process.env.FOLIO_TEST_PDF) { + filePath = process.env.FOLIO_TEST_PDF; + } else { + const { canceled, filePath: chosen } = await showSaveDialog({ + title: "Export Full Book as PDF", + defaultPath: `${currentBookTitle()}.pdf`, + filters: [{ name: "PDF Document", extensions: ["pdf"] }], + }); + if (canceled || !chosen) return { canceled: true } as const; + filePath = chosen.endsWith(".pdf") ? chosen : `${chosen}.pdf`; + } + const win = getWindow(); + if (!win) return { error: "No window is open." }; + const buf = await win.webContents.printToPDF({ + pageSize: "A4", + printBackground: true, + margins: { top: 1, bottom: 1, left: 1, right: 1 }, + }); + fs.writeFileSync(filePath, buf); + const pdfOk = buf.subarray(0, 4).toString("utf-8") === "%PDF"; + return { ok: true, filePath, bytes: buf.length, pdfOk }; + } catch (e) { + return { error: (e as Error).message }; + } +}); + ipcMain.handle("folio:saveChapter", (_e, id: string, content: string) => { try { setChapterContent(requireBook(), id, content); diff --git a/src/main/menu.ts b/src/main/menu.ts index 61f5d95..1c5a86b 100644 --- a/src/main/menu.ts +++ b/src/main/menu.ts @@ -53,6 +53,12 @@ export function buildMenu( enabled: !!getState().bookPath, click: () => getWindow()?.webContents.send("folio:export-zip-requested"), }, + { + label: "Export Full Book (PDF)…", + accelerator: "CmdOrCtrl+Shift+P", + enabled: !!getState().bookPath, + click: () => getWindow()?.webContents.send("folio:export-pdf-requested"), + }, { type: "separator" }, { label: "Close", diff --git a/src/main/preload.ts b/src/main/preload.ts index de16979..b9b2988 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -27,6 +27,7 @@ contextBridge.exposeInMainWorld("folio", { ipcRenderer.invoke("folio:saveChapter", id, content), exportMarkdown: () => ipcRenderer.invoke("folio:exportMarkdown"), exportZip: () => ipcRenderer.invoke("folio:exportZip"), + exportPdf: () => ipcRenderer.invoke("folio:exportPdf"), importAttachment: () => ipcRenderer.invoke("folio:importAttachment"), copyText: (text: string) => ipcRenderer.invoke("folio:copyText", text), getClipboardText: () => ipcRenderer.invoke("folio:getClipboardText"), @@ -50,6 +51,8 @@ contextBridge.exposeInMainWorld("folio", { ipcRenderer.on("folio:export-markdown-requested", () => cb()), onExportZipRequested: (cb: () => void) => ipcRenderer.on("folio:export-zip-requested", () => cb()), + onExportPdfRequested: (cb: () => void) => + ipcRenderer.on("folio:export-pdf-requested", () => cb()), onUndoRequested: (cb: () => void) => ipcRenderer.on("folio:undo-requested", () => cb()), onRedoRequested: (cb: () => void) => diff --git a/src/renderer/book.ts b/src/renderer/book.ts index 3c12b77..6587c98 100644 --- a/src/renderer/book.ts +++ b/src/renderer/book.ts @@ -54,6 +54,7 @@ export type FolioAPI = { saveChapter: (id: string, content: string) => Promise; exportMarkdown: () => Promise; exportZip: () => Promise; + exportPdf: () => Promise; importAttachment: () => Promise; copyText: (text: string) => Promise; getClipboardText: () => Promise; @@ -66,6 +67,7 @@ export type FolioAPI = { onRevealRequested: (cb: (p: string) => void) => void; onExportMarkdownRequested: (cb: () => void) => void; onExportZipRequested: (cb: () => void) => void; + onExportPdfRequested: (cb: () => void) => void; onUndoRequested: (cb: () => void) => void; onRedoRequested: (cb: () => void) => void; onSelectAllRequested: (cb: () => void) => void; @@ -117,6 +119,7 @@ export function initBook(api: FolioAPI) { const fullBookStats = $("fullBookStats"); const copyAllBtn = $("copyAllBtn") as HTMLButtonElement; const fullBookStatus = $("fullBookStatus"); + const exportPdfBtn = $("exportPdfBtn") as HTMLButtonElement; // Marks the line where every chapter after the first begins. In print this // line carries the page-break; on screen it shows a subtle separator. The @@ -643,6 +646,29 @@ export function initBook(api: FolioAPI) { else if (res && "canceled" in res) status("Export canceled"); } + function fullBookNotice(text: string) { + fullBookStatus.textContent = text; + fullBookStatus.classList.add("unsaved"); + setTimeout(() => { + fullBookStatus.textContent = ""; + fullBookStatus.classList.remove("unsaved"); + }, 3000); + } + + // Export the assembled book as a PDF. Only meaningful from the Full Book + // view (that is what gets printed), so jump there first if invoked from + // the File menu. The main process prints the visible window using the + // print stylesheet, which strips all app chrome and starts each chapter + // after the first on its own page. + async function exportPdf() { + if (!currentBook) return; + if (fullBookView.classList.contains("hidden")) await selectFullBook(); + const res = await api.exportPdf(); + if (res && "error" in res) await alertMessage("Export failed", (res as { error: string }).error); + else if (res && "canceled" in res) fullBookNotice("Export canceled"); + else if (res && "filePath" in res) fullBookNotice(`PDF saved to ${basename(res.filePath)}`); + } + function basename(p: string): string { return p.split(/[/\\]/).pop() || p; } @@ -848,6 +874,7 @@ export function initBook(api: FolioAPI) { exportBtn.addEventListener("click", () => void exportMarkdown()); exportZipBtn.addEventListener("click", () => void exportZip()); fullBookBtn.addEventListener("click", () => void selectFullBook()); + exportPdfBtn.addEventListener("click", () => void exportPdf()); copyAllBtn.addEventListener("click", async () => { const text = editorText(fullBookEditor); if (!text) { @@ -879,6 +906,7 @@ export function initBook(api: FolioAPI) { api.onRevealRequested((p) => void api.revealInFolder(p)); api.onExportMarkdownRequested(() => void exportMarkdown()); api.onExportZipRequested(() => void exportZip()); + api.onExportPdfRequested(() => void exportPdf()); api.onUndoRequested(() => { if (editorView.hasFocus) undo(editorView); else document.execCommand("undo"); diff --git a/src/renderer/index.css b/src/renderer/index.css index 8c009f2..816d74d 100644 --- a/src/renderer/index.css +++ b/src/renderer/index.css @@ -523,6 +523,10 @@ html, body { html, body, #root { height: auto !important; overflow: visible !important; + /* Pin the page to light-on-white so the PDF is readable even when the + app is in dark mode (Chromium PDF export inherits the live theme). */ + color: #000 !important; + background: #fff !important; } .sidebar, .full-book-nav, .chapter-header, .editor-placeholder { @@ -542,6 +546,8 @@ html, body { min-height: 0 !important; max-height: none !important; overflow: visible !important; + background: #fff !important; + color: #000 !important; } #fullBookHost .cm-scroller { @@ -552,6 +558,31 @@ html, body { white-space: pre-wrap !important; } + /* Heading and inline styling stays dark enough to read on white. */ + #fullBookHost .cm-h1, + #fullBookHost .cm-h2, + #fullBookHost .cm-h3, + #fullBookHost .cm-h4, + #fullBookHost .cm-h5, + #fullBookHost .cm-h6, + #fullBookHost .cm-strong, + #fullBookHost .cm-em, + #fullBookHost .cm-code { + color: #000 !important; + } + + #fullBookHost .cm-link { color: #1a0dab !important; } + #fullBookHost .cm-url, + #fullBookHost .cm-quote, + #fullBookHost .cm-hr, + #fullBookHost .cm-comment { + color: #444 !important; + } + + #fullBookHost .cm-code { + background: #f2f2f2 !important; + } + /* Every chapter after the first starts on a new printed page. The first chapter keeps its natural position (no blank page before it). */ #fullBookHost .cm-line.chapter-break { diff --git a/src/renderer/index.html b/src/renderer/index.html index e735b8c..e4eafc0 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -173,6 +173,7 @@

Full Book

words: 0 · chars: 0 +
diff --git a/tests/run-e2e.sh b/tests/run-e2e.sh index fbbc9e7..cdf83c3 100755 --- a/tests/run-e2e.sh +++ b/tests/run-e2e.sh @@ -9,5 +9,6 @@ BOOK2=$(node -e 'const fs=require("fs"),path=require("path"),os=require("os");co export FOLIO_SELF_TEST=1 export FOLIO_TEST_BOOK="$BOOK" export FOLIO_TEST_BOOK2="$BOOK2" +export FOLIO_TEST_PDF="$BOOK/export-test.pdf" trap 'rm -rf "$BOOK" "$BOOK2"' EXIT xvfb-run -a electron . diff --git a/tests/selftest-scenario.js b/tests/selftest-scenario.js index c2d6dd5..06d3421 100644 --- a/tests/selftest-scenario.js +++ b/tests/selftest-scenario.js @@ -407,6 +407,34 @@ const BOOK2_DIR = __BOOKDIR2_VALUE__; out.fbWithEmpty = assembledWithEmpty; out.fbEmptyOk = assembledWithEmpty.includes("# Empty") && assembledWithEmpty.includes("Beta Renamed"); + // --- PDF export of the Full Book --- + // The e2e harness sets FOLIO_TEST_PDF, so the main process skips the + // native save dialog and writes to that path, returning the byte count + // and whether the buffer starts with the PDF magic number. The Full Book + // view is on screen here, so what gets printed is exactly the assembled + // manuscript (the print stylesheet hides all app chrome). + const pdfBtn = document.getElementById("exportPdfBtn"); + out.pdfBtnPresent = !!pdfBtn && !pdfBtn.disabled; + out.pdfExportPdfVisible = !document.getElementById("fullBookView").classList.contains("hidden"); + pdfBtn.click(); + await new Promise((r) => setTimeout(r, 2500)); + out.pdfStatusShown = document + .getElementById("fullBookStatus") + .textContent.startsWith("PDF saved to"); + const pdfRes = await window.folio.exportPdf(); + out.pdfExport = + pdfRes && "filePath" in pdfRes + ? { filePath: pdfRes.filePath, bytes: pdfRes.bytes, pdfOk: pdfRes.pdfOk } + : pdfRes; + out.pdfExportOk = + !!pdfRes && + !("error" in pdfRes) && + "filePath" in pdfRes && + pdfRes.filePath.endsWith(".pdf") && + typeof pdfRes.bytes === "number" && + pdfRes.bytes > 500 && + pdfRes.pdfOk === true; + const okFlags = [ "dndMoved", "kbMoved", @@ -452,6 +480,10 @@ const BOOK2_DIR = __BOOKDIR2_VALUE__; "fbAfterDeleteOk", "fbAfterRenameOk", "fbEmptyOk", + "pdfBtnPresent", + "pdfExportPdfVisible", + "pdfStatusShown", + "pdfExportOk", ]; out.all = okFlags.every((f) => out[f] === true); } catch (e) {