@ansonlai/docx-redline-js 0.1.6 → 0.2.1
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 +19 -3
- package/README.md +15 -0
- package/core/redline-validation.js +156 -0
- package/dist/docx-redline-js.esm.js +152 -31
- package/dist/docx-redline-js.esm.js.map +4 -4
- package/dist/docx-redline-js.esm.min.js +62 -62
- package/dist/docx-redline-js.esm.min.js.map +4 -4
- package/docs/VALIDATION.md +79 -23
- package/docs/plans/2026-05-31-architectural changes.md +591 -0
- package/engine/formatting-removal.js +14 -8
- package/engine/run-builders.js +14 -10
- package/engine/surgical-diff-application.js +7 -21
- package/index.d.ts +24 -0
- package/index.js +35 -34
- package/package.json +5 -4
- package/scripts/build.mjs +10 -5
- package/scripts/check-types.mjs +2 -1
- package/scripts/export-validation-fixtures.mjs +73 -16
- package/scripts/lib/minimal-zip.mjs +155 -0
- package/scripts/validate-fixtures-xsd.sh +37 -0
- package/scripts/word-com-differential.ps1 +133 -0
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
|
-
|
|
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
|
|
1
|
+
// @ansonlai/docx-redline-js v0.2.1 — 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;
|
|
@@ -6,7 +6,11 @@ var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
|
6
6
|
var __getProtoOf = Object.getPrototypeOf;
|
|
7
7
|
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
8
|
var __commonJS = (cb, mod) => function __require() {
|
|
9
|
-
|
|
9
|
+
try {
|
|
10
|
+
return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
|
|
11
|
+
} catch (e) {
|
|
12
|
+
throw mod = 0, e;
|
|
13
|
+
}
|
|
10
14
|
};
|
|
11
15
|
var __copyProps = (to, from, except, desc) => {
|
|
12
16
|
if (from && typeof from === "object" || typeof from === "function") {
|
|
@@ -4425,11 +4429,11 @@ function createFormattedRuns(xmlDoc, text, baseRPr, formatHints, baseOffset, aut
|
|
|
4425
4429
|
const applicableHints = formatHints.filter(
|
|
4426
4430
|
(h) => h.start <= segmentBaseOffset && h.end >= segmentEndOffset
|
|
4427
4431
|
);
|
|
4428
|
-
const combinedFormat = {};
|
|
4432
|
+
const combinedFormat = { ...extractFormatFromRPr(baseRPr) };
|
|
4429
4433
|
applicableHints.forEach((h) => {
|
|
4430
4434
|
if (h.format) Object.assign(combinedFormat, h.format);
|
|
4431
4435
|
});
|
|
4432
|
-
const formattedRPr = injectFormattingToRPr(xmlDoc, baseRPr, combinedFormat, author, generateRedlines);
|
|
4436
|
+
const formattedRPr = applicableHints.length > 0 ? injectFormattingToRPr(xmlDoc, baseRPr, combinedFormat, author, generateRedlines) : baseRPr?.cloneNode(true) || null;
|
|
4433
4437
|
runs.push(createTextRunWithRPrElement(xmlDoc, segment, formattedRPr, false));
|
|
4434
4438
|
}
|
|
4435
4439
|
return runs;
|
|
@@ -4549,6 +4553,7 @@ function isWordElement3(node, localName) {
|
|
|
4549
4553
|
return nodeName === `w:${localName}` || nodeName === localName;
|
|
4550
4554
|
}
|
|
4551
4555
|
function buildParagraphInfos(xmlDoc, paragraphs, textSpans) {
|
|
4556
|
+
void xmlDoc;
|
|
4552
4557
|
const spansByParagraph = /* @__PURE__ */ new Map();
|
|
4553
4558
|
for (const span of textSpans) {
|
|
4554
4559
|
if (!span || !span.paragraph) continue;
|
|
@@ -4753,6 +4758,7 @@ function normalizePrecomputedFormatContext(precomputedContext) {
|
|
|
4753
4758
|
};
|
|
4754
4759
|
}
|
|
4755
4760
|
function applyFormatRemovalAsSurgicalReplacement(xmlDoc, textSpans, existingFormatHints, serializer, author, generateRedlines = true) {
|
|
4761
|
+
void textSpans;
|
|
4756
4762
|
let hasAnyChanges = false;
|
|
4757
4763
|
const processedRuns = /* @__PURE__ */ new Set();
|
|
4758
4764
|
log(`[OxmlEngine] Surgical format removal: ${existingFormatHints.length} hints to process (using w:rPrChange)`);
|
|
@@ -5208,26 +5214,11 @@ function cloneRunPiece(xmlDoc, sourceNode, text, asDeletedText) {
|
|
|
5208
5214
|
|
|
5209
5215
|
// engine/surgical-diff-application.js
|
|
5210
5216
|
function reconcileFormattingForTextSpan(xmlDoc, span, start, end, applicableHints, author, generateRedlines) {
|
|
5211
|
-
|
|
5212
|
-
if (applicableHints.length > 0) {
|
|
5213
|
-
applicableHints.forEach((h) => Object.assign(desiredFormat, h.format));
|
|
5214
|
-
}
|
|
5217
|
+
if (applicableHints.length === 0) return false;
|
|
5215
5218
|
const rPr = span.rPr;
|
|
5216
|
-
const
|
|
5217
|
-
|
|
5218
|
-
|
|
5219
|
-
if (isWordElement(node, localName)) {
|
|
5220
|
-
return true;
|
|
5221
|
-
}
|
|
5222
|
-
}
|
|
5223
|
-
return false;
|
|
5224
|
-
};
|
|
5225
|
-
const existingFormat = {
|
|
5226
|
-
bold: hasElement("b"),
|
|
5227
|
-
italic: hasElement("i"),
|
|
5228
|
-
underline: hasElement("u"),
|
|
5229
|
-
strikethrough: hasElement("strike")
|
|
5230
|
-
};
|
|
5219
|
+
const existingFormat = extractFormatFromRPr(rPr);
|
|
5220
|
+
const desiredFormat = { ...existingFormat };
|
|
5221
|
+
applicableHints.forEach((h) => Object.assign(desiredFormat, h.format));
|
|
5231
5222
|
const formatsToCheck = ["bold", "italic", "underline", "strikethrough"];
|
|
5232
5223
|
const changesNeeded = formatsToCheck.some((f) => !!desiredFormat[f] !== existingFormat[f]);
|
|
5233
5224
|
if (!changesNeeded) return false;
|
|
@@ -5361,6 +5352,7 @@ function insertTextRuns(xmlDoc, parent, referenceNode, text, baseRPr, author, fo
|
|
|
5361
5352
|
|
|
5362
5353
|
// engine/surgical-mode.js
|
|
5363
5354
|
function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer, author, formatHints, generateRedlines = true, targetParagraph = null) {
|
|
5355
|
+
void originalText;
|
|
5364
5356
|
const allParagraphs = targetParagraph ? [targetParagraph] : getDocumentParagraphs(xmlDoc);
|
|
5365
5357
|
const { fullText, textSpans } = buildSurgicalTextSpans(allParagraphs);
|
|
5366
5358
|
const diffs = computeWordDiffs(fullText, modifiedText);
|
|
@@ -7715,6 +7707,129 @@ async function executeSingleLineListStructuralFallback(plan, options = {}) {
|
|
|
7715
7707
|
};
|
|
7716
7708
|
}
|
|
7717
7709
|
|
|
7710
|
+
// core/redline-validation.js
|
|
7711
|
+
var REVISION_ID_ELEMENTS = /* @__PURE__ */ new Set(["ins", "del", "rPrChange", "pPrChange"]);
|
|
7712
|
+
var REVISION_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}T/;
|
|
7713
|
+
function localNameOf(node) {
|
|
7714
|
+
return String(node?.localName || node?.nodeName || "").replace(/^.*:/, "");
|
|
7715
|
+
}
|
|
7716
|
+
function elementsByLocalName(root, name) {
|
|
7717
|
+
return Array.from(root.getElementsByTagName("*")).filter((el) => localNameOf(el) === name);
|
|
7718
|
+
}
|
|
7719
|
+
function wordAttribute(node, name) {
|
|
7720
|
+
return node.getAttribute(`w:${name}`) || node.getAttribute(name) || "";
|
|
7721
|
+
}
|
|
7722
|
+
function xmlSpaceAttribute(node) {
|
|
7723
|
+
return node.getAttribute("xml:space") || node.getAttribute("space") || node.getAttributeNS?.("http://www.w3.org/XML/1998/namespace", "space") || "";
|
|
7724
|
+
}
|
|
7725
|
+
function isParagraphMarkRevision(node) {
|
|
7726
|
+
return localNameOf(node.parentNode) === "rPr";
|
|
7727
|
+
}
|
|
7728
|
+
function parseOoxmlForValidation(oxml) {
|
|
7729
|
+
const attempt = (xml) => {
|
|
7730
|
+
const doc = parseXml(xml);
|
|
7731
|
+
const parseError = doc.getElementsByTagName("parsererror")[0];
|
|
7732
|
+
if (parseError) {
|
|
7733
|
+
throw new Error(parseError.textContent || "XML parse error");
|
|
7734
|
+
}
|
|
7735
|
+
return doc;
|
|
7736
|
+
};
|
|
7737
|
+
try {
|
|
7738
|
+
return { doc: attempt(oxml) };
|
|
7739
|
+
} catch {
|
|
7740
|
+
try {
|
|
7741
|
+
return { doc: attempt(`<w:root xmlns:w="${NS_W}">${oxml}</w:root>`) };
|
|
7742
|
+
} catch (error2) {
|
|
7743
|
+
return { error: error2?.message || "XML parse error" };
|
|
7744
|
+
}
|
|
7745
|
+
}
|
|
7746
|
+
}
|
|
7747
|
+
function validateRedlineOoxml(oxml) {
|
|
7748
|
+
const issues = [];
|
|
7749
|
+
const addIssue = (code, severity, message) => issues.push({ code, severity, message });
|
|
7750
|
+
if (typeof oxml !== "string" || oxml.trim() === "") {
|
|
7751
|
+
addIssue("PARSE_ERROR", "error", "Input is not a non-empty OOXML string.");
|
|
7752
|
+
return { valid: false, issues };
|
|
7753
|
+
}
|
|
7754
|
+
const { doc, error: error2 } = parseOoxmlForValidation(oxml);
|
|
7755
|
+
if (!doc) {
|
|
7756
|
+
addIssue("PARSE_ERROR", "error", `OOXML does not parse as XML: ${error2}`);
|
|
7757
|
+
return { valid: false, issues };
|
|
7758
|
+
}
|
|
7759
|
+
const insElements = elementsByLocalName(doc, "ins");
|
|
7760
|
+
const delElements = elementsByLocalName(doc, "del");
|
|
7761
|
+
const revisions = insElements.concat(delElements);
|
|
7762
|
+
for (const revision of revisions) {
|
|
7763
|
+
const nested = Array.from(revision.getElementsByTagName("*")).filter((el) => el !== revision && ["ins", "del"].includes(localNameOf(el)));
|
|
7764
|
+
if (nested.length > 0) {
|
|
7765
|
+
addIssue(
|
|
7766
|
+
"NESTED_REVISION",
|
|
7767
|
+
"error",
|
|
7768
|
+
`<${revision.nodeName}> (w:id="${wordAttribute(revision, "id")}") contains nested <${nested[0].nodeName}>.`
|
|
7769
|
+
);
|
|
7770
|
+
}
|
|
7771
|
+
}
|
|
7772
|
+
for (const del of delElements) {
|
|
7773
|
+
const plainTextNodes = elementsByLocalName(del, "t");
|
|
7774
|
+
if (plainTextNodes.length > 0) {
|
|
7775
|
+
addIssue(
|
|
7776
|
+
"DEL_CONTAINS_T",
|
|
7777
|
+
"error",
|
|
7778
|
+
`<w:del> (w:id="${wordAttribute(del, "id")}") contains <w:t>; deleted text must use <w:delText>.`
|
|
7779
|
+
);
|
|
7780
|
+
}
|
|
7781
|
+
}
|
|
7782
|
+
for (const revision of revisions) {
|
|
7783
|
+
const missing = [];
|
|
7784
|
+
if (!wordAttribute(revision, "id")) missing.push("w:id");
|
|
7785
|
+
if (!wordAttribute(revision, "author")) missing.push("w:author");
|
|
7786
|
+
if (!REVISION_DATE_PATTERN.test(wordAttribute(revision, "date"))) missing.push("w:date");
|
|
7787
|
+
if (missing.length > 0) {
|
|
7788
|
+
addIssue(
|
|
7789
|
+
"MISSING_REVISION_METADATA",
|
|
7790
|
+
"error",
|
|
7791
|
+
`<${revision.nodeName}> is missing or has malformed ${missing.join(", ")}.`
|
|
7792
|
+
);
|
|
7793
|
+
}
|
|
7794
|
+
}
|
|
7795
|
+
const seenIds = /* @__PURE__ */ new Set();
|
|
7796
|
+
for (const node of Array.from(doc.getElementsByTagName("*"))) {
|
|
7797
|
+
if (!REVISION_ID_ELEMENTS.has(localNameOf(node))) continue;
|
|
7798
|
+
const id = wordAttribute(node, "id");
|
|
7799
|
+
if (!id) continue;
|
|
7800
|
+
if (seenIds.has(id)) {
|
|
7801
|
+
addIssue("DUPLICATE_REVISION_ID", "error", `Revision id ${id} appears more than once.`);
|
|
7802
|
+
}
|
|
7803
|
+
seenIds.add(id);
|
|
7804
|
+
}
|
|
7805
|
+
const textNodes = elementsByLocalName(doc, "t").concat(elementsByLocalName(doc, "delText"));
|
|
7806
|
+
for (const node of textNodes) {
|
|
7807
|
+
const text = node.textContent || "";
|
|
7808
|
+
if (/^\s|\s$/.test(text) && xmlSpaceAttribute(node) !== "preserve") {
|
|
7809
|
+
addIssue(
|
|
7810
|
+
"MISSING_SPACE_PRESERVE",
|
|
7811
|
+
"error",
|
|
7812
|
+
`<${node.nodeName}> has boundary whitespace without xml:space="preserve".`
|
|
7813
|
+
);
|
|
7814
|
+
}
|
|
7815
|
+
if (text === "") {
|
|
7816
|
+
addIssue("EMPTY_TEXT_ELEMENT", "warning", `<${node.nodeName}> is empty.`);
|
|
7817
|
+
}
|
|
7818
|
+
}
|
|
7819
|
+
for (const revision of revisions) {
|
|
7820
|
+
if (isParagraphMarkRevision(revision)) continue;
|
|
7821
|
+
const hasElementChild = Array.from(revision.childNodes || []).some((child) => child.nodeType === 1);
|
|
7822
|
+
if (!hasElementChild) {
|
|
7823
|
+
addIssue(
|
|
7824
|
+
"EMPTY_REVISION_WRAPPER",
|
|
7825
|
+
"warning",
|
|
7826
|
+
`<${revision.nodeName}> (w:id="${wordAttribute(revision, "id")}") wraps no content.`
|
|
7827
|
+
);
|
|
7828
|
+
}
|
|
7829
|
+
}
|
|
7830
|
+
return { valid: !issues.some((issue) => issue.severity === "error"), issues };
|
|
7831
|
+
}
|
|
7832
|
+
|
|
7718
7833
|
// core/table-targeting.js
|
|
7719
7834
|
function getDirectWordChildren(element, localName) {
|
|
7720
7835
|
if (!element) return [];
|
|
@@ -8679,6 +8794,11 @@ function injectCommentsIntoPackage2(packageOxml, commentsXml) {
|
|
|
8679
8794
|
}
|
|
8680
8795
|
|
|
8681
8796
|
// engine/formatting-removal.js
|
|
8797
|
+
function removeNode2(node) {
|
|
8798
|
+
if (node?.parentNode) {
|
|
8799
|
+
node.parentNode.removeChild(node);
|
|
8800
|
+
}
|
|
8801
|
+
}
|
|
8682
8802
|
function removeFormattingFromRPr(rPr, formatTypes = ["all"]) {
|
|
8683
8803
|
if (!rPr) return null;
|
|
8684
8804
|
const rPrClone = rPr.cloneNode(true);
|
|
@@ -8702,7 +8822,7 @@ function removeFormattingFromRPr(rPr, formatTypes = ["all"]) {
|
|
|
8702
8822
|
];
|
|
8703
8823
|
toRemove.forEach((tag) => {
|
|
8704
8824
|
const elements = rPrClone.querySelectorAll(`${tag}, ${tag.replace("w:", "")}`);
|
|
8705
|
-
elements.forEach(
|
|
8825
|
+
elements.forEach(removeNode2);
|
|
8706
8826
|
});
|
|
8707
8827
|
} else {
|
|
8708
8828
|
const tagMap = {
|
|
@@ -8724,7 +8844,7 @@ function removeFormattingFromRPr(rPr, formatTypes = ["all"]) {
|
|
|
8724
8844
|
const tag = tagMap[type];
|
|
8725
8845
|
if (tag) {
|
|
8726
8846
|
const elements = rPrClone.querySelectorAll(`${tag}, ${tag.replace("w:", "")}`);
|
|
8727
|
-
elements.forEach(
|
|
8847
|
+
elements.forEach(removeNode2);
|
|
8728
8848
|
}
|
|
8729
8849
|
});
|
|
8730
8850
|
}
|
|
@@ -8754,7 +8874,7 @@ function applyFormattingRemovalToOoxml(ooxmlString, targetText, formatTypes) {
|
|
|
8754
8874
|
rPr.parentNode.replaceChild(newRPr, rPr);
|
|
8755
8875
|
}
|
|
8756
8876
|
} else {
|
|
8757
|
-
rPr
|
|
8877
|
+
removeNode2(rPr);
|
|
8758
8878
|
}
|
|
8759
8879
|
}
|
|
8760
8880
|
}
|
|
@@ -8800,7 +8920,7 @@ function injectHighlightIntoRPr(doc, rPr, color = "yellow", options = {}) {
|
|
|
8800
8920
|
});
|
|
8801
8921
|
}
|
|
8802
8922
|
const existingHighlight = rPrElement.getElementsByTagNameNS(NS_W7, "highlight");
|
|
8803
|
-
Array.from(existingHighlight).forEach(
|
|
8923
|
+
Array.from(existingHighlight).forEach(removeNode2);
|
|
8804
8924
|
const highlightEl = createWordElement(doc, "w:highlight");
|
|
8805
8925
|
highlightEl.setAttributeNS(NS_W7, "w:val", ooxmlColor);
|
|
8806
8926
|
rPrElement.appendChild(highlightEl);
|
|
@@ -8812,7 +8932,7 @@ function injectHighlightIntoRPr(doc, rPr, color = "yellow", options = {}) {
|
|
|
8812
8932
|
rPrChange.setAttribute("w:date", metadata.date);
|
|
8813
8933
|
rPrChange.appendChild(previousRPrState);
|
|
8814
8934
|
const existingChange = rPrElement.getElementsByTagNameNS(NS_W7, "rPrChange");
|
|
8815
|
-
Array.from(existingChange).forEach(
|
|
8935
|
+
Array.from(existingChange).forEach(removeNode2);
|
|
8816
8936
|
rPrElement.appendChild(rPrChange);
|
|
8817
8937
|
}
|
|
8818
8938
|
return rPrElement;
|
|
@@ -8844,7 +8964,7 @@ function applyHighlightToOoxml(ooxmlString, targetText, color = "yellow", option
|
|
|
8844
8964
|
if (prefixText.length > 0) {
|
|
8845
8965
|
const prefixRun = run.cloneNode(true);
|
|
8846
8966
|
const tNodes = prefixRun.getElementsByTagNameNS(NS_W7, "t");
|
|
8847
|
-
Array.from(tNodes).forEach(
|
|
8967
|
+
Array.from(tNodes).forEach(removeNode2);
|
|
8848
8968
|
const newT = createWordElement(doc, "w:t");
|
|
8849
8969
|
newT.setAttribute("xml:space", "preserve");
|
|
8850
8970
|
newT.textContent = prefixText;
|
|
@@ -8854,7 +8974,7 @@ function applyHighlightToOoxml(ooxmlString, targetText, color = "yellow", option
|
|
|
8854
8974
|
if (matchText.length > 0) {
|
|
8855
8975
|
const matchRun = run.cloneNode(true);
|
|
8856
8976
|
const tNodes = matchRun.getElementsByTagNameNS(NS_W7, "t");
|
|
8857
|
-
Array.from(tNodes).forEach(
|
|
8977
|
+
Array.from(tNodes).forEach(removeNode2);
|
|
8858
8978
|
const newT = createWordElement(doc, "w:t");
|
|
8859
8979
|
newT.setAttribute("xml:space", "preserve");
|
|
8860
8980
|
newT.textContent = matchText;
|
|
@@ -8872,7 +8992,7 @@ function applyHighlightToOoxml(ooxmlString, targetText, color = "yellow", option
|
|
|
8872
8992
|
if (suffixText.length > 0) {
|
|
8873
8993
|
const suffixRun = run.cloneNode(true);
|
|
8874
8994
|
const tNodes = suffixRun.getElementsByTagNameNS(NS_W7, "t");
|
|
8875
|
-
Array.from(tNodes).forEach(
|
|
8995
|
+
Array.from(tNodes).forEach(removeNode2);
|
|
8876
8996
|
const newT = createWordElement(doc, "w:t");
|
|
8877
8997
|
newT.setAttribute("xml:space", "preserve");
|
|
8878
8998
|
newT.textContent = suffixText;
|
|
@@ -9578,6 +9698,7 @@ export {
|
|
|
9578
9698
|
synthesizeExpandedListScopeEdit,
|
|
9579
9699
|
synthesizeTableMarkdownFromMultilineCellEdit,
|
|
9580
9700
|
validateDocxPackage,
|
|
9701
|
+
validateRedlineOoxml,
|
|
9581
9702
|
wrapInDocumentFragment
|
|
9582
9703
|
};
|
|
9583
9704
|
//# sourceMappingURL=docx-redline-js.esm.js.map
|