theseus/bundled-addons/docx-editor/lib/pkg.js
Local Dev 56b3f2f206 feat(docx-editor): ship it in the build, updated over the first-party channel
Moving it out of the build left it with no way in. Settings can only install
from the community catalogue, so a first-party extension that isn't bundled
has a working update channel and no first copy for anyone to update — the
mechanism was all there and the front door was missing.

So it goes back beside screenshot, aegis and pdf-editor: seeded into every
profile by the build, listed under "Built into Theseus", and kept current
between releases by the operator-signed channel at
theseus.x/extensions/docx-editor/. That is the arrangement docs/ADDON-UPDATES.md
describes, and the one the signing script was written for.

About 400 KB compressed in the installer, most of it the vendored editor
libraries — next to the ~4 MB of pdf.js that pdf-editor already ships, the
weight argument for keeping it out didn't survive contact with the numbers.

The end-to-end driver goes back to checking that a fresh profile seeds it,
which is the property that actually matters now.
2026-09-21 22:47:13 +02:00

490 lines
22 KiB
JavaScript

// Package-level work on a .docx: everything that happens at the zip layer,
// either side of mammoth and the docx builder.
//
// Two jobs:
//
// scan(zip) — walk the original package and report what's in it that the
// v1 editor can't render, so the user is told BEFORE they
// edit rather than after they've lost something.
//
// graft(...) — the save-side half of the preservation deal. docx (the npm
// builder) always emits a brand-new package, so anything that
// lives outside the document body would vanish on save. We
// take the parts that survive a body rewrite unharmed —
// headers, footers, footnotes, endnotes, the style catalogue,
// the theme, page setup — and carry them across from the
// original into the freshly built package, re-wiring
// relationship ids and content types as we go.
//
// Body-level things we can't model (textboxes, shapes, equations, content
// controls, fields) are NOT preserved; scan() names them so the loss is
// visible. See ROUND-TRIP.md for the full ledger.
(function (root, factory) {
const api = factory();
if (typeof module === "object" && module.exports) module.exports = api;
root.DocxEditor = Object.assign(root.DocxEditor || {}, { pkg: api });
})(typeof globalThis !== "undefined" ? globalThis : this, function () {
"use strict";
const V = () => globalThis.DOCXV;
const W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
const R_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
const CT_NS = "http://schemas.openxmlformats.org/package/2006/content-types";
const PR_NS = "http://schemas.openxmlformats.org/package/2006/relationships";
const CONTENT_TYPES = {
header: "application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml",
footer: "application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml",
footnotes: "application/vnd.openxmlformats-officedocument.wordprocessingml.footnotes+xml",
endnotes: "application/vnd.openxmlformats-officedocument.wordprocessingml.endnotes+xml",
};
const REL_TYPES = {
header: R_NS + "/header",
footer: R_NS + "/footer",
footnotes: R_NS + "/footnotes",
endnotes: R_NS + "/endnotes",
image: R_NS + "/image",
};
function parseXml(text) {
const doc = new DOMParser().parseFromString(text, "application/xml");
const err = doc.getElementsByTagName("parsererror")[0];
if (err) throw new Error("malformed XML in package: " + err.textContent.slice(0, 200));
return doc;
}
function serializeXml(doc) {
const body = new XMLSerializer().serializeToString(doc);
return body.startsWith("<?xml")
? body
: '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\r\n' + body;
}
// Namespace-agnostic child lookup: packages in the wild are inconsistent
// about prefixes, and getElementsByTagNameNS is the only reliable route.
function kids(el, ns, local) {
return Array.prototype.filter.call(el.childNodes,
(n) => n.nodeType === 1 && n.namespaceURI === ns && n.localName === local);
}
function firstKid(el, ns, local) { return kids(el, ns, local)[0] || null; }
function descendants(doc, ns, local) {
return Array.prototype.slice.call(doc.getElementsByTagNameNS(ns, local));
}
async function loadZip(bytes) {
return V().JSZip.loadAsync(bytes);
}
async function textOf(zip, path) {
const f = zip.file(path);
return f ? f.async("string") : null;
}
// ---------------------------------------------------------------- scan ---
// What the scan can say about a feature.
// preserved — survives a save untouched (carried across by graft()).
// lossy — the content survives but not exactly as Word wrote it.
// dropped — gone on save; the user needs to know before editing.
// blocked — dangerous to flatten silently; editing is gated on consent.
const FEATURES = [
{ key: "trackedChanges", level: "blocked", label: "Tracked changes",
note: "Saving would silently accept every pending revision.",
test: (d) => descendants(d, W_NS, "ins").length + descendants(d, W_NS, "del").length > 0 },
{ key: "comments", level: "blocked", label: "Comments",
note: "Comment anchors and the comment text are not carried across.",
test: (d, z) => !!z.file("word/comments.xml") &&
descendants(d, W_NS, "commentRangeStart").length > 0 },
{ key: "headers", level: "preserved", label: "Headers",
test: (d, z) => z.file(/^word\/header\d*\.xml$/).length > 0 },
{ key: "footers", level: "preserved", label: "Footers",
test: (d, z) => z.file(/^word\/footer\d*\.xml$/).length > 0 },
{ key: "footnotes", level: "preserved", label: "Footnotes",
test: (d, z) => !!z.file("word/footnotes.xml") &&
descendants(d, W_NS, "footnoteReference").length > 0 },
{ key: "endnotes", level: "preserved", label: "Endnotes",
test: (d, z) => !!z.file("word/endnotes.xml") &&
descendants(d, W_NS, "endnoteReference").length > 0 },
{ key: "pageSetup", level: "preserved", label: "Page size and margins",
test: (d) => descendants(d, W_NS, "sectPr").length > 0 },
{ key: "equations", level: "dropped", label: "Equations",
note: "OMML equations are removed from the body.",
test: (d) => d.getElementsByTagNameNS("http://schemas.openxmlformats.org/officeDocument/2006/math", "oMath").length > 0 },
{ key: "shapes", level: "dropped", label: "Shapes, text boxes and WordArt",
test: (d) => descendants(d, W_NS, "pict").length > 0 ||
d.getElementsByTagNameNS("http://schemas.openxmlformats.org/markup-compatibility/2006", "AlternateContent").length > 0 },
{ key: "contentControls", level: "dropped", label: "Content controls",
test: (d) => descendants(d, W_NS, "sdt").length > 0 },
{ key: "fields", level: "dropped", label: "Fields (page numbers, tables of contents, cross-references)",
note: "Field codes are dropped; the text Word last calculated is kept.",
test: (d) => descendants(d, W_NS, "fldSimple").length > 0 ||
descendants(d, W_NS, "instrText").length > 0 },
{ key: "bookmarks", level: "dropped", label: "Bookmarks",
test: (d) => descendants(d, W_NS, "bookmarkStart")
.some((b) => !(b.getAttributeNS(W_NS, "name") || "").startsWith("_GoBack") ) },
{ key: "sections", level: "dropped", label: "Multiple sections",
note: "Only the first section's page setup is kept; section breaks are lost.",
test: (d) => descendants(d, W_NS, "sectPr").length > 1 },
{ key: "columns", level: "dropped", label: "Multi-column layout",
test: (d) => descendants(d, W_NS, "cols").some((c) => {
const n = c.getAttributeNS(W_NS, "num");
return n && parseInt(n, 10) > 1;
}) },
{ key: "metafiles", level: "dropped", label: "Metafile pictures (WMF/EMF)",
note: "Word's vector picture format. Browsers can't display it and it can't be written back, so those pictures are lost on save.",
test: (d, z) => z.file(/^word\/media\/.*\.(wmf|emf)$/i).length > 0 },
{ key: "paragraphBorders", level: "lossy", label: "Paragraph borders and shading",
note: "A rule under an empty paragraph is kept as a horizontal rule; other borders are dropped.",
test: (d) => descendants(d, W_NS, "pBdr").length > 0 || descendants(d, W_NS, "shd").length > 0 },
{ key: "tabStops", level: "lossy", label: "Custom tab stops",
note: "Tab characters are kept, custom stop positions are not.",
test: (d) => descendants(d, W_NS, "tabs").length > 0 },
];
async function scan(zip) {
const xml = await textOf(zip, "word/document.xml");
if (!xml) throw new Error("not a Word document: word/document.xml is missing");
const doc = parseXml(xml);
const found = [];
for (const f of FEATURES) {
let hit = false;
try { hit = !!f.test(doc, zip); } catch { hit = false; }
if (hit) found.push({ key: f.key, level: f.level, label: f.label, note: f.note || "" });
}
return {
features: found,
blocked: found.filter((f) => f.level === "blocked"),
dropped: found.filter((f) => f.level === "dropped"),
lossy: found.filter((f) => f.level === "lossy"),
preserved: found.filter((f) => f.level === "preserved"),
};
}
// ------------------------------------------------------------ page setup ---
// The first sectPr, mapped onto what the docx builder wants. Word writes
// these in twips; the builder takes twips too, so this is mostly a rename.
async function readSectionSetup(zip) {
const xml = await textOf(zip, "word/document.xml");
if (!xml) return null;
const doc = parseXml(xml);
const sect = descendants(doc, W_NS, "sectPr")[0];
if (!sect) return null;
const num = (el, attr) => {
if (!el) return null;
const v = el.getAttributeNS(W_NS, attr);
return /^-?\d+$/.test(v || "") ? parseInt(v, 10) : null;
};
const pgSz = firstKid(sect, W_NS, "pgSz");
const pgMar = firstKid(sect, W_NS, "pgMar");
const setup = { page: {} };
if (pgSz) {
const w = num(pgSz, "w"), h = num(pgSz, "h");
const orient = pgSz.getAttributeNS(W_NS, "orient");
if (w && h) setup.page.size = { width: w, height: h, orientation: orient === "landscape" ? "landscape" : "portrait" };
}
if (pgMar) {
const m = {};
for (const [k, a] of [["top", "top"], ["right", "right"], ["bottom", "bottom"],
["left", "left"], ["header", "header"], ["footer", "footer"], ["gutter", "gutter"]]) {
const v = num(pgMar, a);
if (v !== null) m[k] = v;
}
if (Object.keys(m).length) setup.page.margin = m;
}
setup.titlePg = !!firstKid(sect, W_NS, "titlePg");
// Which header/footer parts this section points at, by type.
setup.refs = [];
for (const kind of ["header", "footer"]) {
for (const ref of kids(sect, W_NS, kind + "Reference")) {
setup.refs.push({
kind,
type: ref.getAttributeNS(W_NS, "type") || "default",
rId: ref.getAttributeNS(R_NS, "id") || "",
});
}
}
return setup;
}
// docProps/core.xml — cheap to carry, and losing the author of a document
// is the kind of small betrayal people notice.
async function readCoreProps(zip) {
const xml = await textOf(zip, "docProps/core.xml");
if (!xml) return {};
let doc; try { doc = parseXml(xml); } catch { return {}; }
const pick = (ns, local) => {
const el = doc.getElementsByTagNameNS(ns, local)[0];
return el && el.textContent ? el.textContent : undefined;
};
const DC = "http://purl.org/dc/elements/1.1/";
const CP = "http://schemas.openxmlformats.org/package/2006/metadata/core-properties";
return {
title: pick(DC, "title"),
creator: pick(DC, "creator"),
description: pick(DC, "description"),
subject: pick(DC, "subject"),
keywords: pick(CP, "keywords"),
lastModifiedBy: pick(CP, "lastModifiedBy"),
};
}
// --------------------------------------------------------------- graft ---
function relsPathFor(partPath) {
const i = partPath.lastIndexOf("/");
return partPath.slice(0, i) + "/_rels" + partPath.slice(i) + ".rels";
}
function nextRelId(relsDoc) {
let max = 0;
for (const r of descendants(relsDoc, PR_NS, "Relationship")) {
const m = /^rId(\d+)$/.exec(r.getAttribute("Id") || "");
if (m) max = Math.max(max, parseInt(m[1], 10));
}
return (n) => "rId" + (max + n);
}
function addRelationship(relsDoc, id, type, target) {
const el = relsDoc.createElementNS(PR_NS, "Relationship");
el.setAttribute("Id", id);
el.setAttribute("Type", type);
el.setAttribute("Target", target);
relsDoc.documentElement.appendChild(el);
}
function addOverride(ctDoc, partName, contentType) {
const already = descendants(ctDoc, CT_NS, "Override")
.some((o) => o.getAttribute("PartName") === partName);
if (already) return;
const el = ctDoc.createElementNS(CT_NS, "Override");
el.setAttribute("PartName", partName);
el.setAttribute("ContentType", contentType);
ctDoc.documentElement.appendChild(el);
}
function addDefaultExt(ctDoc, ext, contentType) {
const already = descendants(ctDoc, CT_NS, "Default")
.some((o) => (o.getAttribute("Extension") || "").toLowerCase() === ext.toLowerCase());
if (already) return;
const el = ctDoc.createElementNS(CT_NS, "Default");
el.setAttribute("Extension", ext);
el.setAttribute("ContentType", contentType);
ctDoc.documentElement.insertBefore(el, ctDoc.documentElement.firstChild);
}
// Copy one part plus anything its own .rels file points at. Media gets a
// fresh name whenever the generated package already has a file there, and
// the part's rels are rewritten to match — otherwise a header's logo would
// quietly replace an image from the body.
async function copyPartWithRels(orig, gen, partPath, usedMedia) {
const data = await orig.file(partPath).async("uint8array");
gen.file(partPath, data);
const rp = relsPathFor(partPath);
const relsText = await textOf(orig, rp);
if (!relsText) return;
const relsDoc = parseXml(relsText);
for (const rel of descendants(relsDoc, PR_NS, "Relationship")) {
if (rel.getAttribute("TargetMode") === "External") continue;
const target = rel.getAttribute("Target") || "";
// Targets in word/_rels/*.rels are relative to word/.
const src = ("word/" + target.replace(/^\.\//, "")).replace(/\/+/g, "/");
const f = orig.file(src);
if (!f) continue;
let dest = src;
if (gen.file(dest) && !usedMedia.has(src)) {
const dot = src.lastIndexOf(".");
dest = src.slice(0, dot) + "-carried" + usedMedia.size + src.slice(dot);
rel.setAttribute("Target", dest.replace(/^word\//, ""));
}
usedMedia.set(src, dest);
gen.file(dest, await f.async("uint8array"));
}
gen.file(rp, serializeXml(relsDoc));
}
// Merge the original style catalogue into the generated one. Where both
// define a style id, the ORIGINAL wins: it is what the document actually
// looked like, and the builder's defaults are only there to make a blank
// document presentable. Styles the original doesn't have (the editor's own
// SourceCode, for instance) are left in place.
async function mergeStyles(orig, gen) {
const origText = await textOf(orig, "word/styles.xml");
const genText = await textOf(gen, "word/styles.xml");
if (!origText || !genText) return { merged: 0 };
const origDoc = parseXml(origText);
const genDoc = parseXml(genText);
const genRoot = genDoc.documentElement;
const byId = new Map();
for (const s of kids(genRoot, W_NS, "style")) {
byId.set(s.getAttributeNS(W_NS, "styleId"), s);
}
let merged = 0;
for (const s of descendants(origDoc, W_NS, "style")) {
const id = s.getAttributeNS(W_NS, "styleId");
if (!id) continue;
const imported = genDoc.importNode(s, true);
const existing = byId.get(id);
if (existing) genRoot.replaceChild(imported, existing);
else genRoot.appendChild(imported);
byId.set(id, imported);
merged++;
}
// docDefaults carries the document's base font and spacing; without it a
// grafted style catalogue sits on the builder's defaults and every
// unstyled paragraph shifts.
const origDefaults = descendants(origDoc, W_NS, "docDefaults")[0];
if (origDefaults) {
const genDefaults = kids(genRoot, W_NS, "docDefaults")[0];
const imported = genDoc.importNode(origDefaults, true);
if (genDefaults) genRoot.replaceChild(imported, genDefaults);
else genRoot.insertBefore(imported, genRoot.firstChild);
}
gen.file("word/styles.xml", serializeXml(genDoc));
return { merged };
}
// Header/footer references have to be the FIRST children of sectPr — the
// schema is order-sensitive and Word refuses a file that gets it wrong.
function injectSectionRefs(docDoc, refs, titlePg) {
const sect = descendants(docDoc, W_NS, "sectPr")[0];
if (!sect) return 0;
let n = 0;
const anchor = sect.firstChild;
for (const ref of refs) {
const el = docDoc.createElementNS(W_NS, "w:" + ref.kind + "Reference");
el.setAttributeNS(W_NS, "w:type", ref.type);
el.setAttributeNS(R_NS, "r:id", ref.newRId);
sect.insertBefore(el, anchor);
n++;
}
if (titlePg && !firstKid(sect, W_NS, "titlePg")) {
// titlePg sits after the references but before pgSz; appending is fine
// because Word tolerates it at the tail of sectPr in practice, and the
// references above are the order-critical part.
sect.appendChild(docDoc.createElementNS(W_NS, "w:titlePg"));
}
return n;
}
/**
* Carry preserved parts from the original package into the generated one.
*
* @param {Uint8Array} originalBytes the .docx the user opened
* @param {Uint8Array} generatedBytes what the docx builder just produced
* @param {object} opts { headers, footers, notes, styles, theme }
* @returns {Promise<{bytes: Uint8Array, carried: string[]}>}
*/
async function graft(originalBytes, generatedBytes, opts) {
const o = Object.assign({ headers: true, footers: true, notes: true, styles: true, theme: true }, opts || {});
const orig = await loadZip(originalBytes);
const gen = await loadZip(generatedBytes);
const carried = [];
const ctText = await textOf(gen, "[Content_Types].xml");
const ctDoc = parseXml(ctText);
const relsText = await textOf(gen, "word/_rels/document.xml.rels");
const relsDoc = parseXml(relsText);
const mkId = nextRelId(relsDoc);
let idN = 0;
const usedMedia = new Map();
// --- headers and footers -------------------------------------------
const setup = await readSectionSetup(orig);
const origRels = await textOf(orig, "word/_rels/document.xml.rels");
const origRelsDoc = origRels ? parseXml(origRels) : null;
const targetOf = (rId) => {
if (!origRelsDoc) return null;
const hit = descendants(origRelsDoc, PR_NS, "Relationship")
.find((r) => r.getAttribute("Id") === rId);
return hit ? "word/" + (hit.getAttribute("Target") || "").replace(/^\.\//, "") : null;
};
const newRefs = [];
if (setup && setup.refs.length) {
for (const ref of setup.refs) {
if (ref.kind === "header" && !o.headers) continue;
if (ref.kind === "footer" && !o.footers) continue;
const part = targetOf(ref.rId);
if (!part || !orig.file(part)) continue;
await copyPartWithRels(orig, gen, part, usedMedia);
const newRId = mkId(++idN);
addRelationship(relsDoc, newRId, REL_TYPES[ref.kind], part.replace(/^word\//, ""));
addOverride(ctDoc, "/" + part, CONTENT_TYPES[ref.kind]);
newRefs.push({ kind: ref.kind, type: ref.type, newRId });
}
if (newRefs.length) carried.push(`${newRefs.length} header/footer part(s)`);
}
// --- footnotes and endnotes -----------------------------------------
// The body keeps its footnote references (see read.js), so the note text
// has to come across with the same ids the references use — which is
// exactly what copying the original part wholesale gives us. The builder
// writes its own footnotes.xml only when the document declares notes, so
// in practice this replaces an absent or separator-only part.
if (o.notes) {
for (const kind of ["footnotes", "endnotes"]) {
const part = `word/${kind}.xml`;
if (!orig.file(part)) continue;
await copyPartWithRels(orig, gen, part, usedMedia);
addOverride(ctDoc, "/" + part, CONTENT_TYPES[kind]);
const has = descendants(relsDoc, PR_NS, "Relationship")
.some((r) => r.getAttribute("Type") === REL_TYPES[kind]);
if (!has) addRelationship(relsDoc, mkId(++idN), REL_TYPES[kind], `${kind}.xml`);
carried.push(kind);
}
}
// --- styles and theme ------------------------------------------------
if (o.styles) {
const r = await mergeStyles(orig, gen);
if (r.merged) carried.push(`${r.merged} style definition(s)`);
}
if (o.theme) {
const themeFile = orig.file(/^word\/theme\/theme\d*\.xml$/)[0];
if (themeFile) {
const genTheme = gen.file(/^word\/theme\/theme\d*\.xml$/)[0];
const dest = genTheme ? genTheme.name : "word/theme/theme1.xml";
gen.file(dest, await themeFile.async("uint8array"));
if (!genTheme) {
addOverride(ctDoc, "/" + dest, "application/vnd.openxmlformats-officedocument.theme+xml");
addRelationship(relsDoc, mkId(++idN), R_NS + "/theme", dest.replace(/^word\//, ""));
}
carried.push("theme");
}
}
// Any media extension the carried parts brought with them needs a Default
// entry or Word rejects the package.
for (const dest of usedMedia.values()) {
const ext = (dest.split(".").pop() || "").toLowerCase();
const mime = { png: "image/png", jpeg: "image/jpeg", jpg: "image/jpeg", gif: "image/gif",
bmp: "image/bmp", tiff: "image/tiff", svg: "image/svg+xml",
emf: "image/x-emf", wmf: "image/x-wmf" }[ext];
if (mime) addDefaultExt(ctDoc, ext, mime);
}
// --- rewrite the parts we changed ------------------------------------
if (newRefs.length || (setup && setup.titlePg)) {
const docText = await textOf(gen, "word/document.xml");
const docDoc = parseXml(docText);
injectSectionRefs(docDoc, newRefs, setup && setup.titlePg);
gen.file("word/document.xml", serializeXml(docDoc));
}
gen.file("[Content_Types].xml", serializeXml(ctDoc));
gen.file("word/_rels/document.xml.rels", serializeXml(relsDoc));
const bytes = await gen.generateAsync({
type: "uint8array",
compression: "DEFLATE",
compressionOptions: { level: 6 },
mimeType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
});
return { bytes, carried };
}
return { loadZip, scan, graft, readSectionSetup, readCoreProps, parseXml, serializeXml, FEATURES };
});