96 lines
4.9 KiB
JavaScript
96 lines
4.9 KiB
JavaScript
|
|
// 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);
|