247 lines
7.7 KiB
TypeScript
247 lines
7.7 KiB
TypeScript
/**
|
|
* 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<number>([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<Uint8Array> {
|
|
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")));
|
|
}
|