115 lines
4.9 KiB
JavaScript
115 lines
4.9 KiB
JavaScript
|
|
// Runs the round-trip over a folder of real .docx files.
|
||
|
|
//
|
||
|
|
// node test/corpus.mjs <dir-or-file> [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);
|