// 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("\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 }; });