/** * PDF exporter (via jsPDF). * * Renders the flattened project to a single-page-per-chapter A4 PDF. Text is * laid out with a small manual word-wrap engine so inline marks (bold/italic/ * code/link) can be drawn with the correct font per run. The theme supplies * colours, fonts, and page margins. * * jsPDF is a browser-oriented library but runs unmodified in Node, so the same * code path serves both the web app's download button and the CLI. */ import jsPDF from "jspdf"; import { normalizePdfId } from "./determinism.js"; import type { Block, FlatProject, Mark } from "./model.js"; import { getTheme, type Theme } from "./themes.js"; interface Run { text: string; marks: Mark[]; } function buildRuns(text: string, marks: Mark[]): Run[] { if (!marks.length) return [{ text, marks: [] }]; const boundaries = new Set([0, text.length]); for (const m of marks) { boundaries.add(Math.max(0, Math.min(text.length, m.start))); boundaries.add(Math.max(0, Math.min(text.length, m.end))); } const points = Array.from(boundaries).sort((a, b) => a - b); const runs: Run[] = []; for (let i = 0; i < points.length - 1; i++) { const start = points[i]!; const end = points[i + 1]!; const runMarks = marks.filter((m) => m.start <= start && m.end >= end); runs.push({ text: text.slice(start, end), marks: runMarks }); } return runs; } function resolvePdfFont(fontFamily: string): "times" | "helvetica" | "courier" { const lower = fontFamily.toLowerCase(); if (lower.includes("mono") || lower.includes("courier") || lower.includes("consolas")) { return "courier"; } if ( lower.includes("serif") || lower.includes("garamond") || lower.includes("georgia") || lower.includes("iowan") || lower.includes("times") ) { return "times"; } return "helvetica"; } function setRunFont(doc: jsPDF, run: Run, theme: Theme): void { const hasCode = run.marks.some((m) => m.type === "code"); const hasBold = run.marks.some((m) => m.type === "bold"); const hasItalic = run.marks.some((m) => m.type === "italic"); doc.setFont( hasCode ? "courier" : resolvePdfFont(theme.fontFamily), hasBold ? "bold" : hasItalic ? "italic" : "normal", ); } function pageGeometry() { const pageWidth = 595.28; // A4 in points const pageHeight = 841.89; const margin = 56; const maxWidth = pageWidth - 2 * margin; const lineHeight = 14; return { pageWidth, pageHeight, margin, maxWidth, lineHeight }; } function newPage(doc: jsPDF, theme: Theme): void { doc.setFont(resolvePdfFont(theme.fontFamily), "normal"); doc.setTextColor(hexToRgb(theme.colors.text)); doc.setFillColor(hexToRgb(theme.colors.background)); doc.rect(0, 0, doc.internal.pageSize.getWidth(), doc.internal.pageSize.getHeight(), "F"); } function hexToRgb(hex: string): string { const clean = hex.replace("#", ""); const r = parseInt(clean.slice(0, 2), 16); const g = parseInt(clean.slice(2, 4), 16); const b = parseInt(clean.slice(4, 6), 16); return `rgb(${r}, ${g}, ${b})`; } function renderParagraph( doc: jsPDF, text: string, marks: Mark[], x: number, y: number, maxWidth: number, lineHeight: number, theme: Theme ): number { const runs = buildRuns(text, marks); let cx = x; let cy = y; for (const run of runs) { setRunFont(doc, run, theme); for (const word of run.text.split(" ")) { const wordWidth = doc.getTextWidth(word + " "); if (cx + wordWidth > x + maxWidth && cx > x) { cy += lineHeight; cx = x; } doc.text(word, cx, cy); cx += wordWidth; } } return cy; } function renderBlocks( doc: jsPDF, blocks: Block[], theme: Theme ): void { const { margin, maxWidth, lineHeight } = pageGeometry(); let y = margin; for (const block of blocks) { switch (block.type) { case "heading": { const level = Math.max(1, Math.min(6, block.level ?? 1)); const size = level === 1 ? 24 : level === 2 ? 18 : 14; doc.setFontSize(size); doc.setFont(resolvePdfFont(theme.fontFamily), "bold"); doc.setTextColor(hexToRgb(theme.colors.heading)); y += size * 0.9; y = renderParagraph(doc, block.text ?? "", block.marks ?? [], margin, y, maxWidth, lineHeight * 1.2, theme); y += lineHeight * 0.6; break; } case "paragraph": { doc.setFontSize(12); doc.setFont(resolvePdfFont(theme.fontFamily), "normal"); doc.setTextColor(hexToRgb(theme.colors.text)); y += lineHeight; y = renderParagraph(doc, block.text ?? "", block.marks ?? [], margin, y, maxWidth, lineHeight, theme); y += lineHeight * 0.4; break; } case "sceneBreak": { y += lineHeight; doc.setFontSize(12); doc.setFont("helvetica", "italic"); doc.setTextColor(hexToRgb(theme.colors.muted)); doc.text("***", margin + maxWidth / 2, y, { align: "center" }); y += lineHeight * 0.6; break; } case "blockquote": { y += lineHeight; doc.setFontSize(12); doc.setFont("helvetica", "italic"); doc.setTextColor(hexToRgb(theme.colors.muted)); y = renderParagraph(doc, block.text ?? "", block.marks ?? [], margin, y, maxWidth - 28, lineHeight, theme); y += lineHeight * 0.4; break; } case "codeBlock": { y += lineHeight; doc.setFontSize(9); doc.setFont("courier", "normal"); doc.setTextColor(hexToRgb(theme.colors.text)); for (const line of (block.text ?? "").split("\n")) { y += lineHeight * 0.7; doc.text(line, margin, y, { maxWidth: maxWidth }); } y += lineHeight * 0.4; break; } case "list": { for (const item of block.children ?? []) { y += lineHeight; doc.setFontSize(12); doc.setFont("helvetica", "normal"); doc.setTextColor(hexToRgb(theme.colors.text)); const prefix = block.listType === "ordered" ? "• " : "• "; y = renderParagraph(doc, prefix + (item.text ?? ""), item.marks ?? [], margin, y, maxWidth, lineHeight, theme); y += lineHeight * 0.3; } break; } case "footnote": { y += lineHeight; doc.setFontSize(9); doc.setFont("helvetica", "italic"); doc.setTextColor(hexToRgb(theme.colors.muted)); y = renderParagraph(doc, block.text ?? "", [], margin, y, maxWidth, lineHeight * 0.8, theme); y += lineHeight * 0.4; break; } } // Add a page break when we run low on vertical space. if (y > pageGeometry().pageHeight - margin) { doc.addPage(); y = margin; } } } export interface PdfOptions { theme?: Theme; } /** * Serialize a flattened project to a PDF (`.pdf` bytes). * * @param project the flattened project (frontmatter + blocks) * @param options theme */ export async function exportPdf(project: FlatProject, options?: PdfOptions): Promise { const theme = options?.theme ?? getTheme(undefined); const fm = project.frontmatter; const { margin } = pageGeometry(); const doc = new jsPDF({ unit: "pt", format: "a4" }); newPage(doc, theme); // Title. doc.setFontSize(28); doc.setFont("helvetica", "bold"); doc.setTextColor(hexToRgb(theme.colors.heading)); doc.text(fm.title, margin, margin + 20, { maxWidth: pageGeometry().maxWidth }); // Author. if (fm.author) { doc.setFontSize(14); doc.setFont("helvetica", "italic"); doc.setTextColor(hexToRgb(theme.colors.muted)); doc.text(fm.author, margin, margin + 48, { maxWidth: pageGeometry().maxWidth }); } renderBlocks(doc, project.blocks, theme); return normalizePdfId(new Uint8Array(doc.output("arraybuffer"))); }