@ansonlai/docx-redline-js 0.1.3 → 0.1.6
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 +91 -5
- package/ARCHITECTURE.md +62 -9
- package/README.md +94 -3
- package/core/types.js +35 -8
- package/core/word-xml.js +90 -0
- package/dist/docx-redline-js.esm.js +1149 -367
- package/dist/docx-redline-js.esm.js.map +4 -4
- package/dist/docx-redline-js.esm.min.js +78 -74
- package/dist/docx-redline-js.esm.min.js.map +4 -4
- package/docs/VALIDATION.md +48 -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/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 +148 -0
- package/index.js +26 -19
- package/package.json +8 -1
- 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 +35 -0
- package/scripts/check-types.mjs +28 -0
- package/scripts/export-validation-fixtures.mjs +68 -0
- package/scripts/run-tests.mjs +43 -0
- package/scripts/word-com-smoke.ps1 +48 -0
- package/services/comment-locator.js +10 -9
- package/services/revision-comment-management.js +501 -0
- 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
|
|
@@ -74,6 +81,33 @@ const result = injectCommentsIntoOoxml(paragraphOoxml, [
|
|
|
74
81
|
]);
|
|
75
82
|
```
|
|
76
83
|
|
|
84
|
+
### Accept tracked changes from one user (or all users)
|
|
85
|
+
|
|
86
|
+
```js
|
|
87
|
+
import { acceptTrackedChangesInOoxml } from '@ansonlai/docx-redline-js';
|
|
88
|
+
const acceptedMine = acceptTrackedChangesInOoxml(documentXml, { author: 'Agent' });
|
|
89
|
+
const acceptedAll = acceptTrackedChangesInOoxml(documentXml, { allAuthors: true });
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Reject tracked changes from one user (or all users)
|
|
93
|
+
|
|
94
|
+
```js
|
|
95
|
+
import { rejectTrackedChangesInOoxml } from '@ansonlai/docx-redline-js';
|
|
96
|
+
const rejectedMine = rejectTrackedChangesInOoxml(documentXml, { author: 'Agent' });
|
|
97
|
+
const rejectedAll = rejectTrackedChangesInOoxml(documentXml, { allAuthors: true });
|
|
98
|
+
```
|
|
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
|
+
|
|
103
|
+
### Delete comments from one user (or all users)
|
|
104
|
+
|
|
105
|
+
```js
|
|
106
|
+
import { deleteCommentsByAuthorInOoxml } from '@ansonlai/docx-redline-js';
|
|
107
|
+
const removedMine = deleteCommentsByAuthorInOoxml(packageOrDocumentOoxml, { author: 'Agent' });
|
|
108
|
+
const removedAll = deleteCommentsByAuthorInOoxml(packageOrDocumentOoxml, { allAuthors: true });
|
|
109
|
+
```
|
|
110
|
+
|
|
77
111
|
### Apply multiple operations to full document XML
|
|
78
112
|
|
|
79
113
|
```js
|
|
@@ -81,6 +115,15 @@ import { applyOperationToDocumentXml } from '@ansonlai/docx-redline-js/services/
|
|
|
81
115
|
const result = await applyOperationToDocumentXml(documentXml, operation, options);
|
|
82
116
|
```
|
|
83
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
|
+
|
|
84
127
|
### Convert paragraph text into a Word list
|
|
85
128
|
|
|
86
129
|
```js
|
|
@@ -106,15 +149,21 @@ adapters/
|
|
|
106
149
|
logger.js
|
|
107
150
|
core/
|
|
108
151
|
types.js
|
|
152
|
+
word-xml.js
|
|
109
153
|
paragraph-targeting.js
|
|
110
154
|
list-targeting.js
|
|
111
155
|
table-targeting.js
|
|
112
156
|
engine/
|
|
113
157
|
oxml-engine.js
|
|
114
158
|
surgical-mode.js
|
|
159
|
+
surgical-run-splitting.js
|
|
160
|
+
surgical-diff-application.js
|
|
161
|
+
surgical-spans.js
|
|
115
162
|
reconstruction-mode.js
|
|
163
|
+
reconstruction-writer.js
|
|
116
164
|
format-application.js
|
|
117
165
|
formatting-removal.js
|
|
166
|
+
run-builders.js
|
|
118
167
|
table-mode.js
|
|
119
168
|
pipeline/
|
|
120
169
|
pipeline.js
|
|
@@ -129,6 +178,7 @@ services/
|
|
|
129
178
|
standalone-docx-plumbing.js
|
|
130
179
|
numbering-helpers.js
|
|
131
180
|
comment-engine.js
|
|
181
|
+
revision-comment-management.js
|
|
132
182
|
table-reconciliation.js
|
|
133
183
|
package-builder.js
|
|
134
184
|
orchestration/
|
|
@@ -144,7 +194,8 @@ orchestration/
|
|
|
144
194
|
```js
|
|
145
195
|
{
|
|
146
196
|
generateRedlines: true,
|
|
147
|
-
author: 'Name'
|
|
197
|
+
author: 'Name',
|
|
198
|
+
existingRevisions: 'reject-input'
|
|
148
199
|
}
|
|
149
200
|
```
|
|
150
201
|
|
|
@@ -154,12 +205,17 @@ orchestration/
|
|
|
154
205
|
{
|
|
155
206
|
oxml: string,
|
|
156
207
|
hasChanges: boolean,
|
|
208
|
+
status?: 'ok' | 'no-op' | 'error',
|
|
209
|
+
error?: { code: string, message: string },
|
|
157
210
|
warnings?: string[],
|
|
158
211
|
numberingXml?: string,
|
|
159
212
|
useNativeApi?: boolean
|
|
160
213
|
}
|
|
161
214
|
```
|
|
162
215
|
|
|
216
|
+
Known error codes include `PARSE_ERROR`, `TARGET_NOT_FOUND`, and
|
|
217
|
+
`EXISTING_REVISIONS`.
|
|
218
|
+
|
|
163
219
|
### OOXML wrapping for Word insertOoxml scenarios
|
|
164
220
|
|
|
165
221
|
```js
|
|
@@ -167,10 +223,40 @@ import { wrapInDocumentFragment } from '@ansonlai/docx-redline-js';
|
|
|
167
223
|
const wrapped = wrapInDocumentFragment(rawOoxml, { includeNumbering: true, numberingXml });
|
|
168
224
|
```
|
|
169
225
|
|
|
226
|
+
### Output shape guardrail (important for packaging)
|
|
227
|
+
|
|
228
|
+
When consuming `result.oxml`, do not assume the payload is always safe to write
|
|
229
|
+
directly into `word/document.xml`.
|
|
230
|
+
|
|
231
|
+
- Paragraph/range/table APIs can return a fragment, `<w:document>`, or package payload (`<pkg:package>`).
|
|
232
|
+
- `applyOperationToDocumentXml(...).documentXml` is the document-safe path when you need a full `word/document.xml` replacement.
|
|
233
|
+
- Use `extractReplacementNodesFromOoxml(payload)` to normalize unknown payloads.
|
|
234
|
+
- If `sourceType === 'package'` or the payload starts with `<pkg:package`, do not write it into `word/document.xml` as-is.
|
|
235
|
+
|
|
170
236
|
## Gotchas
|
|
171
237
|
|
|
172
238
|
1. Call `configureXmlProvider` first in Node.js.
|
|
173
239
|
2. `applyRedlineToOxml` is async.
|
|
174
240
|
3. Paragraph APIs expect paragraph-level OOXML, not full `word/document.xml` in all cases.
|
|
175
241
|
4. List operations may return `numberingXml` that must be merged into package parts.
|
|
176
|
-
5. `useNativeApi: true` means standalone mode cannot fully handle that operation path.
|
|
242
|
+
5. `useNativeApi: true` means standalone mode cannot fully handle that operation path.
|
|
243
|
+
6. `deleteCommentsByAuthorInOoxml` removes matching `comments.xml` entries and linked comment anchors/references in the document.
|
|
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,15 +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
|
|
50
|
+
│ ├── revision-comment-management.js
|
|
41
51
|
│ ├── standalone-docx-plumbing.js
|
|
42
52
|
│ └── standalone-operation-runner.js
|
|
43
|
-
|
|
53
|
+
├── index.js
|
|
54
|
+
└── index.d.ts
|
|
44
55
|
```
|
|
45
56
|
|
|
46
57
|
## Entry Points
|
|
@@ -57,18 +68,30 @@ No Word add-in entrypoints or host-specific integration layers are part of this
|
|
|
57
68
|
- Runtime logger injection and shared logging methods.
|
|
58
69
|
- `core/*`
|
|
59
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.
|
|
60
75
|
- `engine/oxml-engine.js`
|
|
61
|
-
- Main reconciliation router and
|
|
76
|
+
- Main reconciliation router, mode selection, existing-revision policy gate, and status/error result handling.
|
|
77
|
+
- `engine/run-builders.js`
|
|
78
|
+
- Shared builders for insertion/deletion wrappers, paragraph-mark revisions, visible run content, and run-property changes.
|
|
79
|
+
- `engine/surgical-*.js`
|
|
80
|
+
- Surgical run splitting, diff application, and span helpers for localized edits that preserve surrounding markup.
|
|
62
81
|
- `engine/formatting-removal.js`
|
|
63
82
|
- Shared formatting removal and highlight helpers.
|
|
64
83
|
- `pipeline/*`
|
|
65
|
-
- Ingestion, markdown preprocessing, diffing, patching, and serialization stages.
|
|
84
|
+
- 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.
|
|
85
|
+
- `services/comment-engine.js`
|
|
86
|
+
- Comment creation and package-level comment XML handling.
|
|
66
87
|
- `services/numbering-helpers.js`
|
|
67
88
|
- Dynamic numbering ID allocation, numbering payload remapping, and schema-order-safe numbering merges.
|
|
68
89
|
- `services/standalone-docx-plumbing.js`
|
|
69
90
|
- Package-level extraction/wiring/validation for `word/document.xml`, `word/numbering.xml`, and `word/comments.xml`.
|
|
91
|
+
- `services/revision-comment-management.js`
|
|
92
|
+
- 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.
|
|
70
93
|
- `services/standalone-operation-runner.js`
|
|
71
|
-
- Host-agnostic operation bridge for `redline`, `highlight`, and `comment` workflows.
|
|
94
|
+
- Host-agnostic operation bridge for full-document `redline`, `highlight`, and `comment` workflows.
|
|
72
95
|
- `orchestration/*`
|
|
73
96
|
- Route planning and list fallback orchestration utilities.
|
|
74
97
|
|
|
@@ -78,13 +101,33 @@ No Word add-in entrypoints or host-specific integration layers are part of this
|
|
|
78
101
|
2. Caller configures XML provider/logger/defaults when needed via `adapters/*`.
|
|
79
102
|
3. Caller invokes reconciliation APIs (`applyRedlineToOxml`, operation runner, ingestion/export helpers).
|
|
80
103
|
4. `engine/oxml-engine.js` routes to format, table, list, surgical, or reconstruction flows.
|
|
81
|
-
5. Pipeline/services return OOXML
|
|
82
|
-
6.
|
|
104
|
+
5. Pipeline/services return OOXML, optional package artifacts (`numberingXml`, comments payloads), and non-breaking `status`/`error` fields where applicable.
|
|
105
|
+
6. Optional revision/comment management transforms can accept/reject revisions, including move revisions, or delete comments by author.
|
|
106
|
+
7. Caller writes resulting XML back to package/document boundaries.
|
|
83
107
|
|
|
84
108
|
## Public Surfaces
|
|
85
109
|
|
|
86
110
|
- Primary: `index.js`
|
|
87
|
-
|
|
111
|
+
- Types: `index.d.ts`
|
|
112
|
+
|
|
113
|
+
Keep public exports centralized through `index.js`; deep imports are supported by
|
|
114
|
+
the package `exports` map for advanced consumers, but new public APIs should
|
|
115
|
+
still be re-exported from `index.js`.
|
|
116
|
+
|
|
117
|
+
## Reliability Guardrails
|
|
118
|
+
|
|
119
|
+
- Create Word namespace elements with `createWordElement(xmlDoc, 'w:...')`.
|
|
120
|
+
Avoid direct `document.createElement('w:*')` or `createElementNS(NS_W, 'w:*')`
|
|
121
|
+
outside `core/word-xml.js`.
|
|
122
|
+
- Generate tracked-change metadata through `createRevisionMetadata(author)` so
|
|
123
|
+
`w:id`, `w:author`, and `w:date` stay consistent and document-unique.
|
|
124
|
+
- Seed revision IDs from parsed input with `seedRevisionIdsFromDocument(xmlDoc)`
|
|
125
|
+
before emitting new tracked changes.
|
|
126
|
+
- Use `containsTrackedChanges(xmlDoc)` before redlining existing revisions unless
|
|
127
|
+
the caller explicitly chooses the `existingRevisions: 'accept-all-first'` policy.
|
|
128
|
+
- Do not write unknown `result.oxml` payloads directly into `word/document.xml`;
|
|
129
|
+
normalize with `extractReplacementNodesFromOoxml(...)` or use
|
|
130
|
+
`applyOperationToDocumentXml(...).documentXml` for full-document replacement.
|
|
88
131
|
|
|
89
132
|
|
|
90
133
|
## Build Output
|
|
@@ -104,6 +147,12 @@ The bundle inlines `diff-match-patch` and keeps `@xmldom/xmldom` external.
|
|
|
104
147
|
- Runs the package test runner (`scripts/run-tests.mjs`) against all `tests/*.mjs` except setup helpers.
|
|
105
148
|
- `npm run test:isolation`
|
|
106
149
|
- Runs boundary checks for Word API markers and dependency-graph isolation.
|
|
150
|
+
- `npm run check:types`
|
|
151
|
+
- Smoke-checks `index.d.ts`.
|
|
152
|
+
- `node scripts/export-validation-fixtures.mjs`
|
|
153
|
+
- Writes release-time validation fixtures to `tmp/validation-docx/`.
|
|
154
|
+
- `npm run smoke:word -- path/to/file.docx`
|
|
155
|
+
- Optional Windows/Word COM smoke test for a completed `.docx`.
|
|
107
156
|
|
|
108
157
|
Use these checks before publishing or tagging.
|
|
109
158
|
|
|
@@ -115,4 +164,8 @@ Use this sequence to understand or modify behavior without reading everything:
|
|
|
115
164
|
2. Follow exports into `engine/oxml-engine.js` or relevant `services/*` module.
|
|
116
165
|
3. For targeting bugs, inspect `core/paragraph-targeting.js`, `core/list-targeting.js`, and `core/table-targeting.js`.
|
|
117
166
|
4. For package wiring issues, inspect `services/standalone-docx-plumbing.js`.
|
|
118
|
-
5. For
|
|
167
|
+
5. For revision/comment cleanup behavior, inspect `services/revision-comment-management.js`.
|
|
168
|
+
6. For numbering/list issues, inspect `services/numbering-helpers.js` and orchestration list-fallback modules.
|
|
169
|
+
7. For reliability regressions, start with `tests/roundtrip_invariant_tests.mjs`,
|
|
170
|
+
`tests/engine_reliability_tests.mjs`, `tests/paragraph_mark_revision_tests.mjs`,
|
|
171
|
+
`tests/move_revision_tests.mjs`, and `tests/hardening_status_tests.mjs`.
|
package/README.md
CHANGED
|
@@ -11,10 +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: detect existing revisions, consume move revisions, and accept/reject tracked changes by author or for all authors
|
|
15
|
+
- Comment management: delete comments by author or for all authors
|
|
14
16
|
- Highlights: apply highlight colors to runs
|
|
15
17
|
- Markdown and OOXML conversion in both directions
|
|
18
|
+
- Status/error result fields for parse, targeting, and existing-revision failures
|
|
16
19
|
- Package plumbing helpers for numbering.xml, comments.xml, content types, and relationships
|
|
17
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`
|
|
18
22
|
|
|
19
23
|
## Install
|
|
20
24
|
|
|
@@ -110,6 +114,21 @@ const result = await applyRedlineToOxml(oxml, original, modified, {
|
|
|
110
114
|
| `applyRedlineToOxmlWithListFallback(oxml, original, modified, options)` | Core engine with automatic single-line list structural fallback. |
|
|
111
115
|
| `reconcileMarkdownTableOoxml(oxml, original, markdownTable, options)` | Table-specific reconciliation helper. |
|
|
112
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
|
+
|
|
113
132
|
### Pipeline (lower-level access)
|
|
114
133
|
|
|
115
134
|
| Function | Purpose |
|
|
@@ -119,12 +138,16 @@ const result = await applyRedlineToOxml(oxml, original, modified, {
|
|
|
119
138
|
| `ingestWordOoxmlToMarkdown(oxml)` | Convert OOXML to markdown. |
|
|
120
139
|
| `ingestOoxml(oxml)` | Flatten OOXML into an internal run model with offsets. |
|
|
121
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. |
|
|
122
142
|
|
|
123
143
|
### Services
|
|
124
144
|
|
|
125
145
|
| Function | Purpose |
|
|
126
146
|
|----------|---------|
|
|
127
147
|
| `injectCommentsIntoOoxml(oxml, comments, options)` | Add comments anchored to text ranges. |
|
|
148
|
+
| `acceptTrackedChangesInOoxml(oxml, { author?, allAuthors? })` | Accept `w:ins` / `w:del` / `w:moveFrom` / `w:moveTo` / `*PrChange` revisions for one author or all authors. |
|
|
149
|
+
| `rejectTrackedChangesInOoxml(oxml, { author?, allAuthors? })` | Reject `w:ins` / `w:del` / `w:moveFrom` / `w:moveTo` / `*PrChange` revisions for one author or all authors. |
|
|
150
|
+
| `deleteCommentsByAuthorInOoxml(oxml, { author?, allAuthors? })` | Delete comments and matching anchors/references for one author or all authors. |
|
|
128
151
|
| `generateTableOoxml(headers, rows, options)` | Generate a `w:tbl` from tabular data. |
|
|
129
152
|
| `createDynamicNumberingIdState(numberingXml)` | Allocate numbering IDs without collisions. |
|
|
130
153
|
| `ensureNumberingArtifactsInZip(zip, numberingXml)` | Merge numbering artifacts into a `.docx` package. |
|
|
@@ -140,6 +163,28 @@ import { applyOperationToDocumentXml } from '@ansonlai/docx-redline-js/services/
|
|
|
140
163
|
import { getParagraphText } from '@ansonlai/docx-redline-js/core/paragraph-targeting.js';
|
|
141
164
|
```
|
|
142
165
|
|
|
166
|
+
### Output Shape Matrix
|
|
167
|
+
|
|
168
|
+
Different APIs return different OOXML shapes. Use this as a packaging safety check.
|
|
169
|
+
|
|
170
|
+
| API | Typical input scope | Output field | Possible root/output shape | Safe to write directly into `word/document.xml` |
|
|
171
|
+
|-----|----------------------|--------------|----------------------------|--------------------------------------------------|
|
|
172
|
+
| `applyRedlineToOxml(...)` | Paragraph, range, or table-scope OOXML | `result.oxml` | Fragment, `<w:document>`, or package payload (`<pkg:package>`) | No. Inspect first. |
|
|
173
|
+
| `applyRedlineToOxmlWithListFallback(...)` | Paragraph or range-scope OOXML | `result.oxml` | Fragment, `<w:document>`, or package payload (`<pkg:package>`) | No. Inspect first. |
|
|
174
|
+
| `reconcileMarkdownTableOoxml(...)` | Table or paragraph-scope OOXML | `result.oxml` | Same shapes as `applyRedlineToOxml(...)` for the supplied scope | No. Inspect first. |
|
|
175
|
+
| `applyOperationToDocumentXml(...)` | Full `word/document.xml` string | `result.documentXml` | `<w:document>` | Yes. This is the document-safe helper. |
|
|
176
|
+
| `extractReplacementNodesFromOoxml(...)` | Any OOXML payload | `{ replacementNodes, numberingXml, sourceType }` | Normalized to `fragment`, `document`, or `package` | Yes. Use this when consuming `result.oxml`. |
|
|
177
|
+
|
|
178
|
+
### Do / Don't for Packaging
|
|
179
|
+
|
|
180
|
+
- Do use `applyOperationToDocumentXml(...).documentXml` when your intent is to replace `word/document.xml`.
|
|
181
|
+
- 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.
|
|
182
|
+
- 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.
|
|
183
|
+
- Do use `extractReplacementNodesFromOoxml(...)` when you are consuming `result.oxml` from paragraph/range/table APIs.
|
|
184
|
+
- Do merge numbering/comments artifacts with `ensureNumberingArtifactsInZip(...)` and `ensureCommentsArtifactsInZip(...)` when those parts are present.
|
|
185
|
+
- Don't write payloads that start with `<pkg:package` directly into `word/document.xml`.
|
|
186
|
+
- Don't assume every `result.oxml` payload is a raw paragraph fragment.
|
|
187
|
+
|
|
143
188
|
## Working With `.docx` Files
|
|
144
189
|
|
|
145
190
|
This package operates on OOXML strings (XML parts inside `.docx` zip archives), not raw `.docx` binaries.
|
|
@@ -155,23 +200,69 @@ Typical flow:
|
|
|
155
200
|
```js
|
|
156
201
|
import JSZip from 'jszip';
|
|
157
202
|
import {
|
|
158
|
-
configureXmlProvider,
|
|
159
203
|
applyRedlineToOxml,
|
|
204
|
+
extractReplacementNodesFromOoxml,
|
|
160
205
|
ensureNumberingArtifactsInZip,
|
|
161
206
|
validateDocxPackage
|
|
162
207
|
} from '@ansonlai/docx-redline-js';
|
|
208
|
+
import { applyOperationToDocumentXml } from '@ansonlai/docx-redline-js/services/standalone-operation-runner.js';
|
|
163
209
|
|
|
164
210
|
const zip = await JSZip.loadAsync(docxBuffer);
|
|
165
211
|
const documentXml = await zip.file('word/document.xml').async('string');
|
|
166
212
|
|
|
167
|
-
|
|
168
|
-
|
|
213
|
+
const opResult = await applyOperationToDocumentXml(
|
|
214
|
+
documentXml,
|
|
215
|
+
{ type: 'redline', target: 'old text', modified: 'new text' },
|
|
216
|
+
'Editor'
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
// applyOperationToDocumentXml(...) returns a full w:document payload.
|
|
220
|
+
zip.file('word/document.xml', opResult.documentXml);
|
|
221
|
+
|
|
222
|
+
const fragmentResult = await applyRedlineToOxml(
|
|
223
|
+
paragraphOoxml,
|
|
224
|
+
'Item text',
|
|
225
|
+
'1. Item text',
|
|
226
|
+
{ generateRedlines: true, author: 'Editor' }
|
|
227
|
+
);
|
|
228
|
+
const normalized = extractReplacementNodesFromOoxml(fragmentResult.oxml);
|
|
229
|
+
|
|
230
|
+
// If sourceType === 'package', merge extracted content/artifacts instead of
|
|
231
|
+
// writing the raw pkg:package payload into word/document.xml.
|
|
232
|
+
if (normalized.numberingXml) {
|
|
233
|
+
await ensureNumberingArtifactsInZip(zip, normalized.numberingXml);
|
|
234
|
+
}
|
|
169
235
|
|
|
236
|
+
await validateDocxPackage(zip);
|
|
170
237
|
const output = await zip.generateAsync({ type: 'nodebuffer' });
|
|
171
238
|
```
|
|
172
239
|
|
|
240
|
+
## Validating Output
|
|
241
|
+
|
|
242
|
+
Run the automated package checks:
|
|
243
|
+
|
|
244
|
+
```bash
|
|
245
|
+
npm test
|
|
246
|
+
npm run test:isolation
|
|
247
|
+
npm run check:types
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
For release-time fixture export:
|
|
251
|
+
|
|
252
|
+
```bash
|
|
253
|
+
node scripts/export-validation-fixtures.mjs
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
On Windows with desktop Word installed, you can smoke-test a completed `.docx`:
|
|
257
|
+
|
|
258
|
+
```bash
|
|
259
|
+
npm run smoke:word -- path/to/file.docx
|
|
260
|
+
```
|
|
261
|
+
|
|
173
262
|
## Architecture
|
|
174
263
|
|
|
175
264
|
See [ARCHITECTURE.md](./ARCHITECTURE.md) for module layout, data flow, and contributor guidance.
|
|
176
265
|
|
|
177
266
|
See [AGENTS.md](./AGENTS.md) for a concise reference for AI coding agents.
|
|
267
|
+
|
|
268
|
+
See [docs/VALIDATION.md](./docs/VALIDATION.md) for release-time validation steps.
|
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
|
*/
|
package/core/word-xml.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { NS_W } from './types.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Returns true when a node is a WordprocessingML element with the given local name.
|
|
5
|
+
*
|
|
6
|
+
* @param {Node|null|undefined} node - Candidate node
|
|
7
|
+
* @param {string} localName - Word local name, for example `r` or `tbl`
|
|
8
|
+
* @returns {boolean}
|
|
9
|
+
*/
|
|
10
|
+
export function isWordElement(node, localName) {
|
|
11
|
+
if (!node || node.nodeType !== 1) return false;
|
|
12
|
+
if (node.namespaceURI === NS_W && node.localName === localName) return true;
|
|
13
|
+
const nodeName = String(node.nodeName || '');
|
|
14
|
+
return nodeName === `w:${localName}` || nodeName === localName;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Creates a WordprocessingML element using namespace-aware DOM APIs when available.
|
|
19
|
+
*
|
|
20
|
+
* @param {Document} xmlDoc - Target document
|
|
21
|
+
* @param {string} qualifiedName - Qualified name, for example `w:r`
|
|
22
|
+
* @returns {Element}
|
|
23
|
+
*/
|
|
24
|
+
export function createWordElement(xmlDoc, qualifiedName) {
|
|
25
|
+
return typeof xmlDoc.createElementNS === 'function'
|
|
26
|
+
? xmlDoc.createElementNS(NS_W, qualifiedName)
|
|
27
|
+
: xmlDoc.createElement(qualifiedName);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function wordElementsByLocalName(xmlDoc, localName) {
|
|
31
|
+
const namespaced = Array.from(xmlDoc?.getElementsByTagNameNS?.(NS_W, localName) || []);
|
|
32
|
+
if (namespaced.length > 0) return namespaced;
|
|
33
|
+
return Array.from(xmlDoc?.getElementsByTagName?.('*') || []).filter(node => isWordElement(node, localName));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Returns true if a document or fragment contains Word tracked-change markup.
|
|
38
|
+
*
|
|
39
|
+
* @param {Document|Element} xmlDoc - Parsed OOXML document or element
|
|
40
|
+
* @returns {boolean}
|
|
41
|
+
*/
|
|
42
|
+
export function containsTrackedChanges(xmlDoc) {
|
|
43
|
+
const trackedChangeNames = [
|
|
44
|
+
'ins',
|
|
45
|
+
'del',
|
|
46
|
+
'moveFrom',
|
|
47
|
+
'moveTo',
|
|
48
|
+
'moveFromRangeStart',
|
|
49
|
+
'moveFromRangeEnd',
|
|
50
|
+
'moveToRangeStart',
|
|
51
|
+
'moveToRangeEnd',
|
|
52
|
+
'rPrChange',
|
|
53
|
+
'pPrChange',
|
|
54
|
+
'cellIns',
|
|
55
|
+
'cellDel'
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
return trackedChangeNames.some(localName => wordElementsByLocalName(xmlDoc, localName).length > 0);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Classifies the shape of an OOXML payload.
|
|
63
|
+
*
|
|
64
|
+
* @param {string} oxml - OOXML payload
|
|
65
|
+
* @returns {'package'|'document'|'fragment'}
|
|
66
|
+
*/
|
|
67
|
+
export function classifyOoxmlSourceType(oxml) {
|
|
68
|
+
const trimmed = String(oxml || '').trim();
|
|
69
|
+
if (/^<\?xml\b[^>]*>\s*<pkg:package\b/i.test(trimmed) || /^<pkg:package\b/i.test(trimmed)) {
|
|
70
|
+
return 'package';
|
|
71
|
+
}
|
|
72
|
+
if (/^<\?xml\b[^>]*>\s*<(?:w:)?document\b/i.test(trimmed) || /^<(?:w:)?document\b/i.test(trimmed)) {
|
|
73
|
+
return 'document';
|
|
74
|
+
}
|
|
75
|
+
return 'fragment';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Adds `sourceType` metadata to OOXML result objects without changing payloads.
|
|
80
|
+
*
|
|
81
|
+
* @template T
|
|
82
|
+
* @param {T & { oxml?: string, sourceType?: 'package'|'document'|'fragment' }} result - Result object
|
|
83
|
+
* @returns {T & { sourceType?: 'package'|'document'|'fragment' }}
|
|
84
|
+
*/
|
|
85
|
+
export function withOoxmlSourceType(result) {
|
|
86
|
+
if (!result || typeof result !== 'object' || result.sourceType || typeof result.oxml !== 'string') {
|
|
87
|
+
return result;
|
|
88
|
+
}
|
|
89
|
+
return { ...result, sourceType: classifyOoxmlSourceType(result.oxml) };
|
|
90
|
+
}
|