// Zero-dependency .docx writer — the Word counterpart to pptx.ts. A model emits structured JSON // blocks and gets a real, editable Word document: headings, paragraphs, bullet/numbered lists, // tables, images and page breaks. A .docx is an OPC zip of WordprocessingML parts; we generate the // minimal valid set (content types, package rels, document, styles, numbering, media). import { readFileSync } from "node:fs"; import { extname, isAbsolute, resolve } from "node:path"; import { EMU, IMAGE_TYPES, XML_DECL, esc, imageSize, relsXml, zip } from "./ooxml.ts"; export interface DocxTable { headers?: string[]; rows: string[][]; } /** One block of the document, discriminated by `type` so weak models can't mis-shape it. */ export type DocxBlock = | { type: "heading"; text: string; level?: 1 | 2 | 3 } | { type: "paragraph"; text: string; bold?: boolean; italic?: boolean } | { type: "bullets"; items: (string | { text: string; level?: number })[] } | { type: "numbered"; items: string[] } | { type: "table"; headers?: string[]; rows: string[][] } | { type: "chart"; data: { label: string; value: number }[]; unit?: string } // bar chart, no image needed | { type: "metrics"; items: { value: string; label?: string }[] } // KPI row | { type: "image"; path: string; width?: number } // width in inches (default 6) | { type: "pageBreak" }; export interface DocxSpec { title?: string; // rendered as the document title, and set in document properties subtitle?: string; author?: string; blocks: DocxBlock[]; } // --------------------------------------------------------------------------- // WordprocessingML fragments. Word measures text in half-points and layout in twips (1/1440 in). const W_NS = `xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" ` + `xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" ` + `xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" ` + `xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" ` + `xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture"`; const COLOR = { heading: "1A2536", body: "24292F", accent: "4472C4", muted: "5F6368" }; const NUM_BULLET = 1; // numId in numbering.xml const NUM_ORDERED = 2; /** A run of text with optional emphasis. */ function run(text: string, o: { sz?: number; bold?: boolean; italic?: boolean; color?: string } = {}): string { const props = `` + (o.bold ? `` : "") + (o.italic ? `` : "") + (o.color ? `` : "") + (o.sz ? `` : "") + ``; // xml:space="preserve" keeps leading/trailing spaces Word would otherwise trim return `${props}${esc(text)}`; } function paragraph(runs: string, opts: { style?: string; numId?: number; level?: number; spaceAfter?: number; align?: "center" } = {}): string { const pPr = `` + (opts.style ? `` : "") + (opts.numId ? `` : "") + (opts.align ? `` : "") + `` + ``; return `${pPr}${runs}`; } function tableXml(t: DocxTable): string { const rows = Array.isArray(t.rows) ? t.rows : []; const cols = Math.max(t.headers?.length ?? 0, ...rows.map((r) => (Array.isArray(r) ? r.length : 0)), 1); const cellW = Math.floor(9360 / cols); // usable page width in twips (8.5in - 1in margins) const cell = (text: string, header: boolean): string => `` + (header ? `` : "") + `${paragraph(run(String(text ?? ""), { sz: 20, bold: header, color: header ? COLOR.heading : COLOR.body }), { spaceAfter: 0 })}`; const borders = `` + ["top", "left", "bottom", "right", "insideH", "insideV"].map((s) => ``).join("") + ``; const head = t.headers?.length ? `${t.headers.map((h) => cell(h, true)).join("")}` : ""; const body = rows.map((r) => `${Array.from({ length: cols }, (_, i) => cell((r ?? [])[i] ?? "", false)).join("")}`).join(""); return `${borders}${head}${body}`; } /** Bar chart as a borderless table: label | bar (a shaded cell scaled to the value) | value. * Word has no lightweight inline shape, and a shaded cell renders identically everywhere. */ function chartXml(data: { label: string; value: number }[], unit = ""): string { const rows = data.slice(0, 12).filter((d) => d && typeof d.label === "string"); if (!rows.length) return ""; const max = Math.max(...rows.map((d) => Math.abs(Number(d.value) || 0)), 1); const LABEL_W = 2200; const TRACK_W = 6000; const VALUE_W = 1100; const cell = (w: number, inner: string, shade?: string): string => `${shade ? `` : ""}${inner}`; const body = rows .map((d) => { const v = Number(d.value) || 0; const filled = Math.max(1, Math.round((Math.abs(v) / max) * TRACK_W)); const rest = Math.max(1, TRACK_W - filled); // the bar is a nested 2-cell table so the filled portion scales precisely const bar = `` + ["top", "left", "bottom", "right", "insideH", "insideV"].map((s) => ``).join("") + `` + cell(filled, paragraph(run(" ", { sz: 16 }), { spaceAfter: 0 }), COLOR.accent) + cell(rest, paragraph(run(" ", { sz: 16 }), { spaceAfter: 0 }), "EDF0F6") + ``; return ( `` + cell(LABEL_W, paragraph(run(d.label, { sz: 20 }), { spaceAfter: 0 })) + cell(TRACK_W, bar) + cell(VALUE_W, paragraph(run(`${v}${unit}`, { sz: 20, bold: true, color: COLOR.heading }), { spaceAfter: 0 })) + `` ); }) .join(""); const noBorders = `` + ["top", "left", "bottom", "right", "insideH", "insideV"].map((s) => ``).join("") + ``; return `${noBorders}${body}`; } /** KPI row: one shaded cell per metric, big figure over a caption. */ function metricsXml(items: { value: string; label?: string }[]): string { const cells = items.slice(0, 4).filter((m) => m && (m.value != null || m.label)); if (!cells.length) return ""; const w = Math.floor(9360 / cells.length); const body = cells .map( (m) => `` + paragraph(run(String(m.value ?? ""), { sz: 44, bold: true, color: COLOR.accent }), { align: "center", spaceAfter: 40 }) + (m.label ? paragraph(run(m.label, { sz: 18, color: COLOR.muted }), { align: "center", spaceAfter: 0 }) : paragraph("", { spaceAfter: 0 })) + ``, ) .join(""); const noBorders = `` + ["top", "left", "bottom", "right", "insideH", "insideV"].map((s) => ``).join("") + ``; return `${noBorders}${body}`; } function imageXml(relId: string, cx: number, cy: number, id: number): string { return ( `` + `` + `` + `` + `` + `` + `` + `` ); } function stylesXml(): string { const style = (id: string, name: string, sz: number, color: string, bold: boolean, before: number): string => `` + `` + `${bold ? "" : ""}`; return ( `${XML_DECL}` + `` + `` + `` + style("Title", "Title", 56, COLOR.heading, true, 0) + style("Heading1", "heading 1", 36, COLOR.heading, true, 320) + style("Heading2", "heading 2", 28, COLOR.heading, true, 280) + style("Heading3", "heading 3", 24, COLOR.accent, true, 240) + `` + `` + `` ); } /** Two lists: bullets (with nesting) and a decimal ordered list. */ function numberingXml(): string { const bulletLvls = Array.from({ length: 5 }, (_, i) => { const char = i % 2 ? "o" : "•"; return ( `` + `` ); }).join(""); const orderedLvls = Array.from({ length: 5 }, (_, i) => `` + ``, ).join(""); return ( `${XML_DECL}` + `${bulletLvls}` + `${orderedLvls}` + `` + `` + `` ); } // --------------------------------------------------------------------------- export function buildDocx(spec: DocxSpec, cwd = process.cwd()): Buffer { const raw = spec.blocks ?? []; if (!Array.isArray(raw) || raw.length === 0) throw new Error("generate_docx: `blocks` must be a non-empty array of block objects"); if (raw.length > 2000) throw new Error("generate_docx: too many blocks (max 2000)"); const entries: { name: string; data: Buffer }[] = []; const rels: { id: string; type: string; target: string }[] = [ { id: "rId1", type: "styles", target: "styles.xml" }, { id: "rId2", type: "numbering", target: "numbering.xml" }, ]; const body: string[] = []; const usedImageExts = new Set(); let media = 0; let nextRel = 3; let drawingId = 1; if (spec.title) body.push(paragraph(run(spec.title), { style: "Title" })); if (spec.subtitle) body.push(paragraph(run(spec.subtitle), { style: "Subtitle" })); raw.forEach((b, i) => { const blk = (typeof b === "string" ? { type: "paragraph", text: b } : (b ?? {})) as DocxBlock; switch (blk.type) { case "heading": { const lvl = Math.min(Math.max(Number(blk.level) || 1, 1), 3); body.push(paragraph(run(String(blk.text ?? "")), { style: `Heading${lvl}` })); break; } case "paragraph": body.push(paragraph(run(String(blk.text ?? ""), { bold: blk.bold, italic: blk.italic }))); break; case "bullets": { const items = Array.isArray(blk.items) ? blk.items : []; for (const it of items) { const text = typeof it === "string" ? it : String(it?.text ?? ""); const level = Math.min(Math.max(typeof it === "string" ? 0 : Number(it?.level) || 0, 0), 4); body.push(paragraph(run(text), { numId: NUM_BULLET, level, spaceAfter: 60 })); } break; } case "numbered": { const items = Array.isArray(blk.items) ? blk.items : []; for (const it of items) body.push(paragraph(run(String(it ?? "")), { numId: NUM_ORDERED, spaceAfter: 60 })); break; } case "table": body.push(tableXml({ headers: blk.headers, rows: blk.rows })); body.push(paragraph("", { spaceAfter: 160 })); // Word needs a paragraph after a table break; case "chart": body.push(chartXml(Array.isArray(blk.data) ? blk.data : [], blk.unit ?? "")); body.push(paragraph("", { spaceAfter: 160 })); break; case "metrics": body.push(metricsXml(Array.isArray(blk.items) ? blk.items : [])); body.push(paragraph("", { spaceAfter: 160 })); break; case "image": { const p = String(blk.path ?? ""); const abs = isAbsolute(p) ? p : resolve(cwd, p); const kind = IMAGE_TYPES[extname(abs).toLowerCase()]; if (!kind) throw new Error(`generate_docx: block ${i + 1}: unsupported image type "${extname(abs)}" (png/jpg/gif)`); const data = readFileSync(abs); // clear ENOENT if missing media++; const ext = kind === "jpeg" ? "jpeg" : kind; usedImageExts.add(ext); entries.push({ name: `word/media/image${media}.${ext}`, data }); const relId = `rId${nextRel++}`; rels.push({ id: relId, type: "image", target: `media/image${media}.${ext}` }); const nat = imageSize(data, kind); const maxW = Math.round((Number(blk.width) > 0 ? Number(blk.width) : 6) * EMU); const cx = maxW; const cy = nat && nat.w > 0 ? Math.round((nat.h / nat.w) * maxW) : Math.round(maxW * 0.6); body.push(imageXml(relId, cx, cy, drawingId++)); break; } case "pageBreak": body.push(``); break; default: throw new Error(`generate_docx: block ${i + 1}: unknown type "${(blk as { type?: string }).type}" (heading|paragraph|bullets|numbered|table|chart|metrics|image|pageBreak)`); } }); // Letter page with 1" margins. const sectPr = ``; entries.push({ name: "word/document.xml", data: Buffer.from(`${XML_DECL}${body.join("")}${sectPr}`, "utf8") }); entries.push({ name: "word/styles.xml", data: Buffer.from(stylesXml(), "utf8") }); entries.push({ name: "word/numbering.xml", data: Buffer.from(numberingXml(), "utf8") }); entries.push({ name: "word/_rels/document.xml.rels", data: Buffer.from(relsXml(rels), "utf8") }); const defaults = [ ``, ``, ...[...usedImageExts].map((e) => ``), ].join(""); entries.push({ name: "[Content_Types].xml", data: Buffer.from( `${XML_DECL}${defaults}` + `` + `` + `` + `` + ``, "utf8", ), }); entries.push({ name: "_rels/.rels", data: Buffer.from( relsXml([ { id: "rId1", type: "officeDocument", target: "word/document.xml" }, { id: "rId2", type: "metadata/core-properties", target: "docProps/core.xml" }, ]), "utf8", ), }); entries.push({ name: "docProps/core.xml", data: Buffer.from( `${XML_DECL}` + `${esc(spec.title ?? "Document")}${esc(spec.author ?? "ada")}`, "utf8", ), }); return zip(entries); }