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