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) {