Fix PDF export to always include the full book

The previous export printed the CodeMirror editor, which only keeps a
virtualized window of lines in the DOM; on larger books the PDF could come
out truncated (only what was rendered around the viewport). Replace the
print path with a dedicated #printBook container: the renderer renders each
chapter of the assembled manuscript to plain HTML with markdown-it, and the
print stylesheet hides the entire app and shows that container instead.
Plain HTML paginates reliably regardless of editor viewport or scroll
position, every chapter after the first still starts on its own page, and
output is a properly formatted manuscript instead of raw markdown. Keep the
screen-only chapter-break separator in the Full Book editor.

Extend the e2e selftest to assert #printBook holds every chapter, is hidden
on screen, and that a 1500-line chapter reaches the print DOM and exported
PDF in full.
This commit is contained in:
avi 2026-08-18 10:41:56 -05:00
commit d2118f080f
4 changed files with 151 additions and 58 deletions

View file

@ -1,4 +1,5 @@
import type { BookMeta } from "../main/project.js"; import type { BookMeta } from "../main/project.js";
import MarkdownIt from "markdown-it";
import { alertMessage, confirmMessage, promptInput } from "./dialog.js"; import { alertMessage, confirmMessage, promptInput } from "./dialog.js";
import { createMdEditor, editorText, setEditorText } from "./md-editor.js"; import { createMdEditor, editorText, setEditorText } from "./md-editor.js";
import { Decoration, EditorView, type DecorationSet } from "@codemirror/view"; import { Decoration, EditorView, type DecorationSet } from "@codemirror/view";
@ -75,6 +76,12 @@ export type FolioAPI = {
const $ = (id: string) => document.getElementById(id) as HTMLElement; 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 { interface ChapterEntry {
id: string; id: string;
title: string; title: string;
@ -117,6 +124,7 @@ export function initBook(api: FolioAPI) {
const fullBookView = $("fullBookView"); const fullBookView = $("fullBookView");
const fullBookHost = $("fullBookHost"); const fullBookHost = $("fullBookHost");
const fullBookStats = $("fullBookStats"); const fullBookStats = $("fullBookStats");
const printBook = $("printBook");
const copyAllBtn = $("copyAllBtn") as HTMLButtonElement; const copyAllBtn = $("copyAllBtn") as HTMLButtonElement;
const fullBookStatus = $("fullBookStatus"); const fullBookStatus = $("fullBookStatus");
const exportPdfBtn = $("exportPdfBtn") as HTMLButtonElement; 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 // 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 // record where every chapter begins in the assembled text so the view can
// mark chapter boundaries for page breaks without touching the chapter files. // 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(); 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 book = res as BookMeta;
const parts: string[] = []; const parts: string[] = [];
const chapterStarts: number[] = []; const chapterStarts: number[] = [];
@ -560,17 +568,20 @@ export function initBook(api: FolioAPI) {
offset += joined.length + 2; offset += joined.length + 2;
} }
} }
return { text: parts.join("\n\n"), chapterStarts }; return { text: parts.join("\n\n"), chapterStarts, chapters: parts };
} }
async function renderFullBook() { async function renderFullBook() {
const { text, chapterStarts } = await assembleFullBook(); const { text, chapterStarts, chapters } = await assembleFullBook();
setEditorText(fullBookEditor, text); setEditorText(fullBookEditor, text);
const ranges = chapterStarts.slice(1).map((from) => chapterBreakLine.range(from)); const ranges = chapterStarts.slice(1).map((from) => chapterBreakLine.range(from));
fullBookEditor.dispatch({ fullBookEditor.dispatch({
effects: setChapterBreaks.of(Decoration.set(ranges, true)), effects: setChapterBreaks.of(Decoration.set(ranges, true)),
}); });
fullBookStats.textContent = `words: ${countWords(text)} · chars: ${text.length}`; fullBookStats.textContent = `words: ${countWords(text)} · chars: ${text.length}`;
printBook.innerHTML = chapters
.map((c) => `<section class="print-chapter">${md.render(c)}</section>`)
.join("");
} }
async function selectFullBook() { async function selectFullBook() {

View file

@ -519,79 +519,107 @@ html, body {
padding-top: 1.5rem; 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 { @media print {
html, body, #root { html, body {
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; color: #000 !important;
background: #fff !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; display: none !important;
} }
.editor, .full-book-view, .editor-panes { #printBook {
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 {
display: block !important; display: block !important;
font-family: var(--font-serif);
font-size: 12pt;
line-height: 1.6;
color: #000;
background: #fff;
} }
#fullBookHost .cm-content { /* Every chapter after the first starts on a new page. The first chapter
white-space: pre-wrap !important; keeps its natural position (no blank page before it). */
} #printBook .print-chapter {
/* 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 {
break-before: page; break-before: page;
page-break-before: always; 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 ---- */ /* ---- editor toolbar ---- */

View file

@ -185,6 +185,7 @@
</div> </div>
</main> </main>
</div> </div>
<div id="printBook" aria-hidden="true"></div>
<div class="modal-backdrop hidden" id="modalBackdrop"> <div class="modal-backdrop hidden" id="modalBackdrop">
<div class="modal" role="dialog" aria-modal="true"> <div class="modal" role="dialog" aria-modal="true">
<h3 class="modal-title" id="modalTitle"></h3> <h3 class="modal-title" id="modalTitle"></h3>

View file

@ -270,7 +270,7 @@ const BOOK2_DIR = __BOOKDIR2_VALUE__;
const rawCss = await (await fetch("./index.css")).text(); const rawCss = await (await fetch("./index.css")).text();
out.printBreakRule = /break-before\s*:\s*page/.test(rawCss); out.printBreakRule = /break-before\s*:\s*page/.test(rawCss);
out.printBreakFallback = /page-break-before\s*:\s*always/.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(); document.getElementById("copyAllBtn").click();
await new Promise((r) => setTimeout(r, 200)); await new Promise((r) => setTimeout(r, 200));
@ -435,6 +435,55 @@ const BOOK2_DIR = __BOOKDIR2_VALUE__;
pdfRes.bytes > 500 && pdfRes.bytes > 500 &&
pdfRes.pdfOk === true; 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 = [ const okFlags = [
"dndMoved", "dndMoved",
"kbMoved", "kbMoved",
@ -484,6 +533,10 @@ const BOOK2_DIR = __BOOKDIR2_VALUE__;
"pdfExportPdfVisible", "pdfExportPdfVisible",
"pdfStatusShown", "pdfStatusShown",
"pdfExportOk", "pdfExportOk",
"printBookHiddenOnScreen",
"printBookHasAllChapters",
"printBookLongFull",
"pdfLongExportOk",
]; ];
out.all = okFlags.every((f) => out[f] === true); out.all = okFlags.every((f) => out[f] === true);
} catch (e) { } catch (e) {