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 <u>, <span style=...> and <div align=...>
    pass through and render instead of appearing literally,
  - ==highlight== rendered as <mark> (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.
This commit is contained in:
avi 2026-08-18 10:59:28 -05:00
commit d510a48742
4 changed files with 232 additions and 14 deletions

View file

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

View file

@ -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;

View file

@ -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 <u>, <span style=...>
// and <div align=...> survive instead of showing as literal text,
// - Obsidian-style ==highlight== rendered as <mark>,
// - 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== -> <mark>text</mark>
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 = () => "<mark>";
md.renderer.rules.highlight_close = () => "</mark>";
}
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) =>
`<div class="${tokens[idx].attrGet("class") ?? "callout"}">`;
md.renderer.rules.callout_close = () => "</div>";
}
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) => `<section class="print-chapter">${md.render(c)}</section>`)
.join("");
}