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 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) => `<section class="print-chapter">${md.render(c)}</section>`)
.join("");
}
async function selectFullBook() {

View file

@ -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 ---- */

View file

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