@ansonlai/docx-redline-js 0.1.4 → 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/AGENTS.md +53 -4
- package/ARCHITECTURE.md +75 -11
- package/README.md +62 -3
- package/core/redline-validation.js +156 -0
- package/core/types.js +35 -8
- package/core/word-xml.js +90 -0
- package/dist/docx-redline-js.esm.js +3195 -2592
- package/dist/docx-redline-js.esm.js.map +4 -4
- package/dist/docx-redline-js.esm.min.js +71 -67
- package/dist/docx-redline-js.esm.min.js.map +4 -4
- package/docs/VALIDATION.md +104 -0
- package/docs/plans/2026-03-01-release-0.1.4-design.md +31 -0
- package/docs/plans/2026-03-01-release-0.1.4.md +108 -0
- package/docs/plans/2026-05-31-architectural changes.md +591 -0
- package/engine/format-application.js +13 -14
- package/engine/format-span-application.js +7 -6
- package/engine/formatting-removal.js +15 -12
- package/engine/oxml-engine.js +146 -55
- package/engine/reconstruction-mapper.js +35 -8
- package/engine/reconstruction-mode.js +14 -13
- package/engine/reconstruction-writer.js +97 -78
- package/engine/rpr-helpers.js +34 -32
- package/engine/run-builders.js +150 -39
- package/engine/surgical-diff-application.js +216 -0
- package/engine/surgical-mode.js +84 -519
- package/engine/surgical-run-splitting.js +96 -0
- package/engine/surgical-spans.js +169 -0
- package/engine/table-cell-context.js +15 -13
- package/engine/table-mode.js +39 -35
- package/index.d.ts +172 -0
- package/index.js +50 -47
- package/package.json +10 -2
- package/pipeline/ingestion-export.js +1 -0
- package/pipeline/ingestion-paragraph.js +37 -12
- package/pipeline/ingestion-table.js +11 -8
- package/scripts/build.mjs +40 -0
- package/scripts/check-types.mjs +29 -0
- package/scripts/export-validation-fixtures.mjs +125 -0
- package/scripts/lib/minimal-zip.mjs +155 -0
- package/scripts/run-tests.mjs +43 -0
- package/scripts/validate-fixtures-xsd.sh +37 -0
- package/scripts/word-com-differential.ps1 +133 -0
- package/scripts/word-com-smoke.ps1 +48 -0
- package/services/comment-locator.js +10 -9
- package/services/revision-comment-management.js +115 -1
- package/services/standalone-operation-runner.js +119 -69
- package/services/table-reconciliation.js +7 -8
package/AGENTS.md
CHANGED
|
@@ -16,10 +16,12 @@ Input: (paragraph OOXML, original text, modified text, options)
|
|
|
16
16
|
Engine routes to: format-only | surgical | reconstruction | list | table mode
|
|
17
17
|
|
|
|
18
18
|
v
|
|
19
|
-
Output: { oxml: string, hasChanges: boolean, warnings?: string[] }
|
|
19
|
+
Output: { oxml: string, hasChanges: boolean, status?: string, error?: object, warnings?: string[] }
|
|
20
20
|
```
|
|
21
21
|
|
|
22
|
-
The engine works at paragraph scope. For full-document
|
|
22
|
+
The engine usually works at paragraph/range/table scope. For full-document
|
|
23
|
+
operations, use the standalone operation runner so the result is safe to write
|
|
24
|
+
back to `word/document.xml`.
|
|
23
25
|
|
|
24
26
|
## Entry Point
|
|
25
27
|
|
|
@@ -45,10 +47,15 @@ Browsers have native DOM APIs, so no provider injection is typically needed.
|
|
|
45
47
|
```js
|
|
46
48
|
const result = await applyRedlineToOxml(oxml, originalText, modifiedText, {
|
|
47
49
|
generateRedlines: true,
|
|
48
|
-
author: 'Agent Name'
|
|
50
|
+
author: 'Agent Name',
|
|
51
|
+
existingRevisions: 'reject-input'
|
|
49
52
|
});
|
|
50
53
|
```
|
|
51
54
|
|
|
55
|
+
`existingRevisions` defaults to `'reject-input'`. Use `'accept-all-first'` only
|
|
56
|
+
when the caller intentionally wants to accept prior tracked changes before
|
|
57
|
+
applying a new edit.
|
|
58
|
+
|
|
52
59
|
### Apply a text edit without tracked changes
|
|
53
60
|
|
|
54
61
|
```js
|
|
@@ -90,6 +97,9 @@ const rejectedMine = rejectTrackedChangesInOoxml(documentXml, { author: 'Agent'
|
|
|
90
97
|
const rejectedAll = rejectTrackedChangesInOoxml(documentXml, { allAuthors: true });
|
|
91
98
|
```
|
|
92
99
|
|
|
100
|
+
Move revisions are consumed too: accept removes `w:moveFrom` and unwraps
|
|
101
|
+
`w:moveTo`; reject unwraps `w:moveFrom` and removes `w:moveTo`.
|
|
102
|
+
|
|
93
103
|
### Delete comments from one user (or all users)
|
|
94
104
|
|
|
95
105
|
```js
|
|
@@ -105,6 +115,15 @@ import { applyOperationToDocumentXml } from '@ansonlai/docx-redline-js/services/
|
|
|
105
115
|
const result = await applyOperationToDocumentXml(documentXml, operation, options);
|
|
106
116
|
```
|
|
107
117
|
|
|
118
|
+
Use `result.documentXml` from this API when replacing full `word/document.xml`.
|
|
119
|
+
|
|
120
|
+
### Detect existing tracked changes
|
|
121
|
+
|
|
122
|
+
```js
|
|
123
|
+
import { containsTrackedChanges } from '@ansonlai/docx-redline-js';
|
|
124
|
+
const hasTrackedChanges = containsTrackedChanges(xmlDoc);
|
|
125
|
+
```
|
|
126
|
+
|
|
108
127
|
### Convert paragraph text into a Word list
|
|
109
128
|
|
|
110
129
|
```js
|
|
@@ -130,15 +149,21 @@ adapters/
|
|
|
130
149
|
logger.js
|
|
131
150
|
core/
|
|
132
151
|
types.js
|
|
152
|
+
word-xml.js
|
|
133
153
|
paragraph-targeting.js
|
|
134
154
|
list-targeting.js
|
|
135
155
|
table-targeting.js
|
|
136
156
|
engine/
|
|
137
157
|
oxml-engine.js
|
|
138
158
|
surgical-mode.js
|
|
159
|
+
surgical-run-splitting.js
|
|
160
|
+
surgical-diff-application.js
|
|
161
|
+
surgical-spans.js
|
|
139
162
|
reconstruction-mode.js
|
|
163
|
+
reconstruction-writer.js
|
|
140
164
|
format-application.js
|
|
141
165
|
formatting-removal.js
|
|
166
|
+
run-builders.js
|
|
142
167
|
table-mode.js
|
|
143
168
|
pipeline/
|
|
144
169
|
pipeline.js
|
|
@@ -169,7 +194,8 @@ orchestration/
|
|
|
169
194
|
```js
|
|
170
195
|
{
|
|
171
196
|
generateRedlines: true,
|
|
172
|
-
author: 'Name'
|
|
197
|
+
author: 'Name',
|
|
198
|
+
existingRevisions: 'reject-input'
|
|
173
199
|
}
|
|
174
200
|
```
|
|
175
201
|
|
|
@@ -179,12 +205,17 @@ orchestration/
|
|
|
179
205
|
{
|
|
180
206
|
oxml: string,
|
|
181
207
|
hasChanges: boolean,
|
|
208
|
+
status?: 'ok' | 'no-op' | 'error',
|
|
209
|
+
error?: { code: string, message: string },
|
|
182
210
|
warnings?: string[],
|
|
183
211
|
numberingXml?: string,
|
|
184
212
|
useNativeApi?: boolean
|
|
185
213
|
}
|
|
186
214
|
```
|
|
187
215
|
|
|
216
|
+
Known error codes include `PARSE_ERROR`, `TARGET_NOT_FOUND`, and
|
|
217
|
+
`EXISTING_REVISIONS`.
|
|
218
|
+
|
|
188
219
|
### OOXML wrapping for Word insertOoxml scenarios
|
|
189
220
|
|
|
190
221
|
```js
|
|
@@ -211,3 +242,21 @@ directly into `word/document.xml`.
|
|
|
211
242
|
5. `useNativeApi: true` means standalone mode cannot fully handle that operation path.
|
|
212
243
|
6. `deleteCommentsByAuthorInOoxml` removes matching `comments.xml` entries and linked comment anchors/references in the document.
|
|
213
244
|
7. If output begins with `<pkg:package`, treat it as package-level OOXML and normalize it before writing anything back to `word/document.xml`.
|
|
245
|
+
8. Existing revisions are rejected by default; pass `existingRevisions: 'accept-all-first'` only when that is desired.
|
|
246
|
+
9. Hyperlinks, bookmarks, comment markers, tabs/breaks, and footnote/endnote references are structural OOXML and should survive adjacent redline edits.
|
|
247
|
+
10. Internally, create Word elements through `createWordElement` and tracked-change metadata through `createRevisionMetadata`.
|
|
248
|
+
|
|
249
|
+
## Validation Commands
|
|
250
|
+
|
|
251
|
+
```bash
|
|
252
|
+
npm test
|
|
253
|
+
npm run test:isolation
|
|
254
|
+
npm run check:types
|
|
255
|
+
node scripts/export-validation-fixtures.mjs
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
Optional Windows/Word smoke test for a completed `.docx`:
|
|
259
|
+
|
|
260
|
+
```bash
|
|
261
|
+
npm run smoke:word -- path/to/file.docx
|
|
262
|
+
```
|
package/ARCHITECTURE.md
CHANGED
|
@@ -13,7 +13,8 @@ This repository contains only the publishable package surface:
|
|
|
13
13
|
- `services/`
|
|
14
14
|
- `orchestration/`
|
|
15
15
|
- `index.js`
|
|
16
|
-
- `
|
|
16
|
+
- `index.d.ts`
|
|
17
|
+
- `dist/`
|
|
17
18
|
|
|
18
19
|
No Word add-in entrypoints or host-specific integration layers are part of this package.
|
|
19
20
|
|
|
@@ -22,6 +23,8 @@ No Word add-in entrypoints or host-specific integration layers are part of this
|
|
|
22
23
|
- Preserve Word-compatible redlines by editing OOXML directly.
|
|
23
24
|
- Keep core logic host-independent (no Office.js globals, no Word API calls).
|
|
24
25
|
- Reuse the same engine in browser, Node.js, and other JavaScript runtimes.
|
|
26
|
+
- Keep generated OOXML schema-safe through shared Word element creation and
|
|
27
|
+
centralized revision metadata helpers.
|
|
25
28
|
|
|
26
29
|
## Folder Layout
|
|
27
30
|
|
|
@@ -32,16 +35,23 @@ No Word add-in entrypoints or host-specific integration layers are part of this
|
|
|
32
35
|
│ ├── logger.js
|
|
33
36
|
│ └── xml-adapter.js
|
|
34
37
|
├── core/
|
|
38
|
+
│ └── word-xml.js
|
|
35
39
|
├── engine/
|
|
40
|
+
│ ├── oxml-engine.js
|
|
41
|
+
│ ├── surgical-mode.js
|
|
42
|
+
│ ├── reconstruction-mode.js
|
|
43
|
+
│ ├── run-builders.js
|
|
36
44
|
│ └── formatting-removal.js
|
|
37
45
|
├── orchestration/
|
|
38
46
|
├── pipeline/
|
|
39
47
|
├── services/
|
|
48
|
+
│ ├── comment-engine.js
|
|
40
49
|
│ ├── numbering-helpers.js
|
|
41
50
|
│ ├── revision-comment-management.js
|
|
42
51
|
│ ├── standalone-docx-plumbing.js
|
|
43
52
|
│ └── standalone-operation-runner.js
|
|
44
|
-
|
|
53
|
+
├── index.js
|
|
54
|
+
└── index.d.ts
|
|
45
55
|
```
|
|
46
56
|
|
|
47
57
|
## Entry Points
|
|
@@ -58,20 +68,32 @@ No Word add-in entrypoints or host-specific integration layers are part of this
|
|
|
58
68
|
- Runtime logger injection and shared logging methods.
|
|
59
69
|
- `core/*`
|
|
60
70
|
- Shared types, OOXML identity helpers, target resolution, list/table targeting heuristics, and XML query helpers.
|
|
71
|
+
- `core/word-xml.js`
|
|
72
|
+
- Namespace-safe Word element creation, tracked-change detection, and OOXML payload source-shape helpers.
|
|
73
|
+
- `core/types.js`
|
|
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.
|
|
61
77
|
- `engine/oxml-engine.js`
|
|
62
|
-
- Main reconciliation router and
|
|
78
|
+
- Main reconciliation router, mode selection, existing-revision policy gate, and status/error result handling.
|
|
79
|
+
- `engine/run-builders.js`
|
|
80
|
+
- Shared builders for insertion/deletion wrappers, paragraph-mark revisions, visible run content, and run-property changes.
|
|
81
|
+
- `engine/surgical-*.js`
|
|
82
|
+
- Surgical run splitting, diff application, and span helpers for localized edits that preserve surrounding markup.
|
|
63
83
|
- `engine/formatting-removal.js`
|
|
64
84
|
- Shared formatting removal and highlight helpers.
|
|
65
85
|
- `pipeline/*`
|
|
66
|
-
- Ingestion, markdown preprocessing, diffing, patching, and serialization stages.
|
|
86
|
+
- Ingestion, markdown preprocessing, diffing, patching, and serialization stages. Ingestion treats deleted and moved-from content as non-visible text and inserted/moved-to content as visible text.
|
|
87
|
+
- `services/comment-engine.js`
|
|
88
|
+
- Comment creation and package-level comment XML handling.
|
|
67
89
|
- `services/numbering-helpers.js`
|
|
68
90
|
- Dynamic numbering ID allocation, numbering payload remapping, and schema-order-safe numbering merges.
|
|
69
91
|
- `services/standalone-docx-plumbing.js`
|
|
70
92
|
- Package-level extraction/wiring/validation for `word/document.xml`, `word/numbering.xml`, and `word/comments.xml`.
|
|
71
93
|
- `services/revision-comment-management.js`
|
|
72
|
-
- OOXML transforms for accepting/rejecting
|
|
94
|
+
- OOXML transforms for accepting/rejecting insertion, deletion, move, paragraph-mark, and property-change revisions by author/all-authors, plus deleting comments by author/all-authors.
|
|
73
95
|
- `services/standalone-operation-runner.js`
|
|
74
|
-
- Host-agnostic operation bridge for `redline`, `highlight`, and `comment` workflows.
|
|
96
|
+
- Host-agnostic operation bridge for full-document `redline`, `highlight`, and `comment` workflows.
|
|
75
97
|
- `orchestration/*`
|
|
76
98
|
- Route planning and list fallback orchestration utilities.
|
|
77
99
|
|
|
@@ -81,14 +103,35 @@ No Word add-in entrypoints or host-specific integration layers are part of this
|
|
|
81
103
|
2. Caller configures XML provider/logger/defaults when needed via `adapters/*`.
|
|
82
104
|
3. Caller invokes reconciliation APIs (`applyRedlineToOxml`, operation runner, ingestion/export helpers).
|
|
83
105
|
4. `engine/oxml-engine.js` routes to format, table, list, surgical, or reconstruction flows.
|
|
84
|
-
5. Pipeline/services return OOXML
|
|
85
|
-
6. Optional revision/comment management transforms can accept/reject revisions or delete comments by author.
|
|
106
|
+
5. Pipeline/services return OOXML, optional package artifacts (`numberingXml`, comments payloads), and non-breaking `status`/`error` fields where applicable.
|
|
107
|
+
6. Optional revision/comment management transforms can accept/reject revisions, including move revisions, or delete comments by author.
|
|
86
108
|
7. Caller writes resulting XML back to package/document boundaries.
|
|
87
109
|
|
|
88
110
|
## Public Surfaces
|
|
89
111
|
|
|
90
112
|
- Primary: `index.js`
|
|
91
|
-
|
|
113
|
+
- Types: `index.d.ts`
|
|
114
|
+
|
|
115
|
+
Keep public exports centralized through `index.js`; deep imports are supported by
|
|
116
|
+
the package `exports` map for advanced consumers, but new public APIs should
|
|
117
|
+
still be re-exported from `index.js`.
|
|
118
|
+
|
|
119
|
+
## Reliability Guardrails
|
|
120
|
+
|
|
121
|
+
- Create Word namespace elements with `createWordElement(xmlDoc, 'w:...')`.
|
|
122
|
+
Avoid direct `document.createElement('w:*')` or `createElementNS(NS_W, 'w:*')`
|
|
123
|
+
outside `core/word-xml.js`.
|
|
124
|
+
- Generate tracked-change metadata through `createRevisionMetadata(author)` so
|
|
125
|
+
`w:id`, `w:author`, and `w:date` stay consistent and document-unique.
|
|
126
|
+
- Seed revision IDs from parsed input with `seedRevisionIdsFromDocument(xmlDoc)`
|
|
127
|
+
before emitting new tracked changes.
|
|
128
|
+
- Use `containsTrackedChanges(xmlDoc)` before redlining existing revisions unless
|
|
129
|
+
the caller explicitly chooses the `existingRevisions: 'accept-all-first'` policy.
|
|
130
|
+
- Do not write unknown `result.oxml` payloads directly into `word/document.xml`;
|
|
131
|
+
normalize with `extractReplacementNodesFromOoxml(...)` or use
|
|
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 }`.
|
|
92
135
|
|
|
93
136
|
|
|
94
137
|
## Build Output
|
|
@@ -108,8 +151,26 @@ The bundle inlines `diff-match-patch` and keeps `@xmldom/xmldom` external.
|
|
|
108
151
|
- Runs the package test runner (`scripts/run-tests.mjs`) against all `tests/*.mjs` except setup helpers.
|
|
109
152
|
- `npm run test:isolation`
|
|
110
153
|
- Runs boundary checks for Word API markers and dependency-graph isolation.
|
|
111
|
-
|
|
112
|
-
|
|
154
|
+
- `npm run check:types`
|
|
155
|
+
- Smoke-checks `index.d.ts`.
|
|
156
|
+
- `node scripts/export-validation-fixtures.mjs`
|
|
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`.
|
|
162
|
+
- `npm run smoke:word -- path/to/file.docx`
|
|
163
|
+
- Optional Windows/Word COM smoke test for a completed `.docx`.
|
|
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`.
|
|
113
174
|
|
|
114
175
|
## Fast Orientation For Contributors
|
|
115
176
|
|
|
@@ -121,3 +182,6 @@ Use this sequence to understand or modify behavior without reading everything:
|
|
|
121
182
|
4. For package wiring issues, inspect `services/standalone-docx-plumbing.js`.
|
|
122
183
|
5. For revision/comment cleanup behavior, inspect `services/revision-comment-management.js`.
|
|
123
184
|
6. For numbering/list issues, inspect `services/numbering-helpers.js` and orchestration list-fallback modules.
|
|
185
|
+
7. For reliability regressions, start with `tests/roundtrip_invariant_tests.mjs`,
|
|
186
|
+
`tests/engine_reliability_tests.mjs`, `tests/paragraph_mark_revision_tests.mjs`,
|
|
187
|
+
`tests/move_revision_tests.mjs`, and `tests/hardening_status_tests.mjs`.
|
package/README.md
CHANGED
|
@@ -11,12 +11,14 @@ Converts AI-generated or programmatic text/markdown edits into valid Office Open
|
|
|
11
11
|
- Lists: generate and edit real Word lists (`w:numPr`) from markdown
|
|
12
12
|
- Tables: virtual-grid diffing for cell-level edits with merge safety
|
|
13
13
|
- Comments: inject OOXML comments anchored to text ranges
|
|
14
|
-
- Revision management: accept/reject tracked changes by author or for all authors
|
|
14
|
+
- Revision management: detect existing revisions, consume move revisions, and accept/reject tracked changes by author or for all authors
|
|
15
15
|
- Comment management: delete comments by author or for all authors
|
|
16
16
|
- Highlights: apply highlight colors to runs
|
|
17
17
|
- Markdown and OOXML conversion in both directions
|
|
18
|
+
- Status/error result fields for parse, targeting, and existing-revision failures
|
|
18
19
|
- Package plumbing helpers for numbering.xml, comments.xml, content types, and relationships
|
|
19
20
|
- Zero host dependencies: works in Node.js, browsers, Deno, and similar JS runtimes with DOM parsing support
|
|
21
|
+
- TypeScript declarations included via `index.d.ts`
|
|
20
22
|
|
|
21
23
|
## Install
|
|
22
24
|
|
|
@@ -112,6 +114,21 @@ const result = await applyRedlineToOxml(oxml, original, modified, {
|
|
|
112
114
|
| `applyRedlineToOxmlWithListFallback(oxml, original, modified, options)` | Core engine with automatic single-line list structural fallback. |
|
|
113
115
|
| `reconcileMarkdownTableOoxml(oxml, original, markdownTable, options)` | Table-specific reconciliation helper. |
|
|
114
116
|
|
|
117
|
+
Common `applyRedlineToOxml` options:
|
|
118
|
+
|
|
119
|
+
| Option | Purpose |
|
|
120
|
+
|--------|---------|
|
|
121
|
+
| `generateRedlines` | When `true`, emit Word-native tracked changes; when `false`, apply clean text changes. |
|
|
122
|
+
| `author` | Track-change author used for generated revisions. |
|
|
123
|
+
| `existingRevisions` | Policy for source OOXML that already contains tracked changes: `'reject-input'` (default) returns `status: 'error'` with code `EXISTING_REVISIONS`; `'accept-all-first'` accepts existing revisions before applying the new edit. |
|
|
124
|
+
|
|
125
|
+
Common result fields:
|
|
126
|
+
|
|
127
|
+
| Field | Purpose |
|
|
128
|
+
|-------|---------|
|
|
129
|
+
| `status` | Optional non-breaking status: `'ok'`, `'no-op'`, or `'error'`. |
|
|
130
|
+
| `error` | Present when `status === 'error'`; includes a stable `code` such as `PARSE_ERROR`, `TARGET_NOT_FOUND`, or `EXISTING_REVISIONS`. |
|
|
131
|
+
|
|
115
132
|
### Pipeline (lower-level access)
|
|
116
133
|
|
|
117
134
|
| Function | Purpose |
|
|
@@ -121,14 +138,16 @@ const result = await applyRedlineToOxml(oxml, original, modified, {
|
|
|
121
138
|
| `ingestWordOoxmlToMarkdown(oxml)` | Convert OOXML to markdown. |
|
|
122
139
|
| `ingestOoxml(oxml)` | Flatten OOXML into an internal run model with offsets. |
|
|
123
140
|
| `preprocessMarkdown(text)` | Normalize markdown and extract format hints. |
|
|
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. |
|
|
124
143
|
|
|
125
144
|
### Services
|
|
126
145
|
|
|
127
146
|
| Function | Purpose |
|
|
128
147
|
|----------|---------|
|
|
129
148
|
| `injectCommentsIntoOoxml(oxml, comments, options)` | Add comments anchored to text ranges. |
|
|
130
|
-
| `acceptTrackedChangesInOoxml(oxml, { author?, allAuthors? })` | Accept `w:ins` / `w:del` / `*PrChange` revisions for one author or all authors. |
|
|
131
|
-
| `rejectTrackedChangesInOoxml(oxml, { author?, allAuthors? })` | Reject `w:ins` / `w:del` / `*PrChange` revisions for one author or all authors. |
|
|
149
|
+
| `acceptTrackedChangesInOoxml(oxml, { author?, allAuthors? })` | Accept `w:ins` / `w:del` / `w:moveFrom` / `w:moveTo` / `*PrChange` revisions for one author or all authors. |
|
|
150
|
+
| `rejectTrackedChangesInOoxml(oxml, { author?, allAuthors? })` | Reject `w:ins` / `w:del` / `w:moveFrom` / `w:moveTo` / `*PrChange` revisions for one author or all authors. |
|
|
132
151
|
| `deleteCommentsByAuthorInOoxml(oxml, { author?, allAuthors? })` | Delete comments and matching anchors/references for one author or all authors. |
|
|
133
152
|
| `generateTableOoxml(headers, rows, options)` | Generate a `w:tbl` from tabular data. |
|
|
134
153
|
| `createDynamicNumberingIdState(numberingXml)` | Allocate numbering IDs without collisions. |
|
|
@@ -160,6 +179,8 @@ Different APIs return different OOXML shapes. Use this as a packaging safety che
|
|
|
160
179
|
### Do / Don't for Packaging
|
|
161
180
|
|
|
162
181
|
- Do use `applyOperationToDocumentXml(...).documentXml` when your intent is to replace `word/document.xml`.
|
|
182
|
+
- Redline application now strips non-visible field scaffolding (`w:fldChar`, `w:instrText`) and proofing markers (`w:proofErr`) from the matched target paragraph before diffing, while preserving the visible field result text. This avoids a class of Word-open failures caused by tracked changes spanning hidden field instruction runs.
|
|
183
|
+
- Hyperlinks, bookmarks, comment range markers, tabs/breaks, and footnote/endnote references are treated as structural OOXML that should survive adjacent redline edits instead of being orphaned or wrapped in deletions.
|
|
163
184
|
- Do use `extractReplacementNodesFromOoxml(...)` when you are consuming `result.oxml` from paragraph/range/table APIs.
|
|
164
185
|
- Do merge numbering/comments artifacts with `ensureNumberingArtifactsInZip(...)` and `ensureCommentsArtifactsInZip(...)` when those parts are present.
|
|
165
186
|
- Don't write payloads that start with `<pkg:package` directly into `word/document.xml`.
|
|
@@ -217,8 +238,46 @@ await validateDocxPackage(zip);
|
|
|
217
238
|
const output = await zip.generateAsync({ type: 'nodebuffer' });
|
|
218
239
|
```
|
|
219
240
|
|
|
241
|
+
## Validating Output
|
|
242
|
+
|
|
243
|
+
Run the automated package checks:
|
|
244
|
+
|
|
245
|
+
```bash
|
|
246
|
+
npm test
|
|
247
|
+
npm run test:isolation
|
|
248
|
+
npm run check:types
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
For release-time fixture export:
|
|
252
|
+
|
|
253
|
+
```bash
|
|
254
|
+
node scripts/export-validation-fixtures.mjs
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
On Windows with desktop Word installed, you can smoke-test a completed `.docx`:
|
|
258
|
+
|
|
259
|
+
```bash
|
|
260
|
+
npm run smoke:word -- path/to/file.docx
|
|
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
|
+
|
|
220
277
|
## Architecture
|
|
221
278
|
|
|
222
279
|
See [ARCHITECTURE.md](./ARCHITECTURE.md) for module layout, data flow, and contributor guidance.
|
|
223
280
|
|
|
224
281
|
See [AGENTS.md](./AGENTS.md) for a concise reference for AI coding agents.
|
|
282
|
+
|
|
283
|
+
See [docs/VALIDATION.md](./docs/VALIDATION.md) for release-time validation steps.
|
|
@@ -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
|
+
}
|
package/core/types.js
CHANGED
|
@@ -120,12 +120,13 @@ export const NumberSuffix = Object.freeze({
|
|
|
120
120
|
* @property {FormatHint[]} formatHints - Position-based format information
|
|
121
121
|
*/
|
|
122
122
|
|
|
123
|
-
/**
|
|
124
|
-
* @typedef {Object} ReconciliationResult
|
|
125
|
-
* @property {string} ooxml - The reconciled OOXML output
|
|
126
|
-
* @property {boolean} isValid - Whether validation passed
|
|
127
|
-
* @property {string[]} warnings - Any warnings during processing
|
|
128
|
-
|
|
123
|
+
/**
|
|
124
|
+
* @typedef {Object} ReconciliationResult
|
|
125
|
+
* @property {string} ooxml - The reconciled OOXML output
|
|
126
|
+
* @property {boolean} isValid - Whether validation passed
|
|
127
|
+
* @property {string[]} warnings - Any warnings during processing
|
|
128
|
+
* @property {'package'|'document'|'fragment'} [sourceType] - Shape of the OOXML payload when known
|
|
129
|
+
*/
|
|
129
130
|
|
|
130
131
|
/**
|
|
131
132
|
* @typedef {Object} SerializationOptions
|
|
@@ -194,8 +195,34 @@ export function createRevisionMetadata(author) {
|
|
|
194
195
|
date: getRevisionTimestamp()
|
|
195
196
|
};
|
|
196
197
|
}
|
|
197
|
-
|
|
198
|
-
/**
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Seeds the revision ID counter above any existing Word revision/comment id values.
|
|
201
|
+
*
|
|
202
|
+
* @param {Document|Element} xmlDoc - Parsed OOXML document or element
|
|
203
|
+
* @returns {number} Next revision id after seeding
|
|
204
|
+
*/
|
|
205
|
+
export function seedRevisionIdsFromDocument(xmlDoc) {
|
|
206
|
+
let maxFound = -1;
|
|
207
|
+
const elements = Array.from(xmlDoc?.getElementsByTagName?.('*') || []);
|
|
208
|
+
|
|
209
|
+
for (const element of elements) {
|
|
210
|
+
for (const attr of Array.from(element.attributes || [])) {
|
|
211
|
+
if ((attr.localName || '').toLowerCase() !== 'id') continue;
|
|
212
|
+
const parsed = Number.parseInt(attr.value, 10);
|
|
213
|
+
if (Number.isFinite(parsed)) {
|
|
214
|
+
maxFound = Math.max(maxFound, parsed);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (maxFound >= revisionIdCounter) {
|
|
220
|
+
revisionIdCounter = maxFound + 1;
|
|
221
|
+
}
|
|
222
|
+
return revisionIdCounter;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
199
226
|
* Resets the revision ID counter (for testing)
|
|
200
227
|
* @param {number} [startValue=1000] - Value to reset to
|
|
201
228
|
*/
|