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.
This commit is contained in:
avi 2026-08-18 10:11:06 -05:00
commit 778b480ea2
8 changed files with 137 additions and 0 deletions

View file

@ -321,6 +321,41 @@ ipcMain.handle("folio:exportZip", async () => {
return { ok: true, filePath }; 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) => { ipcMain.handle("folio:saveChapter", (_e, id: string, content: string) => {
try { try {
setChapterContent(requireBook(), id, content); setChapterContent(requireBook(), id, content);

View file

@ -53,6 +53,12 @@ export function buildMenu(
enabled: !!getState().bookPath, enabled: !!getState().bookPath,
click: () => getWindow()?.webContents.send("folio:export-zip-requested"), 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" }, { type: "separator" },
{ {
label: "Close", label: "Close",

View file

@ -27,6 +27,7 @@ contextBridge.exposeInMainWorld("folio", {
ipcRenderer.invoke("folio:saveChapter", id, content), ipcRenderer.invoke("folio:saveChapter", id, content),
exportMarkdown: () => ipcRenderer.invoke("folio:exportMarkdown"), exportMarkdown: () => ipcRenderer.invoke("folio:exportMarkdown"),
exportZip: () => ipcRenderer.invoke("folio:exportZip"), exportZip: () => ipcRenderer.invoke("folio:exportZip"),
exportPdf: () => ipcRenderer.invoke("folio:exportPdf"),
importAttachment: () => ipcRenderer.invoke("folio:importAttachment"), importAttachment: () => ipcRenderer.invoke("folio:importAttachment"),
copyText: (text: string) => ipcRenderer.invoke("folio:copyText", text), copyText: (text: string) => ipcRenderer.invoke("folio:copyText", text),
getClipboardText: () => ipcRenderer.invoke("folio:getClipboardText"), getClipboardText: () => ipcRenderer.invoke("folio:getClipboardText"),
@ -50,6 +51,8 @@ contextBridge.exposeInMainWorld("folio", {
ipcRenderer.on("folio:export-markdown-requested", () => cb()), ipcRenderer.on("folio:export-markdown-requested", () => cb()),
onExportZipRequested: (cb: () => void) => onExportZipRequested: (cb: () => void) =>
ipcRenderer.on("folio:export-zip-requested", () => cb()), ipcRenderer.on("folio:export-zip-requested", () => cb()),
onExportPdfRequested: (cb: () => void) =>
ipcRenderer.on("folio:export-pdf-requested", () => cb()),
onUndoRequested: (cb: () => void) => onUndoRequested: (cb: () => void) =>
ipcRenderer.on("folio:undo-requested", () => cb()), ipcRenderer.on("folio:undo-requested", () => cb()),
onRedoRequested: (cb: () => void) => onRedoRequested: (cb: () => void) =>

View file

@ -54,6 +54,7 @@ export type FolioAPI = {
saveChapter: (id: string, content: string) => Promise<any>; saveChapter: (id: string, content: string) => Promise<any>;
exportMarkdown: () => Promise<any>; exportMarkdown: () => Promise<any>;
exportZip: () => Promise<any>; exportZip: () => Promise<any>;
exportPdf: () => Promise<any>;
importAttachment: () => Promise<any>; importAttachment: () => Promise<any>;
copyText: (text: string) => Promise<any>; copyText: (text: string) => Promise<any>;
getClipboardText: () => Promise<string>; getClipboardText: () => Promise<string>;
@ -66,6 +67,7 @@ export type FolioAPI = {
onRevealRequested: (cb: (p: string) => void) => void; onRevealRequested: (cb: (p: string) => void) => void;
onExportMarkdownRequested: (cb: () => void) => void; onExportMarkdownRequested: (cb: () => void) => void;
onExportZipRequested: (cb: () => void) => void; onExportZipRequested: (cb: () => void) => void;
onExportPdfRequested: (cb: () => void) => void;
onUndoRequested: (cb: () => void) => void; onUndoRequested: (cb: () => void) => void;
onRedoRequested: (cb: () => void) => void; onRedoRequested: (cb: () => void) => void;
onSelectAllRequested: (cb: () => void) => void; onSelectAllRequested: (cb: () => void) => void;
@ -117,6 +119,7 @@ export function initBook(api: FolioAPI) {
const fullBookStats = $("fullBookStats"); const fullBookStats = $("fullBookStats");
const copyAllBtn = $("copyAllBtn") as HTMLButtonElement; const copyAllBtn = $("copyAllBtn") as HTMLButtonElement;
const fullBookStatus = $("fullBookStatus"); const fullBookStatus = $("fullBookStatus");
const exportPdfBtn = $("exportPdfBtn") as HTMLButtonElement;
// Marks the line where every chapter after the first begins. In print this // 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 // 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"); 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 { function basename(p: string): string {
return p.split(/[/\\]/).pop() || p; return p.split(/[/\\]/).pop() || p;
} }
@ -848,6 +874,7 @@ export function initBook(api: FolioAPI) {
exportBtn.addEventListener("click", () => void exportMarkdown()); exportBtn.addEventListener("click", () => void exportMarkdown());
exportZipBtn.addEventListener("click", () => void exportZip()); exportZipBtn.addEventListener("click", () => void exportZip());
fullBookBtn.addEventListener("click", () => void selectFullBook()); fullBookBtn.addEventListener("click", () => void selectFullBook());
exportPdfBtn.addEventListener("click", () => void exportPdf());
copyAllBtn.addEventListener("click", async () => { copyAllBtn.addEventListener("click", async () => {
const text = editorText(fullBookEditor); const text = editorText(fullBookEditor);
if (!text) { if (!text) {
@ -879,6 +906,7 @@ export function initBook(api: FolioAPI) {
api.onRevealRequested((p) => void api.revealInFolder(p)); api.onRevealRequested((p) => void api.revealInFolder(p));
api.onExportMarkdownRequested(() => void exportMarkdown()); api.onExportMarkdownRequested(() => void exportMarkdown());
api.onExportZipRequested(() => void exportZip()); api.onExportZipRequested(() => void exportZip());
api.onExportPdfRequested(() => void exportPdf());
api.onUndoRequested(() => { api.onUndoRequested(() => {
if (editorView.hasFocus) undo(editorView); if (editorView.hasFocus) undo(editorView);
else document.execCommand("undo"); else document.execCommand("undo");

View file

@ -523,6 +523,10 @@ html, body {
html, body, #root { html, body, #root {
height: auto !important; height: auto !important;
overflow: visible !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 { .sidebar, .full-book-nav, .chapter-header, .editor-placeholder {
@ -542,6 +546,8 @@ html, body {
min-height: 0 !important; min-height: 0 !important;
max-height: none !important; max-height: none !important;
overflow: visible !important; overflow: visible !important;
background: #fff !important;
color: #000 !important;
} }
#fullBookHost .cm-scroller { #fullBookHost .cm-scroller {
@ -552,6 +558,31 @@ html, body {
white-space: pre-wrap !important; 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 /* Every chapter after the first starts on a new printed page. The first
chapter keeps its natural position (no blank page before it). */ chapter keeps its natural position (no blank page before it). */
#fullBookHost .cm-line.chapter-break { #fullBookHost .cm-line.chapter-break {

View file

@ -173,6 +173,7 @@
<h2 class="chapter-title">Full Book</h2> <h2 class="chapter-title">Full Book</h2>
<div class="editor-actions"> <div class="editor-actions">
<span class="word-count" id="fullBookStats">words: 0 · chars: 0</span> <span class="word-count" id="fullBookStats">words: 0 · chars: 0</span>
<button class="button" id="exportPdfBtn" title="Export the entire Full Book as a PDF">Export PDF</button>
<button class="button button-primary" id="copyAllBtn" title="Copy the entire assembled manuscript as Markdown">Copy All</button> <button class="button button-primary" id="copyAllBtn" title="Copy the entire assembled manuscript as Markdown">Copy All</button>
<span class="save-state" id="fullBookStatus"></span> <span class="save-state" id="fullBookStatus"></span>
</div> </div>

View file

@ -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_SELF_TEST=1
export FOLIO_TEST_BOOK="$BOOK" export FOLIO_TEST_BOOK="$BOOK"
export FOLIO_TEST_BOOK2="$BOOK2" export FOLIO_TEST_BOOK2="$BOOK2"
export FOLIO_TEST_PDF="$BOOK/export-test.pdf"
trap 'rm -rf "$BOOK" "$BOOK2"' EXIT trap 'rm -rf "$BOOK" "$BOOK2"' EXIT
xvfb-run -a electron . xvfb-run -a electron .

View file

@ -407,6 +407,34 @@ const BOOK2_DIR = __BOOKDIR2_VALUE__;
out.fbWithEmpty = assembledWithEmpty; out.fbWithEmpty = assembledWithEmpty;
out.fbEmptyOk = assembledWithEmpty.includes("# Empty") && assembledWithEmpty.includes("Beta Renamed"); 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 = [ const okFlags = [
"dndMoved", "dndMoved",
"kbMoved", "kbMoved",
@ -452,6 +480,10 @@ const BOOK2_DIR = __BOOKDIR2_VALUE__;
"fbAfterDeleteOk", "fbAfterDeleteOk",
"fbAfterRenameOk", "fbAfterRenameOk",
"fbEmptyOk", "fbEmptyOk",
"pdfBtnPresent",
"pdfExportPdfVisible",
"pdfStatusShown",
"pdfExportOk",
]; ];
out.all = okFlags.every((f) => out[f] === true); out.all = okFlags.every((f) => out[f] === true);
} catch (e) { } catch (e) {