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:
parent
778b480ea2
commit
d2118f080f
4 changed files with 151 additions and 58 deletions
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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 ---- */
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue