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