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