// A very small .docx writer. // // A Word document is a ZIP of XML parts, and the subset this editor can // honestly produce — paragraphs of styled runs — needs five of them. Writing // those by hand costs about two hundred lines. Vendoring a document builder to // do it would cost another megabyte on top of the four pdf.js and pdf-lib // already weigh, to generate markup we would still have to get right. // // Entries are STORED, not deflated. Word accepts them, the files are small // enough that compression buys little, and it keeps a compressor out of the // add-on. The one thing that cannot be skipped is the CRC of each entry: get // it wrong and Word reports the file as corrupt rather than telling you which // part upset it. const enc = new TextEncoder(); // ---- zip -------------------------------------------------------------- let CRC_TABLE = null; function crcTable() { if (CRC_TABLE) return CRC_TABLE; CRC_TABLE = new Uint32Array(256); for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; CRC_TABLE[n] = c >>> 0; } return CRC_TABLE; } function crc32(bytes) { const t = crcTable(); let c = 0xffffffff; for (let i = 0; i < bytes.length; i++) c = t[(c ^ bytes[i]) & 0xff] ^ (c >>> 8); return (c ^ 0xffffffff) >>> 0; } class Writer { constructor() { this.parts = []; this.length = 0; } bytes(b) { this.parts.push(b); this.length += b.length; } u16(n) { this.bytes(new Uint8Array([n & 255, (n >>> 8) & 255])); } u32(n) { this.bytes(new Uint8Array([n & 255, (n >>> 8) & 255, (n >>> 16) & 255, (n >>> 24) & 255])); } concat() { const out = new Uint8Array(this.length); let at = 0; for (const p of this.parts) { out.set(p, at); at += p.length; } return out; } } /** @param {{name:string, data:Uint8Array}[]} files */ export function zip(files) { const w = new Writer(); const central = []; for (const f of files) { const name = enc.encode(f.name); const crc = crc32(f.data); const offset = w.length; w.u32(0x04034b50); w.u16(20); w.u16(0); w.u16(0); // version, flags, method 0 = stored w.u16(0); w.u16(0); // mod time / date, left at zero w.u32(crc); w.u32(f.data.length); w.u32(f.data.length); w.u16(name.length); w.u16(0); w.bytes(name); w.bytes(f.data); central.push({ name, crc, size: f.data.length, offset }); } const dirStart = w.length; for (const c of central) { w.u32(0x02014b50); w.u16(20); w.u16(20); w.u16(0); w.u16(0); w.u16(0); w.u16(0); w.u32(c.crc); w.u32(c.size); w.u32(c.size); w.u16(c.name.length); w.u16(0); w.u16(0); w.u16(0); w.u16(0); w.u32(0); w.u32(c.offset); w.bytes(c.name); } const dirSize = w.length - dirStart; w.u32(0x06054b50); w.u16(0); w.u16(0); w.u16(central.length); w.u16(central.length); w.u32(dirSize); w.u32(dirStart); w.u16(0); return w.concat(); } // ---- xml -------------------------------------------------------------- export function xmlEscape(s) { return String(s ?? "") .replace(/&/g, "&").replace(//g, ">") .replace(/"/g, """) // XML 1.0 has no way to represent these at all, and Word rejects the file // rather than ignoring them. PDFs do contain them — stray control bytes // from odd encodings — so they are dropped here rather than at the source. .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, ""); } const HEADING_STYLE = { 1: "Heading1", 2: "Heading2", 3: "Heading3" }; function runXml(run, defaults) { const props = []; const family = run.family || defaults.family; if (family) props.push(``); if (run.bold) props.push(""); if (run.italic) props.push(""); // Word measures type in half-points. const size = Math.max(2, Math.round((run.size || defaults.size) * 2)); props.push(``); const colour = (run.color || "").replace("#", "").toUpperCase(); if (/^[0-9A-F]{6}$/.test(colour) && colour !== "000000") props.push(``); const rPr = props.length ? `${props.join("")}` : ""; return `${rPr}${xmlEscape(run.text)}`; } function paragraphXml(p, defaults) { const props = []; if (p.heading && HEADING_STYLE[p.heading]) props.push(``); if (p.align && p.align !== "left") props.push(``); const pPr = props.length ? `${props.join("")}` : ""; const runs = (p.runs || []).map((r) => runXml(r, defaults)).join(""); return `${pPr}${runs}`; } const CONTENT_TYPES = ` `; const ROOT_RELS = ` `; const DOC_RELS = ` `; function stylesXml(defaults) { const size = Math.max(2, Math.round(defaults.size * 2)); const heading = (n, pts) => ` `; return ` ${heading(1, 20)}${heading(2, 16)}${heading(3, 13)} `; } /** * Build a .docx from paragraphs. * * @param {object} doc * @param {{heading?:number, align?:string, runs:{text:string,size?:number,bold?:boolean,italic?:boolean,color?:string,family?:string}[]}[]} doc.paragraphs * @param {{widthPt:number, heightPt:number}} doc.page page size, in points * @param {{size:number, family:string}} [doc.defaults] * @returns {Uint8Array} the .docx bytes */ export function buildDocx({ paragraphs, page, defaults }) { const def = { size: 11, family: "Calibri", ...(defaults || {}) }; // Word works in twips: 20 to the point. const tw = (pt) => Math.max(1, Math.round(pt * 20)); const body = paragraphs.map((p) => paragraphXml(p, def)).join("\n"); const sect = `` + ``; const document = ` ${body} ${sect} `; return zip([ { name: "[Content_Types].xml", data: enc.encode(CONTENT_TYPES) }, { name: "_rels/.rels", data: enc.encode(ROOT_RELS) }, { name: "word/_rels/document.xml.rels", data: enc.encode(DOC_RELS) }, { name: "word/styles.xml", data: enc.encode(stylesXml(def)) }, { name: "word/document.xml", data: enc.encode(document) }, ]); }