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