190 lines
8.3 KiB
JavaScript
190 lines
8.3 KiB
JavaScript
|
|
// 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, ">")
|
||
|
|
.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(`<w:rFonts w:ascii="${xmlEscape(family)}" w:hAnsi="${xmlEscape(family)}"/>`);
|
||
|
|
if (run.bold) props.push("<w:b/>");
|
||
|
|
if (run.italic) props.push("<w:i/>");
|
||
|
|
// Word measures type in half-points.
|
||
|
|
const size = Math.max(2, Math.round((run.size || defaults.size) * 2));
|
||
|
|
props.push(`<w:sz w:val="${size}"/><w:szCs w:val="${size}"/>`);
|
||
|
|
const colour = (run.color || "").replace("#", "").toUpperCase();
|
||
|
|
if (/^[0-9A-F]{6}$/.test(colour) && colour !== "000000") props.push(`<w:color w:val="${colour}"/>`);
|
||
|
|
const rPr = props.length ? `<w:rPr>${props.join("")}</w:rPr>` : "";
|
||
|
|
return `<w:r>${rPr}<w:t xml:space="preserve">${xmlEscape(run.text)}</w:t></w:r>`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function paragraphXml(p, defaults) {
|
||
|
|
const props = [];
|
||
|
|
if (p.heading && HEADING_STYLE[p.heading]) props.push(`<w:pStyle w:val="${HEADING_STYLE[p.heading]}"/>`);
|
||
|
|
if (p.align && p.align !== "left") props.push(`<w:jc w:val="${p.align}"/>`);
|
||
|
|
const pPr = props.length ? `<w:pPr>${props.join("")}</w:pPr>` : "";
|
||
|
|
const runs = (p.runs || []).map((r) => runXml(r, defaults)).join("");
|
||
|
|
return `<w:p>${pPr}${runs}</w:p>`;
|
||
|
|
}
|
||
|
|
|
||
|
|
const CONTENT_TYPES = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||
|
|
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
||
|
|
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
||
|
|
<Default Extension="xml" ContentType="application/xml"/>
|
||
|
|
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
|
||
|
|
<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>
|
||
|
|
</Types>`;
|
||
|
|
|
||
|
|
const ROOT_RELS = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||
|
|
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||
|
|
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
|
||
|
|
</Relationships>`;
|
||
|
|
|
||
|
|
const DOC_RELS = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||
|
|
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||
|
|
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
|
||
|
|
</Relationships>`;
|
||
|
|
|
||
|
|
function stylesXml(defaults) {
|
||
|
|
const size = Math.max(2, Math.round(defaults.size * 2));
|
||
|
|
const heading = (n, pts) => `<w:style w:type="paragraph" w:styleId="Heading${n}">
|
||
|
|
<w:name w:val="heading ${n}"/><w:basedOn w:val="Normal"/><w:qFormat/>
|
||
|
|
<w:pPr><w:keepNext/><w:spacing w:before="${240 - n * 40}" w:after="${120 - n * 20}"/><w:outlineLvl w:val="${n - 1}"/></w:pPr>
|
||
|
|
<w:rPr><w:b/><w:sz w:val="${pts * 2}"/><w:szCs w:val="${pts * 2}"/></w:rPr></w:style>`;
|
||
|
|
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||
|
|
<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||
|
|
<w:docDefaults><w:rPrDefault><w:rPr>
|
||
|
|
<w:rFonts w:ascii="${xmlEscape(defaults.family)}" w:hAnsi="${xmlEscape(defaults.family)}"/>
|
||
|
|
<w:sz w:val="${size}"/><w:szCs w:val="${size}"/>
|
||
|
|
</w:rPr></w:rPrDefault></w:docDefaults>
|
||
|
|
<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/><w:qFormat/></w:style>
|
||
|
|
${heading(1, 20)}${heading(2, 16)}${heading(3, 13)}
|
||
|
|
</w:styles>`;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 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 = `<w:sectPr><w:pgSz w:w="${tw(page.widthPt)}" w:h="${tw(page.heightPt)}"/>`
|
||
|
|
+ `<w:pgMar w:top="1134" w:right="1134" w:bottom="1134" w:left="1134" w:header="709" w:footer="709" w:gutter="0"/></w:sectPr>`;
|
||
|
|
const document = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||
|
|
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||
|
|
<w:body>
|
||
|
|
${body}
|
||
|
|
${sect}
|
||
|
|
</w:body>
|
||
|
|
</w:document>`;
|
||
|
|
|
||
|
|
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) },
|
||
|
|
]);
|
||
|
|
}
|