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,224 @@
|
||||
/**
|
||||
* DOCX exporter (via the `docx` library, v8.x).
|
||||
*
|
||||
* Builds a WordprocessingML document from the flattened project. Headings,
|
||||
* paragraphs, scene breaks, blockquotes, code blocks, lists, and tables are
|
||||
* all mapped to native Word elements so the output is editable and re-exportable.
|
||||
* The theme supplies fonts, colours, and heading sizes.
|
||||
*/
|
||||
import {
|
||||
AlignmentType,
|
||||
Document,
|
||||
Paragraph,
|
||||
Packer,
|
||||
Table,
|
||||
TableCell,
|
||||
TableRow,
|
||||
TextRun,
|
||||
WidthType,
|
||||
} from "docx";
|
||||
import type { Block, FlatProject, Mark } from "./model.js";
|
||||
import { getTheme, type Theme } from "./themes.js";
|
||||
import { normalizeDocx } from "./determinism.js";
|
||||
|
||||
function runToTextRun(run: { text: string; marks: Mark[] }, theme: Theme): TextRun {
|
||||
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");
|
||||
const hasStrike = run.marks.some((m) => m.type === "strike");
|
||||
const color = (run.marks.find((m) => m.type === "color")?.attrs?.color as string | undefined) ?? theme.colors.text;
|
||||
return new TextRun({
|
||||
text: run.text,
|
||||
bold: hasBold,
|
||||
italics: hasItalic,
|
||||
strike: hasStrike,
|
||||
font: hasCode ? "Courier New" : theme.fontFamily,
|
||||
color,
|
||||
});
|
||||
}
|
||||
|
||||
function runsForInline(text: string, marks: Mark[], theme: Theme): TextRun[] {
|
||||
if (!marks.length) return [new TextRun({ text, color: theme.colors.text })];
|
||||
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: TextRun[] = [];
|
||||
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(runToTextRun({ text: text.slice(start, end), marks: runMarks }, theme));
|
||||
}
|
||||
return runs;
|
||||
}
|
||||
|
||||
function headingLevel(level: number): string {
|
||||
if (level <= 1) return "Heading1";
|
||||
if (level <= 2) return "Heading2";
|
||||
if (level <= 3) return "Heading3";
|
||||
if (level <= 4) return "Heading4";
|
||||
if (level <= 5) return "Heading5";
|
||||
return "Heading6";
|
||||
}
|
||||
|
||||
function blockToParagraph(block: Block, theme: Theme): Paragraph {
|
||||
switch (block.type) {
|
||||
case "heading": {
|
||||
const level = block.level ?? 1;
|
||||
return new Paragraph({
|
||||
heading: headingLevel(level) as never,
|
||||
spacing: { after: 200 },
|
||||
children: runsForInline(block.text ?? "", block.marks ?? [], theme),
|
||||
}) as unknown as Paragraph;
|
||||
}
|
||||
case "paragraph":
|
||||
return new Paragraph({
|
||||
spacing: { after: 200 },
|
||||
children: runsForInline(block.text ?? "", block.marks ?? [], theme),
|
||||
});
|
||||
case "sceneBreak":
|
||||
return new Paragraph({
|
||||
alignment: AlignmentType.CENTER,
|
||||
spacing: { after: 200 },
|
||||
children: [new TextRun({ text: "***", italics: true, color: theme.colors.muted })],
|
||||
});
|
||||
case "blockquote":
|
||||
return new Paragraph({
|
||||
indent: { left: 360 },
|
||||
spacing: { after: 200 },
|
||||
children: [
|
||||
new TextRun({
|
||||
text: (block.children ?? []).map((c) => c.text ?? "").join("\n"),
|
||||
italics: true,
|
||||
color: theme.colors.muted,
|
||||
}),
|
||||
],
|
||||
});
|
||||
case "codeBlock":
|
||||
return new Paragraph({
|
||||
spacing: { after: 200 },
|
||||
children: [
|
||||
new TextRun({
|
||||
text: block.text ?? "",
|
||||
font: "Courier New",
|
||||
size: 16,
|
||||
color: theme.colors.text,
|
||||
}),
|
||||
],
|
||||
});
|
||||
case "footnote":
|
||||
return new Paragraph({
|
||||
indent: { firstLine: 360 },
|
||||
spacing: { after: 200 },
|
||||
children: [
|
||||
new TextRun({ text: block.text ?? "", italics: true, color: theme.colors.muted, size: 18 }),
|
||||
],
|
||||
});
|
||||
case "list": {
|
||||
const items = block.children ?? [];
|
||||
const pad = block.listType === "ordered" ? "1. " : "- ";
|
||||
return new Paragraph({
|
||||
spacing: { after: 120 },
|
||||
children: [
|
||||
new TextRun({ text: pad, bold: true, color: theme.colors.heading }),
|
||||
...runsForInline(items[0]?.text ?? "", items[0]?.marks ?? [], theme),
|
||||
],
|
||||
});
|
||||
}
|
||||
default:
|
||||
return new Paragraph({ spacing: { after: 200 }, children: [] });
|
||||
}
|
||||
}
|
||||
|
||||
function buildTable(blocks: Block[], theme: Theme): Table | null {
|
||||
const rows = blocks.filter((b) => b.type === "table");
|
||||
if (!rows.length) return null;
|
||||
const header = rows[0];
|
||||
const headers = header?.children ?? [];
|
||||
const body = rows.slice(1);
|
||||
const headerRow = new TableRow({
|
||||
children: headers.map((h) =>
|
||||
new TableCell({
|
||||
children: [
|
||||
new Paragraph({
|
||||
children: [
|
||||
new TextRun({ text: h.text ?? "", bold: true, color: theme.colors.background }),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
),
|
||||
});
|
||||
const bodyRows = body.map((row) =>
|
||||
new TableRow({
|
||||
children: (row.children ?? []).map((cell) =>
|
||||
new TableCell({
|
||||
children: [
|
||||
new Paragraph({
|
||||
children: [new TextRun({ text: cell.text ?? "" })],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
),
|
||||
}),
|
||||
);
|
||||
return new Table({
|
||||
width: { size: 100, type: WidthType.PERCENTAGE },
|
||||
rows: [headerRow, ...bodyRows],
|
||||
});
|
||||
}
|
||||
|
||||
export interface DocxOptions {
|
||||
themeId?: string;
|
||||
generatedAt?: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a flattened project to a DOCX (`.docx` bytes).
|
||||
*
|
||||
* @param project the flattened project (frontmatter + blocks)
|
||||
* @param options theme id (defaults to the built-in "ink" theme)
|
||||
*/
|
||||
export async function exportDocx(project: FlatProject, options?: DocxOptions): Promise<Uint8Array> {
|
||||
const theme = getTheme(options?.themeId);
|
||||
const fm = project.frontmatter;
|
||||
|
||||
const children: (Paragraph | Table)[] = [];
|
||||
if (fm.title) {
|
||||
children.push(
|
||||
new Paragraph({
|
||||
heading: "Title" as never,
|
||||
spacing: { after: 120 },
|
||||
children: [new TextRun({ text: fm.title, bold: true, size: 32, color: theme.colors.heading })],
|
||||
}) as unknown as Paragraph
|
||||
);
|
||||
}
|
||||
if (fm.author) {
|
||||
children.push(
|
||||
new Paragraph({
|
||||
spacing: { after: 300 },
|
||||
children: [new TextRun({ text: fm.author, italics: true, color: theme.colors.muted })],
|
||||
})
|
||||
);
|
||||
}
|
||||
for (const block of project.blocks) {
|
||||
if (block.type === "table") continue; // handled separately
|
||||
children.push(blockToParagraph(block, theme));
|
||||
}
|
||||
const table = buildTable(project.blocks, theme);
|
||||
if (table) children.push(table);
|
||||
|
||||
const doc = new Document({
|
||||
sections: [{ children }],
|
||||
});
|
||||
|
||||
const buffer = await Packer.toBuffer(doc);
|
||||
const generatedAt = options?.generatedAt ?? new Date().toISOString();
|
||||
return normalizeDocx(
|
||||
new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength),
|
||||
generatedAt
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user