// Runs the round-trip over a folder of real .docx files. // // node test/corpus.mjs [more…] // // Reports structure only — block counts, features found, whether the saved // package is well-formed and whether a second read matches the first. It // never prints document text, and it writes its output to a temp folder // rather than next to the input. import { readFileSync, writeFileSync, readdirSync, statSync, mkdtempSync } from "node:fs"; import path from "node:path"; import os from "node:os"; import { loadAddonLibs, summarise, diff } from "./harness.mjs"; const DocxEditor = loadAddonLibs(); const schema = DocxEditor.schema.build(); const outDir = mkdtempSync(path.join(os.tmpdir(), "docx-corpus-")); function collect(target, into) { let st; try { st = statSync(target); } catch { return into; } if (st.isDirectory()) { for (const name of readdirSync(target)) { if (name.startsWith("~$")) continue; // Word lock files collect(path.join(target, name), into); } } else if (/\.docx$/i.test(target)) { into.push(target); } return into; } const targets = []; for (const arg of process.argv.slice(2)) collect(arg, targets); if (!targets.length) { console.error("no .docx files found"); process.exit(2); } const counters = { ok: 0, drift: 0, invalid: 0, failed: 0 }; const featureTally = new Map(); const problems = []; for (const file of targets) { const label = path.basename(file).replace(/[^\x20-\x7e]/g, "?"); let bytes; try { bytes = new Uint8Array(readFileSync(file)); } catch (e) { problems.push(`${label}: unreadable (${e.message})`); counters.failed++; continue; } try { const first = await DocxEditor.read.docxToDoc(bytes, schema); for (const f of first.report.features) { featureTally.set(f.label, (featureTally.get(f.label) || 0) + 1); } const saved = await DocxEditor.write.docToDocx(first.doc, { originalBytes: bytes, setup: first.setup, meta: first.meta, }); const second = await DocxEditor.read.docxToDoc(saved.bytes, schema); // Package sanity, same checks as the fixture round-trip. const zip = await DocxEditor.pkg.loadZip(saved.bytes); const names = Object.keys(zip.files); const bad = []; 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) { bad.push(name); } } 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 dangling = [...new Set([...docXml.matchAll(/r:(?:id|embed|link)="([^"]+)"/g)].map((m) => m[1]))] .filter((id) => !declared.has(id)); const missingParts = 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)); const d = diff(summarise(first.doc), summarise(second.doc)); const sizeKb = (bytes.length / 1024).toFixed(0); if (bad.length || dangling.length || missingParts.length) { counters.invalid++; problems.push(`${label}: INVALID PACKAGE — ${[ bad.length ? `malformed ${bad.join(",")}` : "", dangling.length ? `dangling ${dangling.join(",")}` : "", missingParts.length ? `missing ${missingParts.join(",")}` : "", ].filter(Boolean).join("; ")}`); writeFileSync(path.join(outDir, label + ".out.docx"), Buffer.from(saved.bytes)); } else if (d.length) { counters.drift++; problems.push(`${label} (${sizeKb} KB, ${first.doc.childCount} blocks): ${d.length} drift — ` + d.slice(0, 3).map((x) => x.replace(/"[^"]{40,}"/g, '"…"')).join(" | ")); } else { counters.ok++; } } catch (e) { counters.failed++; problems.push(`${label}: THREW — ${(e && e.message || e).toString().slice(0, 160)}`); } } console.log(`\ncorpus: ${targets.length} documents`); console.log(` clean round-trip : ${counters.ok}`); console.log(` content drift : ${counters.drift}`); console.log(` invalid package : ${counters.invalid}`); console.log(` threw : ${counters.failed}`); console.log(`\nfeatures across the corpus:`); for (const [label, n] of [...featureTally].sort((a, b) => b[1] - a[1])) { console.log(` ${String(n).padStart(3)}x ${label}`); } if (problems.length) { console.log(`\nproblems:`); for (const p of problems) console.log(" " + p); } console.log(`\noutput for failures: ${outDir}`); process.exit(counters.invalid || counters.failed ? 2 : counters.drift ? 1 : 0);