theseus/bundled-addons/pdf-editor/lib/docx.js
Local Dev c4f7251860 feat(pdf-editor): convert a PDF to Word, and say what that costs
A PDF does not contain paragraphs. It contains glyphs with coordinates, and
there is no heading, no list, no table and no guaranteed reading order —
only runs of characters that happen to sit near each other. Converting to
Word means working out where the paragraphs were, from geometry. That
inference is the whole feature, and it is sometimes wrong, so this is called
a conversion and never an edit, and the dialog reports what it found before
anything is written.

Lines are grouped by baseline, runs joined with the spaces a PDF only implies
by leaving a gap, and paragraphs ended where the next line sits unusually far
below, is indented, or where the previous one stopped short of the measure.
Headings come from size relative to the body — which is the most common size
on the page, not the average, because a page of 11 pt under a 28 pt title
averages to something that is neither. Bold and italic come from the font's
name, the only place a PDF records them.

What it refuses to fake is as important. A page set in columns is reported,
not silently interleaved. A page with no text says so, and says why: it is an
image of writing, and reading that needs character recognition this editor
does not have. Tables become plain paragraphs rather than an invented grid,
because a wrong table is harder to repair than no table.

The .docx is written here rather than by a vendored builder: a Word file is a
zip of five XML parts, and the subset that can honestly be produced —
paragraphs of styled runs — is about two hundred lines. Vendoring a document
library would have added 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 rather than deflated, which keeps a compressor out of the
add-on; the CRCs are the part that cannot be skipped, since Word calls the
file corrupt rather than naming the part that upset it.

Text replaced in place converts as replaced. Converting would otherwise hand
back the words the user had just edited away.

Checked by taking the output apart — every CRC verified, both XML parts run
through a real parser — and then, because that is still marking my own
homework, by opening the result in the Word editor extension, where mammoth
reads it with none of my code involved.
2026-09-22 22:06:54 +02:00

189 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
// 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) },
]);
}