diff --git a/src/renderer/book.ts b/src/renderer/book.ts
index 6587c98..34c16fc 100644
--- a/src/renderer/book.ts
+++ b/src/renderer/book.ts
@@ -1,4 +1,5 @@
import type { BookMeta } from "../main/project.js";
+import MarkdownIt from "markdown-it";
import { alertMessage, confirmMessage, promptInput } from "./dialog.js";
import { createMdEditor, editorText, setEditorText } from "./md-editor.js";
import { Decoration, EditorView, type DecorationSet } from "@codemirror/view";
@@ -75,6 +76,12 @@ export type FolioAPI = {
const $ = (id: string) => document.getElementById(id) as HTMLElement;
+// Renders the assembled manuscript into the print-only #printBook container,
+// which the print stylesheet substitutes for the whole app when exporting a
+// PDF. Plain HTML paginates reliably regardless of the editor's virtualized
+// viewport, so every chapter always reaches the printed book.
+const md = new MarkdownIt();
+
interface ChapterEntry {
id: string;
title: string;
@@ -117,6 +124,7 @@ export function initBook(api: FolioAPI) {
const fullBookView = $("fullBookView");
const fullBookHost = $("fullBookHost");
const fullBookStats = $("fullBookStats");
+ const printBook = $("printBook");
const copyAllBtn = $("copyAllBtn") as HTMLButtonElement;
const fullBookStatus = $("fullBookStatus");
const exportPdfBtn = $("exportPdfBtn") as HTMLButtonElement;
@@ -530,9 +538,9 @@ export function initBook(api: FolioAPI) {
// Each chapter is preserved as its own block (title + content) and we also
// record where every chapter begins in the assembled text so the view can
// mark chapter boundaries for page breaks without touching the chapter files.
- async function assembleFullBook(): Promise<{ text: string; chapterStarts: number[] }> {
+ async function assembleFullBook(): Promise<{ text: string; chapterStarts: number[]; chapters: string[] }> {
const res = await api.getBookMeta();
- if (!res || typeof res !== "object" || "error" in res) return { text: "", chapterStarts: [] };
+ if (!res || typeof res !== "object" || "error" in res) return { text: "", chapterStarts: [], chapters: [] };
const book = res as BookMeta;
const parts: string[] = [];
const chapterStarts: number[] = [];
@@ -560,17 +568,20 @@ export function initBook(api: FolioAPI) {
offset += joined.length + 2;
}
}
- return { text: parts.join("\n\n"), chapterStarts };
+ return { text: parts.join("\n\n"), chapterStarts, chapters: parts };
}
async function renderFullBook() {
- const { text, chapterStarts } = await assembleFullBook();
+ const { text, chapterStarts, chapters } = await assembleFullBook();
setEditorText(fullBookEditor, text);
const ranges = chapterStarts.slice(1).map((from) => chapterBreakLine.range(from));
fullBookEditor.dispatch({
effects: setChapterBreaks.of(Decoration.set(ranges, true)),
});
fullBookStats.textContent = `words: ${countWords(text)} · chars: ${text.length}`;
+ printBook.innerHTML = chapters
+ .map((c) => ``)
+ .join("");
}
async function selectFullBook() {
diff --git a/src/renderer/index.css b/src/renderer/index.css
index 816d74d..e591f50 100644
--- a/src/renderer/index.css
+++ b/src/renderer/index.css
@@ -519,79 +519,107 @@ html, body {
padding-top: 1.5rem;
}
+/* ---- PDF / print output ----
+ PDF export prints the whole window via webContents.printToPDF, so the
+ print stylesheet replaces the entire app with a plain-HTML manuscript
+ (#printBook) that the renderer keeps populated from the assembled book.
+ Printing plain HTML (instead of the CodeMirror editor, which only keeps a
+ virtualized window of lines in the DOM) guarantees the PDF always contains
+ the complete book in full, no matter how long it is or where the editor is
+ scrolled. #printBook is hidden on screen and shown only when printing. */
+#printBook {
+ display: none;
+}
+
@media print {
- html, body, #root {
+ html, body {
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 {
+ /* Nothing from the app is printed — sidebar, toolbar, headers, buttons,
+ modals and the live editor are all replaced by the manuscript. */
+ #root, .modal-backdrop {
display: none !important;
}
- .editor, .full-book-view, .editor-panes {
- height: auto !important;
- min-height: 0 !important;
- overflow: visible !important;
- }
-
- #fullBookHost .cm-editor,
- #fullBookHost .cm-scroller,
- #fullBookHost .cm-content {
- height: auto !important;
- min-height: 0 !important;
- max-height: none !important;
- overflow: visible !important;
- background: #fff !important;
- color: #000 !important;
- }
-
- #fullBookHost .cm-scroller {
+ #printBook {
display: block !important;
+ font-family: var(--font-serif);
+ font-size: 12pt;
+ line-height: 1.6;
+ color: #000;
+ background: #fff;
}
- #fullBookHost .cm-content {
- 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 {
+ /* Every chapter after the first starts on a new page. The first chapter
+ keeps its natural position (no blank page before it). */
+ #printBook .print-chapter {
break-before: page;
page-break-before: always;
- border-top: none;
- margin-top: 0;
- padding-top: 0;
}
+ #printBook .print-chapter:first-child {
+ break-before: auto;
+ page-break-before: auto;
+ }
+
+ #printBook h1,
+ #printBook h2,
+ #printBook h3,
+ #printBook h4,
+ #printBook h5,
+ #printBook h6 {
+ margin: 0 0 0.7em;
+ line-height: 1.3;
+ color: #000;
+ }
+ #printBook h1 { font-size: 1.7em; }
+ #printBook h2 { font-size: 1.45em; }
+ #printBook h3 { font-size: 1.25em; }
+ #printBook h4 { font-size: 1.1em; }
+
+ #printBook p,
+ #printBook ul,
+ #printBook ol,
+ #printBook blockquote,
+ #printBook pre,
+ #printBook table {
+ margin: 0 0 0.8em;
+ }
+ #printBook ul, #printBook ol { padding-left: 1.5em; }
+ #printBook blockquote {
+ padding-left: 1em;
+ border-left: 3px solid #999;
+ color: #333;
+ }
+ #printBook pre {
+ background: #f2f2f2;
+ padding: 8px;
+ white-space: pre-wrap;
+ font-size: 0.9em;
+ }
+ #printBook code {
+ font-family: SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace;
+ background: #f2f2f2;
+ padding: 1px 4px;
+ border-radius: 3px;
+ font-size: 0.9em;
+ }
+ #printBook pre code { padding: 0; background: none; }
+ #printBook a { color: #1a0dab; }
+ #printBook hr {
+ border: none;
+ border-top: 1px solid #000;
+ margin: 1em 0;
+ }
+ #printBook table { border-collapse: collapse; width: 100%; }
+ #printBook th, #printBook td {
+ border: 1px solid #666;
+ padding: 4px 8px;
+ }
+ #printBook img { max-width: 100%; }
}
/* ---- editor toolbar ---- */
diff --git a/src/renderer/index.html b/src/renderer/index.html
index e4eafc0..b19a630 100644
--- a/src/renderer/index.html
+++ b/src/renderer/index.html
@@ -185,6 +185,7 @@
+
diff --git a/tests/selftest-scenario.js b/tests/selftest-scenario.js
index 06d3421..b6c6a2a 100644
--- a/tests/selftest-scenario.js
+++ b/tests/selftest-scenario.js
@@ -270,7 +270,7 @@ const BOOK2_DIR = __BOOKDIR2_VALUE__;
const rawCss = await (await fetch("./index.css")).text();
out.printBreakRule = /break-before\s*:\s*page/.test(rawCss);
out.printBreakFallback = /page-break-before\s*:\s*always/.test(rawCss);
- out.printBreakScoped = /#fullBookHost\s+\.cm-line\.chapter-break/.test(rawCss);
+ out.printBreakScoped = /\.print-chapter\s*{[^}]*break-before\s*:\s*page/.test(rawCss);
document.getElementById("copyAllBtn").click();
await new Promise((r) => setTimeout(r, 200));
@@ -435,6 +435,55 @@ const BOOK2_DIR = __BOOKDIR2_VALUE__;
pdfRes.bytes > 500 &&
pdfRes.pdfOk === true;
+ // The printed book comes from the plain-HTML #printBook container (the
+ // print stylesheet hides the whole app and shows #printBook instead), so
+ // the PDF can never be limited to the CodeMirror viewport. Verify the
+ // container is hidden on screen and carries every chapter.
+ const printBookEl = document.getElementById("printBook");
+ out.printBookHiddenOnScreen = getComputedStyle(printBookEl).display === "none";
+ const printChapters = () =>
+ Array.from(document.querySelectorAll("#printBook .print-chapter"));
+ const pcsAtSmallBook = printChapters();
+ out.printBookChapterCount = pcsAtSmallBook.length;
+ out.printBookHasAllChapters =
+ pcsAtSmallBook.length === 3 &&
+ pcsAtSmallBook.some((s) => s.textContent.includes("Gamma text")) &&
+ pcsAtSmallBook.some((s) => s.textContent.includes("Beta body without heading")) &&
+ pcsAtSmallBook.some((s) => s.querySelector("h1")?.textContent === "Empty");
+
+ // Long book: every line must reach the print DOM — the guard against the
+ // "PDF does not show the full book" bug where a long manuscript was cut
+ // off after the first screenful.
+ const longLines = Array.from(
+ { length: 1500 },
+ (_, i) => `Line ${i} of a very long chapter that must all reach the printed book.`
+ );
+ const veryLongId = (await window.folio.createChapter("Very Long")).id;
+ await window.folio.saveChapter(veryLongId, longLines.join("\n"));
+ await new Promise((r) => setTimeout(r, 500));
+ document.getElementById("fullBookBtn").click();
+ await new Promise((r) => setTimeout(r, 400));
+ const printBookText = printBookEl.textContent;
+ out.printBookHasFirstLongLine = printBookText.includes("Line 0 of a very long chapter");
+ out.printBookHasLastLongLine = printBookText.includes("Line 1499 of a very long chapter");
+ out.printBookLongChapterCount = printChapters().length;
+ out.printBookLongFull =
+ out.printBookHasFirstLongLine &&
+ out.printBookHasLastLongLine &&
+ printChapters().length === 4;
+
+ const pdfLongRes = await window.folio.exportPdf();
+ out.pdfLongExport =
+ pdfLongRes && "filePath" in pdfLongRes
+ ? { filePath: pdfLongRes.filePath, bytes: pdfLongRes.bytes, pdfOk: pdfLongRes.pdfOk }
+ : pdfLongRes;
+ out.pdfLongExportOk =
+ !!pdfLongRes &&
+ !("error" in pdfLongRes) &&
+ "filePath" in pdfLongRes &&
+ pdfLongRes.bytes > pdfRes.bytes * 2 &&
+ pdfLongRes.pdfOk === true;
+
const okFlags = [
"dndMoved",
"kbMoved",
@@ -484,6 +533,10 @@ const BOOK2_DIR = __BOOKDIR2_VALUE__;
"pdfExportPdfVisible",
"pdfStatusShown",
"pdfExportOk",
+ "printBookHiddenOnScreen",
+ "printBookHasAllChapters",
+ "printBookLongFull",
+ "pdfLongExportOk",
];
out.all = okFlags.every((f) => out[f] === true);
} catch (e) {