@ansonlai/docx-redline-js 0.1.6 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ARCHITECTURE.md CHANGED
@@ -72,6 +72,8 @@ No Word add-in entrypoints or host-specific integration layers are part of this
72
72
  - Namespace-safe Word element creation, tracked-change detection, and OOXML payload source-shape helpers.
73
73
  - `core/types.js`
74
74
  - Shared model enums/types plus revision metadata generation and document-aware revision ID seeding.
75
+ - `core/redline-validation.js`
76
+ - Runtime structural validation (`validateRedlineOoxml`) mirroring the test-suite invariants: no nested revisions, `w:delText` inside `w:del`, complete revision metadata, unique revision ids, preserved boundary whitespace.
75
77
  - `engine/oxml-engine.js`
76
78
  - Main reconciliation router, mode selection, existing-revision policy gate, and status/error result handling.
77
79
  - `engine/run-builders.js`
@@ -128,6 +130,8 @@ still be re-exported from `index.js`.
128
130
  - Do not write unknown `result.oxml` payloads directly into `word/document.xml`;
129
131
  normalize with `extractReplacementNodesFromOoxml(...)` or use
130
132
  `applyOperationToDocumentXml(...).documentXml` for full-document replacement.
133
+ - Run `validateRedlineOoxml(oxml)` on generated output before packaging it;
134
+ it reports structural invariant violations as `{ valid, issues }`.
131
135
 
132
136
 
133
137
  ## Build Output
@@ -150,11 +154,23 @@ The bundle inlines `diff-match-patch` and keeps `@xmldom/xmldom` external.
150
154
  - `npm run check:types`
151
155
  - Smoke-checks `index.d.ts`.
152
156
  - `node scripts/export-validation-fixtures.mjs`
153
- - Writes release-time validation fixtures to `tmp/validation-docx/`.
157
+ - Writes release-time validation fixtures to `tmp/validation-docx/` as
158
+ `word/document.xml` parts, assembled `.docx` files, and expected-text sidecars.
159
+ - `tests/roundtrip_fuzz_tests.mjs` (part of `npm test`)
160
+ - Seeded fuzz sweep of the accept/reject round-trip invariant; tune with
161
+ `FUZZ_SEED` / `FUZZ_ITERATIONS`.
154
162
  - `npm run smoke:word -- path/to/file.docx`
155
163
  - Optional Windows/Word COM smoke test for a completed `.docx`.
156
-
157
- Use these checks before publishing or tagging.
164
+ - `npm run smoke:word:diff`
165
+ - Windows/Word COM differential test: Word itself accepts/rejects the
166
+ generated fixtures and the resulting text is compared to expectations.
167
+ - `bash scripts/validate-fixtures-xsd.sh`
168
+ - Validates exported fixtures against the ECMA-376 transitional `wml.xsd`.
169
+ - `.github/workflows/validation.yml`
170
+ - Nightly independent-oracle validation: XSD schema check, LibreOffice
171
+ conversion, and an extended 20k-case fuzz sweep with a fresh seed.
172
+
173
+ Use these checks before publishing or tagging. See `docs/VALIDATION.md`.
158
174
 
159
175
  ## Fast Orientation For Contributors
160
176
 
package/README.md CHANGED
@@ -139,6 +139,7 @@ Common result fields:
139
139
  | `ingestOoxml(oxml)` | Flatten OOXML into an internal run model with offsets. |
140
140
  | `preprocessMarkdown(text)` | Normalize markdown and extract format hints. |
141
141
  | `containsTrackedChanges(xmlDoc)` | Detect `w:ins`, `w:del`, move revisions, property changes, and paragraph-mark revision markup in a parsed OOXML document/fragment. |
142
+ | `validateRedlineOoxml(oxml)` | Validate generated redline OOXML against the package's structural invariants (no nested revisions, `w:delText` inside `w:del`, complete metadata, unique revision ids, preserved boundary whitespace). Returns `{ valid, issues }`; run it before writing output into a package. |
142
143
 
143
144
  ### Services
144
145
 
@@ -259,6 +260,20 @@ On Windows with desktop Word installed, you can smoke-test a completed `.docx`:
259
260
  npm run smoke:word -- path/to/file.docx
260
261
  ```
261
262
 
263
+ To validate against Word as an independent oracle (Word itself accepts and
264
+ rejects the generated revisions and the resulting text is compared to the
265
+ expected outcomes):
266
+
267
+ ```bash
268
+ node scripts/export-validation-fixtures.mjs
269
+ npm run smoke:word:diff
270
+ ```
271
+
272
+ A nightly GitHub Actions workflow additionally validates generated fixtures
273
+ against the ECMA-376 transitional schemas (`xmllint`), opens them with
274
+ LibreOffice, and runs an extended fuzz sweep of the accept/reject round-trip
275
+ invariant with a fresh seed. See [docs/VALIDATION.md](./docs/VALIDATION.md).
276
+
262
277
  ## Architecture
263
278
 
264
279
  See [ARCHITECTURE.md](./ARCHITECTURE.md) for module layout, data flow, and contributor guidance.
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Runtime structural validation for generated redline OOXML.
3
+ *
4
+ * Mirrors the invariants enforced by the test-suite round-trip harness so
5
+ * downstream consumers can verify output before writing it into a package:
6
+ * no nested revisions, deleted text uses w:delText, revision metadata is
7
+ * complete, revision ids are unique, and boundary whitespace is preserved.
8
+ */
9
+
10
+ import { parseXml } from '../adapters/xml-adapter.js';
11
+ import { NS_W } from './types.js';
12
+
13
+ const REVISION_ID_ELEMENTS = new Set(['ins', 'del', 'rPrChange', 'pPrChange']);
14
+ const REVISION_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}T/;
15
+
16
+ function localNameOf(node) {
17
+ return String(node?.localName || node?.nodeName || '').replace(/^.*:/, '');
18
+ }
19
+
20
+ function elementsByLocalName(root, name) {
21
+ return Array.from(root.getElementsByTagName('*')).filter(el => localNameOf(el) === name);
22
+ }
23
+
24
+ function wordAttribute(node, name) {
25
+ return node.getAttribute(`w:${name}`) || node.getAttribute(name) || '';
26
+ }
27
+
28
+ function xmlSpaceAttribute(node) {
29
+ return node.getAttribute('xml:space') ||
30
+ node.getAttribute('space') ||
31
+ node.getAttributeNS?.('http://www.w3.org/XML/1998/namespace', 'space') ||
32
+ '';
33
+ }
34
+
35
+ function isParagraphMarkRevision(node) {
36
+ return localNameOf(node.parentNode) === 'rPr';
37
+ }
38
+
39
+ function parseOoxmlForValidation(oxml) {
40
+ const attempt = xml => {
41
+ const doc = parseXml(xml);
42
+ const parseError = doc.getElementsByTagName('parsererror')[0];
43
+ if (parseError) {
44
+ throw new Error(parseError.textContent || 'XML parse error');
45
+ }
46
+ return doc;
47
+ };
48
+
49
+ try {
50
+ return { doc: attempt(oxml) };
51
+ } catch {
52
+ try {
53
+ return { doc: attempt(`<w:root xmlns:w="${NS_W}">${oxml}</w:root>`) };
54
+ } catch (error) {
55
+ return { error: error?.message || 'XML parse error' };
56
+ }
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Validates redline OOXML against the package's structural invariants.
62
+ *
63
+ * Issue severities: 'error' issues indicate output Word may repair or
64
+ * mis-resolve; 'warning' issues are suspicious but tolerated by Word.
65
+ *
66
+ * @param {string} oxml - OOXML string (fragment, document, or package scope)
67
+ * @returns {{ valid: boolean, issues: Array<{ code: string, severity: 'error'|'warning', message: string }> }}
68
+ */
69
+ export function validateRedlineOoxml(oxml) {
70
+ const issues = [];
71
+ const addIssue = (code, severity, message) => issues.push({ code, severity, message });
72
+
73
+ if (typeof oxml !== 'string' || oxml.trim() === '') {
74
+ addIssue('PARSE_ERROR', 'error', 'Input is not a non-empty OOXML string.');
75
+ return { valid: false, issues };
76
+ }
77
+
78
+ const { doc, error } = parseOoxmlForValidation(oxml);
79
+ if (!doc) {
80
+ addIssue('PARSE_ERROR', 'error', `OOXML does not parse as XML: ${error}`);
81
+ return { valid: false, issues };
82
+ }
83
+
84
+ const insElements = elementsByLocalName(doc, 'ins');
85
+ const delElements = elementsByLocalName(doc, 'del');
86
+ const revisions = insElements.concat(delElements);
87
+
88
+ // No w:ins/w:del nested inside another w:ins/w:del.
89
+ for (const revision of revisions) {
90
+ const nested = Array.from(revision.getElementsByTagName('*'))
91
+ .filter(el => el !== revision && ['ins', 'del'].includes(localNameOf(el)));
92
+ if (nested.length > 0) {
93
+ addIssue('NESTED_REVISION', 'error',
94
+ `<${revision.nodeName}> (w:id="${wordAttribute(revision, 'id')}") contains nested <${nested[0].nodeName}>.`);
95
+ }
96
+ }
97
+
98
+ // Deleted runs must carry w:delText, never w:t.
99
+ for (const del of delElements) {
100
+ const plainTextNodes = elementsByLocalName(del, 't');
101
+ if (plainTextNodes.length > 0) {
102
+ addIssue('DEL_CONTAINS_T', 'error',
103
+ `<w:del> (w:id="${wordAttribute(del, 'id')}") contains <w:t>; deleted text must use <w:delText>.`);
104
+ }
105
+ }
106
+
107
+ // Every revision needs complete metadata.
108
+ for (const revision of revisions) {
109
+ const missing = [];
110
+ if (!wordAttribute(revision, 'id')) missing.push('w:id');
111
+ if (!wordAttribute(revision, 'author')) missing.push('w:author');
112
+ if (!REVISION_DATE_PATTERN.test(wordAttribute(revision, 'date'))) missing.push('w:date');
113
+ if (missing.length > 0) {
114
+ addIssue('MISSING_REVISION_METADATA', 'error',
115
+ `<${revision.nodeName}> is missing or has malformed ${missing.join(', ')}.`);
116
+ }
117
+ }
118
+
119
+ // Revision ids must be unique among ins/del/rPrChange/pPrChange.
120
+ const seenIds = new Set();
121
+ for (const node of Array.from(doc.getElementsByTagName('*'))) {
122
+ if (!REVISION_ID_ELEMENTS.has(localNameOf(node))) continue;
123
+ const id = wordAttribute(node, 'id');
124
+ if (!id) continue;
125
+ if (seenIds.has(id)) {
126
+ addIssue('DUPLICATE_REVISION_ID', 'error', `Revision id ${id} appears more than once.`);
127
+ }
128
+ seenIds.add(id);
129
+ }
130
+
131
+ // Boundary whitespace requires xml:space="preserve".
132
+ const textNodes = elementsByLocalName(doc, 't').concat(elementsByLocalName(doc, 'delText'));
133
+ for (const node of textNodes) {
134
+ const text = node.textContent || '';
135
+ if (/^\s|\s$/.test(text) && xmlSpaceAttribute(node) !== 'preserve') {
136
+ addIssue('MISSING_SPACE_PRESERVE', 'error',
137
+ `<${node.nodeName}> has boundary whitespace without xml:space="preserve".`);
138
+ }
139
+ if (text === '') {
140
+ addIssue('EMPTY_TEXT_ELEMENT', 'warning', `<${node.nodeName}> is empty.`);
141
+ }
142
+ }
143
+
144
+ // Empty w:ins/w:del wrappers (paragraph-mark revisions inside w:rPr are
145
+ // legitimately empty and excluded).
146
+ for (const revision of revisions) {
147
+ if (isParagraphMarkRevision(revision)) continue;
148
+ const hasElementChild = Array.from(revision.childNodes || []).some(child => child.nodeType === 1);
149
+ if (!hasElementChild) {
150
+ addIssue('EMPTY_REVISION_WRAPPER', 'warning',
151
+ `<${revision.nodeName}> (w:id="${wordAttribute(revision, 'id')}") wraps no content.`);
152
+ }
153
+ }
154
+
155
+ return { valid: !issues.some(issue => issue.severity === 'error'), issues };
156
+ }
@@ -1,4 +1,4 @@
1
- // @ansonlai/docx-redline-js v0.1.6 — https://github.com/AnsonLai/docx-redline-js
1
+ // @ansonlai/docx-redline-js v0.2.0 — https://github.com/AnsonLai/docx-redline-js
2
2
  var __create = Object.create;
3
3
  var __defProp = Object.defineProperty;
4
4
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
@@ -7715,6 +7715,129 @@ async function executeSingleLineListStructuralFallback(plan, options = {}) {
7715
7715
  };
7716
7716
  }
7717
7717
 
7718
+ // core/redline-validation.js
7719
+ var REVISION_ID_ELEMENTS = /* @__PURE__ */ new Set(["ins", "del", "rPrChange", "pPrChange"]);
7720
+ var REVISION_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}T/;
7721
+ function localNameOf(node) {
7722
+ return String(node?.localName || node?.nodeName || "").replace(/^.*:/, "");
7723
+ }
7724
+ function elementsByLocalName(root, name) {
7725
+ return Array.from(root.getElementsByTagName("*")).filter((el) => localNameOf(el) === name);
7726
+ }
7727
+ function wordAttribute(node, name) {
7728
+ return node.getAttribute(`w:${name}`) || node.getAttribute(name) || "";
7729
+ }
7730
+ function xmlSpaceAttribute(node) {
7731
+ return node.getAttribute("xml:space") || node.getAttribute("space") || node.getAttributeNS?.("http://www.w3.org/XML/1998/namespace", "space") || "";
7732
+ }
7733
+ function isParagraphMarkRevision(node) {
7734
+ return localNameOf(node.parentNode) === "rPr";
7735
+ }
7736
+ function parseOoxmlForValidation(oxml) {
7737
+ const attempt = (xml) => {
7738
+ const doc = parseXml(xml);
7739
+ const parseError = doc.getElementsByTagName("parsererror")[0];
7740
+ if (parseError) {
7741
+ throw new Error(parseError.textContent || "XML parse error");
7742
+ }
7743
+ return doc;
7744
+ };
7745
+ try {
7746
+ return { doc: attempt(oxml) };
7747
+ } catch {
7748
+ try {
7749
+ return { doc: attempt(`<w:root xmlns:w="${NS_W}">${oxml}</w:root>`) };
7750
+ } catch (error2) {
7751
+ return { error: error2?.message || "XML parse error" };
7752
+ }
7753
+ }
7754
+ }
7755
+ function validateRedlineOoxml(oxml) {
7756
+ const issues = [];
7757
+ const addIssue = (code, severity, message) => issues.push({ code, severity, message });
7758
+ if (typeof oxml !== "string" || oxml.trim() === "") {
7759
+ addIssue("PARSE_ERROR", "error", "Input is not a non-empty OOXML string.");
7760
+ return { valid: false, issues };
7761
+ }
7762
+ const { doc, error: error2 } = parseOoxmlForValidation(oxml);
7763
+ if (!doc) {
7764
+ addIssue("PARSE_ERROR", "error", `OOXML does not parse as XML: ${error2}`);
7765
+ return { valid: false, issues };
7766
+ }
7767
+ const insElements = elementsByLocalName(doc, "ins");
7768
+ const delElements = elementsByLocalName(doc, "del");
7769
+ const revisions = insElements.concat(delElements);
7770
+ for (const revision of revisions) {
7771
+ const nested = Array.from(revision.getElementsByTagName("*")).filter((el) => el !== revision && ["ins", "del"].includes(localNameOf(el)));
7772
+ if (nested.length > 0) {
7773
+ addIssue(
7774
+ "NESTED_REVISION",
7775
+ "error",
7776
+ `<${revision.nodeName}> (w:id="${wordAttribute(revision, "id")}") contains nested <${nested[0].nodeName}>.`
7777
+ );
7778
+ }
7779
+ }
7780
+ for (const del of delElements) {
7781
+ const plainTextNodes = elementsByLocalName(del, "t");
7782
+ if (plainTextNodes.length > 0) {
7783
+ addIssue(
7784
+ "DEL_CONTAINS_T",
7785
+ "error",
7786
+ `<w:del> (w:id="${wordAttribute(del, "id")}") contains <w:t>; deleted text must use <w:delText>.`
7787
+ );
7788
+ }
7789
+ }
7790
+ for (const revision of revisions) {
7791
+ const missing = [];
7792
+ if (!wordAttribute(revision, "id")) missing.push("w:id");
7793
+ if (!wordAttribute(revision, "author")) missing.push("w:author");
7794
+ if (!REVISION_DATE_PATTERN.test(wordAttribute(revision, "date"))) missing.push("w:date");
7795
+ if (missing.length > 0) {
7796
+ addIssue(
7797
+ "MISSING_REVISION_METADATA",
7798
+ "error",
7799
+ `<${revision.nodeName}> is missing or has malformed ${missing.join(", ")}.`
7800
+ );
7801
+ }
7802
+ }
7803
+ const seenIds = /* @__PURE__ */ new Set();
7804
+ for (const node of Array.from(doc.getElementsByTagName("*"))) {
7805
+ if (!REVISION_ID_ELEMENTS.has(localNameOf(node))) continue;
7806
+ const id = wordAttribute(node, "id");
7807
+ if (!id) continue;
7808
+ if (seenIds.has(id)) {
7809
+ addIssue("DUPLICATE_REVISION_ID", "error", `Revision id ${id} appears more than once.`);
7810
+ }
7811
+ seenIds.add(id);
7812
+ }
7813
+ const textNodes = elementsByLocalName(doc, "t").concat(elementsByLocalName(doc, "delText"));
7814
+ for (const node of textNodes) {
7815
+ const text = node.textContent || "";
7816
+ if (/^\s|\s$/.test(text) && xmlSpaceAttribute(node) !== "preserve") {
7817
+ addIssue(
7818
+ "MISSING_SPACE_PRESERVE",
7819
+ "error",
7820
+ `<${node.nodeName}> has boundary whitespace without xml:space="preserve".`
7821
+ );
7822
+ }
7823
+ if (text === "") {
7824
+ addIssue("EMPTY_TEXT_ELEMENT", "warning", `<${node.nodeName}> is empty.`);
7825
+ }
7826
+ }
7827
+ for (const revision of revisions) {
7828
+ if (isParagraphMarkRevision(revision)) continue;
7829
+ const hasElementChild = Array.from(revision.childNodes || []).some((child) => child.nodeType === 1);
7830
+ if (!hasElementChild) {
7831
+ addIssue(
7832
+ "EMPTY_REVISION_WRAPPER",
7833
+ "warning",
7834
+ `<${revision.nodeName}> (w:id="${wordAttribute(revision, "id")}") wraps no content.`
7835
+ );
7836
+ }
7837
+ }
7838
+ return { valid: !issues.some((issue) => issue.severity === "error"), issues };
7839
+ }
7840
+
7718
7841
  // core/table-targeting.js
7719
7842
  function getDirectWordChildren(element, localName) {
7720
7843
  if (!element) return [];
@@ -9578,6 +9701,7 @@ export {
9578
9701
  synthesizeExpandedListScopeEdit,
9579
9702
  synthesizeTableMarkdownFromMultilineCellEdit,
9580
9703
  validateDocxPackage,
9704
+ validateRedlineOoxml,
9581
9705
  wrapInDocumentFragment
9582
9706
  };
9583
9707
  //# sourceMappingURL=docx-redline-js.esm.js.map