feat(export): Chunk #6 Export Pipeline — 7 exporters, ExportDialog & tests
Add the @pubscript/exporters package exporting a flattened ProseMirror project to Markdown, HTML, EPUB, DOCX, ODT and PDF, plus a deterministic generatedAt timestamp threaded through every format (ZIP entry dates, PDF /ID, DOCX dc:created/modified, EPUB id, HTML/XML footers) so golden files are byte-stable. Wire an ExportDialog into the web app (Topbar trigger, App route) with format selection, theme pick, and download helpers. Add round-trip and golden-file determinism tests (22) covering non-empty output, byte-identical re-exports, Markdown round-trip through the editor, and EPUB/ODT ZIP + mimetype + XML validation.
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* 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 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" : theme.fontFamily.split(",")[0]!.replace(/"/g, "").trim() || "helvetica", 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(theme.fontFamily.split(",")[0]!.replace(/"/g, "").trim() || "helvetica", "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(theme.fontFamily.split(",")[0]!.replace(/"/g, "").trim() || "helvetica", "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(theme.fontFamily.split(",")[0]!.replace(/"/g, "").trim() || "helvetica", "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")));
|
||||
}
|
||||
Reference in New Issue
Block a user