From d510a48742ed06d38bea32283cc7be90a2902551 Mon Sep 17 00:00:00 2001 From: avi Date: Tue, 18 Aug 2026 10:59:28 -0500 Subject: [PATCH] Preserve formatting in the exported PDF The printed book is rendered from the same markdown the editor writes, but markdown-it escaped HTML and did not understand Folio's extensions, so underlines, text/highlight colors, alignments, ==highlights==, callouts and wikilinks all showed up as raw source text in the PDF. Render the print book with a purpose-built print-format renderer that keeps every feature: - html: true so the toolbar's , and
pass through and render instead of appearing literally, - ==highlight== rendered as (new inline rule), - Obsidian callouts (> [!note] Title) rendered as styled divs with a bold title (new core rule), - wikilinks resolved to links via the shared wikilink plugin. Add matching print styles for mark, underline and callout types, and e2e assertions that none of these features leak as raw source into #printBook. --- src/renderer/book.ts | 30 ++++---- src/renderer/index.css | 37 ++++++++++ src/renderer/print-format.ts | 136 +++++++++++++++++++++++++++++++++++ tests/selftest-scenario.js | 43 +++++++++++ 4 files changed, 232 insertions(+), 14 deletions(-) create mode 100644 src/renderer/print-format.ts diff --git a/src/renderer/book.ts b/src/renderer/book.ts index 34c16fc..dfa6c83 100644 --- a/src/renderer/book.ts +++ b/src/renderer/book.ts @@ -1,7 +1,8 @@ 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 { renderPrintChapters } from "./print-format.js"; +import type { WikilinkTarget } from "./wikilinks.js"; import { Decoration, EditorView, type DecorationSet } from "@codemirror/view"; import { StateEffect, StateField } from "@codemirror/state"; import { redo, selectAll, undo, indentLess, indentMore } from "@codemirror/commands"; @@ -76,12 +77,6 @@ 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; @@ -538,16 +533,25 @@ 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[]; chapters: string[] }> { + async function assembleFullBook(): Promise<{ + text: string; + chapterStarts: number[]; + chapters: string[]; + targets: WikilinkTarget[]; + }> { const res = await api.getBookMeta(); - if (!res || typeof res !== "object" || "error" in res) return { text: "", chapterStarts: [], chapters: [] }; + if (!res || typeof res !== "object" || "error" in res) { + return { text: "", chapterStarts: [], chapters: [], targets: [] }; + } const book = res as BookMeta; const parts: string[] = []; const chapterStarts: number[] = []; + const targets: WikilinkTarget[] = []; let offset = 0; for (const id of book.chapterOrder) { const ch = book.chapters[id]; if (!ch) continue; + targets.push({ id: ch.id, title: ch.title }); const contentRes = await api.getChapterContent(ch.id); const content = typeof contentRes === "string" ? contentRes : ""; const body = content.replace(/^\n+/, "").replace(/\s+$/, ""); @@ -568,20 +572,18 @@ export function initBook(api: FolioAPI) { offset += joined.length + 2; } } - return { text: parts.join("\n\n"), chapterStarts, chapters: parts }; + return { text: parts.join("\n\n"), chapterStarts, chapters: parts, targets }; } async function renderFullBook() { - const { text, chapterStarts, chapters } = await assembleFullBook(); + const { text, chapterStarts, chapters, targets } = 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(""); + printBook.innerHTML = renderPrintChapters(chapters, targets); } async function selectFullBook() { diff --git a/src/renderer/index.css b/src/renderer/index.css index e591f50..a4fba77 100644 --- a/src/renderer/index.css +++ b/src/renderer/index.css @@ -609,6 +609,43 @@ html, body { } #printBook pre code { padding: 0; background: none; } #printBook a { color: #1a0dab; } + #printBook u { text-decoration: underline; } + #printBook mark { + background: #ffeb99; + padding: 0 2px; + border-radius: 2px; + } + #printBook .callout { + border-left: 4px solid #3b82f6; + background: #f0f6ff; + padding: 0.6em 1em; + margin: 0 0 0.8em; + } + #printBook .callout-title { + font-weight: 700; + margin: 0 0 0.3em; + } + #printBook .callout > :last-child { margin-bottom: 0; } + #printBook .callout-note, + #printBook .callout-info, + #printBook .callout-tip, + #printBook .callout-important { + border-left-color: #3b82f6; + background: #f0f6ff; + } + #printBook .callout-warning, + #printBook .callout-caution { + border-left-color: #f59e0b; + background: #fff8ef; + } + #printBook .callout-danger { + border-left-color: #e11d48; + background: #fff1f2; + } + #printBook .callout-success { + border-left-color: #16a34a; + background: #f0fdf4; + } #printBook hr { border: none; border-top: 1px solid #000; diff --git a/src/renderer/print-format.ts b/src/renderer/print-format.ts new file mode 100644 index 0000000..b16f486 --- /dev/null +++ b/src/renderer/print-format.ts @@ -0,0 +1,136 @@ +// Print rendering for the PDF-exported manuscript (#printBook). +// +// The PDF is produced by printing plain HTML, so this renderer has to keep +// every formatting feature Folio's editor can write. Standard markdown is +// handled by markdown-it; the extensions on top of it are: +// - raw HTML passthrough (html: true) so the toolbar's , +// and
survive instead of showing as literal text, +// - Obsidian-style ==highlight== rendered as , +// - Obsidian callouts (> [!note] Title) rendered as styled divs, +// - wikilinks ([[Title]]) resolved to links via the shared wikilink plugin. +import MarkdownIt from "markdown-it"; +import type { + MarkdownIt as MarkdownItInstance, + StateCore, + StateInline, +} from "markdown-it"; +import { wikilinkPlugin, type WikilinkTarget } from "./wikilinks.js"; + +// ==text== -> text +function highlightPlugin(md: MarkdownItInstance) { + const parse = (state: StateInline, silent: boolean): boolean => { + const src = state.src; + const max = state.posMax; + const start = state.pos; + if (start + 2 > max) return false; + if (src.charCodeAt(start) !== 0x3d /* = */ || src.charCodeAt(start + 1) !== 0x3d) + return false; + let end = -1; + for (let i = start + 2; i < max - 1; i++) { + const ch = src.charCodeAt(i); + if (ch === 0x0a /* newline */) return false; + if (ch === 0x3d && src.charCodeAt(i + 1) === 0x3d) { + end = i; + break; + } + } + if (end === -1) return false; + const content = src.slice(start + 2, end); + if (!content.trim()) return false; + if (silent) { + state.pos = end + 2; + return true; + } + state.push("highlight_open", "mark", 1); + state.push("text", "", 0).content = content; + state.push("highlight_close", "mark", -1); + state.pos = end + 2; + return true; + }; + md.inline.ruler.before("emphasis", "highlight", parse); + md.renderer.rules.highlight_open = () => ""; + md.renderer.rules.highlight_close = () => ""; +} + +const CALLOUT_RE = /^\s*\[!([a-zA-Z0-9_-]+)\]\s*(.*)$/; + +// > [!note] Title followed by "> body" lines becomes a styled callout div. +// Runs after inline parsing so blockquote paragraphs already have children. +function calloutPlugin(md: MarkdownItInstance) { + md.core.ruler.after("inline", "callout", (state: StateCore) => { + const tokens = state.tokens; + for (let i = 0; i < tokens.length; i++) { + const open = tokens[i]; + if (open.type !== "blockquote_open") continue; + let depth = 1; + let closeIdx = -1; + for (let j = i + 1; j < tokens.length; j++) { + const t = tokens[j]; + if (t.type === "blockquote_open") depth += 1; + else if (t.type === "blockquote_close") { + depth -= 1; + if (depth === 0) { + closeIdx = j; + break; + } + } + } + if (closeIdx === -1) continue; + const paraOpen = tokens[i + 1]; + if (!paraOpen || paraOpen.type !== "paragraph_open") continue; + const inline = tokens[i + 2]; + if (!inline || inline.type !== "inline" || !inline.children) continue; + const first = inline.children[0]; + if (!first || first.type !== "text") continue; + const m = CALLOUT_RE.exec(first.content); + if (!m) continue; + + const calloutType = m[1].toLowerCase(); + const title = m[2].trim(); + + open.type = "callout_open"; + open.tag = "div"; + open.attrs = [["class", `callout callout-${calloutType}`]]; + const close = tokens[closeIdx]; + close.type = "callout_close"; + close.tag = "div"; + + // Strip the "[!type] Title" marker from the first paragraph and drop + // any text tokens that became empty as a result (plus the softbreak the + // marker line left behind). + first.content = ""; + inline.children = inline.children.filter( + (c) => !(c.type === "text" && c.content === "") + ); + if (inline.children[0]?.type === "softbreak") inline.children.shift(); + + if (title) { + const titleOpen = new state.Token("paragraph_open", "p", 1); + titleOpen.attrs = [["class", "callout-title"]]; + const titleInline = new state.Token("inline", "", 0); + titleInline.content = title; + const titleText = new state.Token("text", "", 0); + titleText.content = title; + titleInline.children = [titleText]; + const titleClose = new state.Token("paragraph_close", "p", -1); + tokens.splice(i + 1, 0, titleOpen, titleInline, titleClose); + } + } + }); + md.renderer.rules.callout_open = (tokens, idx) => + `
`; + md.renderer.rules.callout_close = () => "
"; +} + +export function renderPrintChapters( + chapters: string[], + targets: WikilinkTarget[] +): string { + const md = new MarkdownIt({ html: true }); + md.use(wikilinkPlugin, { chapters: targets }); + md.use(highlightPlugin); + md.use(calloutPlugin); + return chapters + .map((c) => ``) + .join(""); +} \ No newline at end of file diff --git a/tests/selftest-scenario.js b/tests/selftest-scenario.js index b6c6a2a..96e29e0 100644 --- a/tests/selftest-scenario.js +++ b/tests/selftest-scenario.js @@ -484,6 +484,48 @@ const BOOK2_DIR = __BOOKDIR2_VALUE__; pdfLongRes.bytes > pdfRes.bytes * 2 && pdfLongRes.pdfOk === true; + // Formatting must survive into the printed book: markdown-it is configured + // to pass through the toolbar's HTML (, ,
), + // ==highlight== becomes , callouts become styled divs, and + // wikilinks resolve to links — none of them may show up as raw source. + const fmtId = (await window.folio.createChapter("Formatting")).id; + await window.folio.saveChapter( + fmtId, + [ + "**bold** *italic* ~~strike~~ ==highlight== under", + 'red marked', + "", + "> [!note] Callout title", + "> callout body", + "", + "[[Beta Renamed]]", + ].join("\n") + ); + await new Promise((r) => setTimeout(r, 500)); + document.getElementById("fullBookBtn").click(); + await new Promise((r) => setTimeout(r, 400)); + const pbInner = document.getElementById("printBook").innerHTML; + const pbText = document.getElementById("printBook").textContent; + out.printFmtBold = /bold<\/strong>/.test(pbInner); + out.printFmtMark = /highlight<\/mark>/.test(pbInner); + out.printFmtUnderline = /under<\/u>/.test(pbInner); + out.printFmtColor = /red<\/span>/.test(pbInner); + out.printFmtNoRawEq = !pbText.includes("==highlight=="); + out.printFmtCallout = /class="callout callout-note"/.test(pbInner); + out.printFmtNoRawCallout = !pbText.includes("[!note]"); + out.printFmtWikilink = /class="wikilink internal"/.test(pbInner); + out.printFmtNoRawWikilink = !pbText.includes("[[Beta Renamed]]"); + out.printFmtOk = + out.printFmtBold && + out.printFmtMark && + out.printFmtUnderline && + out.printFmtColor && + out.printFmtNoRawEq && + out.printFmtCallout && + out.printFmtNoRawCallout && + out.printFmtWikilink && + out.printFmtNoRawWikilink; + const okFlags = [ "dndMoved", "kbMoved", @@ -537,6 +579,7 @@ const BOOK2_DIR = __BOOKDIR2_VALUE__; "printBookHasAllChapters", "printBookLongFull", "pdfLongExportOk", + "printFmtOk", ]; out.all = okFlags.every((f) => out[f] === true); } catch (e) {