// The round-trip test: fixture.docx -> editor model -> .docx -> editor model, // then diff the two models. Anything that shows up in the diff is something a // user would lose by opening a document and pressing Save. // // node test/roundtrip.mjs [some-other.docx] import { writeFileSync, readFileSync, existsSync } from "node:fs"; import { fileURLToPath } from "node:url"; import path from "node:path"; import { loadAddonLibs, summarise, diff } from "./harness.mjs"; import { buildFixture } from "./fixture.mjs"; const here = path.dirname(fileURLToPath(import.meta.url)); const DocxEditor = loadAddonLibs(); const schema = DocxEditor.schema.build(); const input = process.argv[2]; const original = input && existsSync(input) ? new Uint8Array(readFileSync(input)) : new Uint8Array(await buildFixture()); console.log(`input: ${input || "generated fixture"} (${(original.length / 1024).toFixed(1)} KB)\n`); // --- pass 1: read --------------------------------------------------------- const first = await DocxEditor.read.docxToDoc(original, schema); console.log(`read: ${first.doc.childCount} top-level blocks`); if (first.report.features.length) { for (const f of first.report.features) { console.log(` [${f.level}] ${f.label}${f.note ? " — " + f.note : ""}`); } } if (first.warnings.length) console.log(` warnings: ${first.warnings.join(", ")}`); // --- pass 2: write -------------------------------------------------------- const saved = await DocxEditor.write.docToDocx(first.doc, { originalBytes: original, setup: first.setup, meta: first.meta, }); console.log(`\nwrote: ${(saved.bytes.length / 1024).toFixed(1)} KB`); if (saved.carried.length) console.log(` carried over: ${saved.carried.join(", ")}`); if (saved.warnings.length) console.log(` warnings: ${saved.warnings.join(", ")}`); const outPath = path.join(here, "roundtrip-out.docx"); writeFileSync(outPath, Buffer.from(saved.bytes)); // --- pass 3: read back ---------------------------------------------------- const second = await DocxEditor.read.docxToDoc(saved.bytes, schema); console.log(`re-read: ${second.doc.childCount} top-level blocks`); // --- package sanity ------------------------------------------------------- const zip = await DocxEditor.pkg.loadZip(saved.bytes); const names = Object.keys(zip.files).sort(); const required = ["[Content_Types].xml", "_rels/.rels", "word/document.xml", "word/_rels/document.xml.rels", "word/styles.xml"]; const missing = required.filter((r) => !names.includes(r)); let xmlErrors = []; for (const name of names) { if (!name.endsWith(".xml") && !name.endsWith(".rels")) continue; try { DocxEditor.pkg.parseXml(await zip.file(name).async("string")); } catch (e) { xmlErrors.push(`${name}: ${e.message}`); } } // Every r:id the document references must exist in its rels part. const relsDoc = DocxEditor.pkg.parseXml(await zip.file("word/_rels/document.xml.rels").async("string")); const declared = new Set(Array.from(relsDoc.getElementsByTagName("*")) .filter((e) => e.localName === "Relationship").map((e) => e.getAttribute("Id"))); const docXml = await zip.file("word/document.xml").async("string"); const referenced = [...docXml.matchAll(/r:(?:id|embed|link)="([^"]+)"/g)].map((m) => m[1]); const danglingRels = [...new Set(referenced)].filter((id) => !declared.has(id)); // Every part declared in the rels must actually be in the package. const danglingParts = Array.from(relsDoc.getElementsByTagName("*")) .filter((e) => e.localName === "Relationship" && e.getAttribute("TargetMode") !== "External") .map((e) => "word/" + (e.getAttribute("Target") || "").replace(/^\.\//, "")) .filter((p) => !p.includes("://") && !names.includes(p)); console.log("\npackage sanity"); console.log(` parts: ${names.length}`); console.log(` missing required: ${missing.length ? missing.join(", ") : "none"}`); console.log(` malformed xml: ${xmlErrors.length ? xmlErrors.join("; ") : "none"}`); console.log(` dangling r:ids: ${danglingRels.length ? danglingRels.join(", ") : "none"}`); console.log(` rels pointing at missing parts: ${danglingParts.length ? danglingParts.join(", ") : "none"}`); for (const want of ["word/header1.xml", "word/footer1.xml", "word/footnotes.xml"]) { if (names.includes(want)) console.log(` kept ${want}`); } // --- the diff ------------------------------------------------------------- const a = summarise(first.doc); const b = summarise(second.doc); const d = diff(a, b); console.log(`\nround-trip diff: ${d.length ? d.length + " difference(s)" : "clean"}`); for (const line of d.slice(0, 60)) console.log(" " + line); if (d.length > 60) console.log(` … and ${d.length - 60} more`); writeFileSync(path.join(here, "roundtrip-a.json"), JSON.stringify(a, null, 1)); writeFileSync(path.join(here, "roundtrip-b.json"), JSON.stringify(b, null, 1)); const fatal = missing.length || xmlErrors.length || danglingRels.length || danglingParts.length; process.exit(fatal ? 2 : d.length ? 1 : 0);