Files
PubScript/packages/exporters/src/docx.ts
T
Schwitzz 978858362d 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.
2026-10-26 23:30:47 -05:00

225 lines
6.8 KiB
TypeScript

/**
* 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
);
}