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:
parent
ebbb424ee5
commit
778b480ea2
8 changed files with 137 additions and 0 deletions
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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) =>
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 .
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue