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 };
|
||||
});
|
||||
|
||||
// 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);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ export type FolioAPI = {
|
|||
saveChapter: (id: string, content: string) => Promise<any>;
|
||||
exportMarkdown: () => Promise<any>;
|
||||
exportZip: () => Promise<any>;
|
||||
exportPdf: () => Promise<any>;
|
||||
importAttachment: () => Promise<any>;
|
||||
copyText: (text: string) => Promise<any>;
|
||||
getClipboardText: () => Promise<string>;
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -173,6 +173,7 @@
|
|||
<h2 class="chapter-title">Full Book</h2>
|
||||
<div class="editor-actions">
|
||||
<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>
|
||||
<span class="save-state" id="fullBookStatus"></span>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue