Files
PubScript/packages/exporters/src/pdf.ts
T

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")));
}