@ansonlai/docx-redline-js 0.1.6 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +19 -3
- package/README.md +15 -0
- package/core/redline-validation.js +156 -0
- package/dist/docx-redline-js.esm.js +125 -1
- package/dist/docx-redline-js.esm.js.map +4 -4
- package/dist/docx-redline-js.esm.min.js +61 -61
- 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/index.d.ts +24 -0
- package/index.js +35 -34
- package/package.json +3 -2
- 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
|
@@ -0,0 +1,591 @@
|
|
|
1
|
+
# Redline Reliability Improvement Plan
|
|
2
|
+
|
|
3
|
+
This plan hardens the library against the ways Microsoft Word redlines are brittle.
|
|
4
|
+
It is written to be executed phase by phase, in order. Each phase is independently
|
|
5
|
+
shippable and ends with a green `npm test`.
|
|
6
|
+
|
|
7
|
+
**Scope note:** A high-level docx-in/docx-out wrapper API is explicitly OUT of scope.
|
|
8
|
+
Downstream tools own the packaging layer. Do not add a JSZip dependency or any
|
|
9
|
+
`applyRedlineToDocx`-style API to this package.
|
|
10
|
+
|
|
11
|
+
**Final audit status:** Complete. The plan has been reviewed top to bottom after
|
|
12
|
+
Phases 1-6. Stale status notes were removed, remaining namespace/metadata
|
|
13
|
+
convention gaps were closed, and the validation commands at the end of this plan
|
|
14
|
+
pass. The only intentionally unimplemented item is Phase 5.4 move-emission, which
|
|
15
|
+
is explicitly marked as a stretch/separate-PR item; move consumption is complete.
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Conventions (read before starting any phase)
|
|
20
|
+
|
|
21
|
+
- Tests live in `tests/*.mjs` and are auto-discovered by `scripts/run-tests.mjs`
|
|
22
|
+
(run via `npm test`). Files in `tests/helpers/` and `tests/setup-xml-provider.mjs`
|
|
23
|
+
are excluded from discovery. Follow the style of existing test files
|
|
24
|
+
(e.g. `tests/revision_comment_management_tests.mjs`): plain `assert/strict`,
|
|
25
|
+
no test framework.
|
|
26
|
+
- Shared assertion helpers belong in `tests/helpers/ooxml-assertions.mjs`. Add new
|
|
27
|
+
helpers there rather than duplicating in test files.
|
|
28
|
+
- All XML element creation must go through `createWordElement` in `core/word-xml.js`
|
|
29
|
+
so namespaces are correct. Never use `document.createElement` for `w:*` elements.
|
|
30
|
+
- Revision metadata (`w:id`, `w:author`, `w:date`) must come from
|
|
31
|
+
`createRevisionMetadata(author)` in `core/types.js`. Never hand-roll these attributes.
|
|
32
|
+
- All public API additions must be exported through `index.js` (the only public surface,
|
|
33
|
+
per ARCHITECTURE.md).
|
|
34
|
+
- After each phase: run `npm test` and `npm run test:isolation`. Both must pass.
|
|
35
|
+
- Do not change existing public function signatures. New behavior is added via new
|
|
36
|
+
optional fields on options/result objects.
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Phase 1 — Round-trip invariant test harness (do this first)
|
|
41
|
+
|
|
42
|
+
**Status:** Complete.
|
|
43
|
+
|
|
44
|
+
- Added reusable structural assertions to `tests/helpers/ooxml-assertions.mjs`.
|
|
45
|
+
- Added `tests/helpers/roundtrip.mjs` with `assertRoundTrip(...)`.
|
|
46
|
+
- Added initial corpus in `tests/roundtrip_invariant_tests.mjs`.
|
|
47
|
+
- Fixed a Phase 1-discovered namespace bug in `engine/reconstruction-writer.js` by routing Word element creation through `createWordElement`.
|
|
48
|
+
- Added optional Word COM smoke script at `scripts/word-com-smoke.ps1`, exposed as `npm run smoke:word`.
|
|
49
|
+
- Verification: `npm test` passed (21/21); `npm run test:isolation` passed.
|
|
50
|
+
|
|
51
|
+
**Why:** The single most valuable check for redline correctness is:
|
|
52
|
+
*accepting all generated revisions must yield the modified text; rejecting all
|
|
53
|
+
generated revisions must yield the original text.* The library already owns both
|
|
54
|
+
halves of this loop (`applyRedlineToOxml` to generate, and
|
|
55
|
+
`acceptTrackedChangesInOoxml` / `rejectTrackedChangesInOoxml` in
|
|
56
|
+
`services/revision-comment-management.js` to resolve). Every later phase is
|
|
57
|
+
verified through this harness, so it lands first.
|
|
58
|
+
|
|
59
|
+
### 1.1 Build the harness helper
|
|
60
|
+
|
|
61
|
+
Create `tests/helpers/roundtrip.mjs` exporting:
|
|
62
|
+
|
|
63
|
+
```js
|
|
64
|
+
/**
|
|
65
|
+
* Applies a redline, then asserts the accept/reject round-trip invariant.
|
|
66
|
+
*
|
|
67
|
+
* @param {string} oxml - input OOXML (fragment, document, or package scope)
|
|
68
|
+
* @param {string} original - original plain text
|
|
69
|
+
* @param {string} modified - modified text (may contain markdown)
|
|
70
|
+
* @param {object} [options] - options forwarded to applyRedlineToOxml
|
|
71
|
+
* @returns {Promise<{ redlined, accepted, rejected }>}
|
|
72
|
+
*/
|
|
73
|
+
export async function assertRoundTrip(oxml, original, modified, options = {})
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Implementation steps inside `assertRoundTrip`:
|
|
77
|
+
|
|
78
|
+
1. Call `applyRedlineToOxml(oxml, original, modified, { generateRedlines: true, author: 'RoundTrip', ...options })`.
|
|
79
|
+
2. Assert the result parses as XML (use `parseXmlFragment` from
|
|
80
|
+
`tests/helpers/ooxml-assertions.mjs`).
|
|
81
|
+
3. Run `acceptTrackedChangesInOoxml(result.oxml, { author: 'RoundTrip' })`, extract
|
|
82
|
+
plain text with `ingestWordOoxmlToPlainText`, and assert it equals the
|
|
83
|
+
*plain-text rendering* of `modified` (strip markdown markers the same way the
|
|
84
|
+
engine does — reuse `preprocessMarkdown` from `pipeline/markdown-processor.js`
|
|
85
|
+
to get `cleanText`). Compare with normalized whitespace
|
|
86
|
+
(`s.replace(/\s+/g, ' ').trim()`).
|
|
87
|
+
4. Run `rejectTrackedChangesInOoxml(result.oxml, { author: 'RoundTrip' })`, extract
|
|
88
|
+
plain text, assert it equals `original` (same whitespace normalization).
|
|
89
|
+
5. Structural assertions on the redlined output (add these as separate exported
|
|
90
|
+
helpers so other tests can reuse them):
|
|
91
|
+
- `assertNoNestedRevisions(xml)` — no `w:ins` inside `w:del` or vice versa.
|
|
92
|
+
- `assertDelUsesDelText(xml)` — every `w:r` inside a `w:del` contains only
|
|
93
|
+
`w:delText` (never `w:t`).
|
|
94
|
+
- `assertRevisionMetadata(xml)` — every `w:ins`/`w:del` has non-empty `w:id`,
|
|
95
|
+
`w:author`, and a `w:date` matching `/^\d{4}-\d{2}-\d{2}T/`.
|
|
96
|
+
- `assertUniqueRevisionIds(xml)` — no duplicate `w:id` among `w:ins`/`w:del`/
|
|
97
|
+
`w:rPrChange`/`w:pPrChange` elements in the output.
|
|
98
|
+
- `assertSpacePreserved(xml)` — every `w:t`/`w:delText` whose text has leading
|
|
99
|
+
or trailing whitespace carries `xml:space="preserve"`.
|
|
100
|
+
|
|
101
|
+
### 1.2 Build the corpus test
|
|
102
|
+
|
|
103
|
+
Create `tests/roundtrip_invariant_tests.mjs` that runs `assertRoundTrip` over a
|
|
104
|
+
corpus of (oxml, original, modified) cases. Reuse fixture inputs already present
|
|
105
|
+
in `tests/fixtures/` and `tests/sample_doc/` where possible. Minimum corpus
|
|
106
|
+
(each is one case; build the OOXML inline as template strings like the existing
|
|
107
|
+
tests do):
|
|
108
|
+
|
|
109
|
+
| # | Case |
|
|
110
|
+
|---|------|
|
|
111
|
+
| 1 | Single-run paragraph, one word replaced mid-sentence |
|
|
112
|
+
| 2 | Multi-run paragraph (3+ runs with different `w:rPr`), edit spanning a run boundary |
|
|
113
|
+
| 3 | Leading/trailing whitespace significant: replace `"foo "` with `"bar baz "` |
|
|
114
|
+
| 4 | Pure insertion at start of paragraph; pure insertion at end |
|
|
115
|
+
| 5 | Pure deletion of an entire sentence |
|
|
116
|
+
| 6 | Edit inside a paragraph that contains a `w:hyperlink` (edit text *outside* the link) |
|
|
117
|
+
| 7 | Edit inside a table cell paragraph |
|
|
118
|
+
| 8 | Markdown formatting added: `**bold**` around an existing word |
|
|
119
|
+
| 9 | Paragraph containing `w:proofErr` markers and a simple field (`w:fldChar`/`w:instrText`) |
|
|
120
|
+
| 10 | Unicode: text with emoji and CJK characters replaced |
|
|
121
|
+
| 11 | Two consecutive edits: feed the redlined output of case 1 back through accept-all, then redline again (exercises re-entry on clean docs) |
|
|
122
|
+
|
|
123
|
+
If a case fails, do NOT weaken the assertion to make it pass — fix the engine or,
|
|
124
|
+
if the fix belongs to a later phase (e.g. hyperlink failures belong to Phase 4),
|
|
125
|
+
mark the case with a `// KNOWN-GAP: Phase N` comment and skip it with a logged
|
|
126
|
+
warning, so later phases un-skip it.
|
|
127
|
+
|
|
128
|
+
### 1.3 Optional Word smoke script (manual, not part of `npm test`)
|
|
129
|
+
|
|
130
|
+
Create `scripts/word-com-smoke.ps1` (Windows-only, requires desktop Word):
|
|
131
|
+
takes a `.docx` path, opens it via COM
|
|
132
|
+
(`New-Object -ComObject Word.Application`, `Documents.Open` with
|
|
133
|
+
`OpenAndRepair:$false`), reports whether Word opened it cleanly, counts
|
|
134
|
+
`document.Revisions.Count`, then closes without saving. Add an npm script
|
|
135
|
+
`"smoke:word": "powershell -File scripts/word-com-smoke.ps1"` and document it in
|
|
136
|
+
README under a new "Validating output" section. Do not wire it into CI.
|
|
137
|
+
|
|
138
|
+
**Acceptance for Phase 1:** new test file passes for all non-skipped cases;
|
|
139
|
+
helpers exported; `npm test` green.
|
|
140
|
+
|
|
141
|
+
---
|
|
142
|
+
|
|
143
|
+
## Phase 2 — Policy for pre-existing tracked changes in the source
|
|
144
|
+
|
|
145
|
+
**Status:** Complete.
|
|
146
|
+
|
|
147
|
+
- Added `containsTrackedChanges(xmlDoc)` in `core/word-xml.js` and exported it from `index.js`.
|
|
148
|
+
- Added the `existingRevisions` policy gate in `engine/oxml-engine.js`.
|
|
149
|
+
- Documented that `pipeline/ingestion-paragraph.js` records `w:delText` as a zero-width deletion model entry but excludes it from accepted text.
|
|
150
|
+
- Added `tests/existing_revisions_policy_tests.mjs`.
|
|
151
|
+
- Added README documentation for `existingRevisions`.
|
|
152
|
+
- Forwarded `existingRevisions` through `services/standalone-operation-runner.js` and updated prior-revision standalone tests to opt into `accept-all-first`.
|
|
153
|
+
- Final verification: `npm test` passed (21/21); `npm run test:isolation` passed.
|
|
154
|
+
|
|
155
|
+
**Why:** Running the engine over a paragraph that already contains `w:ins`/`w:del`
|
|
156
|
+
(from a human reviewer or a prior engine run) is the most common real-world
|
|
157
|
+
corruption source. Diff text extraction must treat `w:delText` as invisible and
|
|
158
|
+
`w:ins` content as visible, and the patcher must never nest revisions.
|
|
159
|
+
|
|
160
|
+
### 2.1 Detection
|
|
161
|
+
|
|
162
|
+
Add to `core/word-xml.js`:
|
|
163
|
+
|
|
164
|
+
```js
|
|
165
|
+
/**
|
|
166
|
+
* Returns true if the document/fragment contains any revision markup:
|
|
167
|
+
* w:ins, w:del, w:moveFrom, w:moveTo, w:rPrChange, w:pPrChange,
|
|
168
|
+
* w:cellIns, w:cellDel, or a w:del/w:ins inside w:pPr/w:rPr (paragraph mark).
|
|
169
|
+
*/
|
|
170
|
+
export function containsTrackedChanges(xmlDoc)
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Use `getElementsByTagNameNS(NS_W, localName)` per element name (see how
|
|
174
|
+
`services/revision-comment-management.js` does namespace-safe lookups with
|
|
175
|
+
`getWordElementsByLocalName`).
|
|
176
|
+
|
|
177
|
+
### 2.2 Engine policy gate
|
|
178
|
+
|
|
179
|
+
In `engine/oxml-engine.js`, immediately after the existing parse-error check in
|
|
180
|
+
`applyRedlineToOxml`, add:
|
|
181
|
+
|
|
182
|
+
1. Call `containsTrackedChanges(xmlDoc)`.
|
|
183
|
+
2. If true, behavior is controlled by a new option
|
|
184
|
+
`options.existingRevisions` with values:
|
|
185
|
+
- `'reject-input'` (default): return
|
|
186
|
+
`{ oxml, hasChanges: false, status: 'error', error: { code: 'EXISTING_REVISIONS', message: ... } }`
|
|
187
|
+
(the `status` field is introduced in Phase 6.1 — if Phase 6.1 is not yet done,
|
|
188
|
+
implement the `status`/`error` fields now as part of this step; Phase 6.1
|
|
189
|
+
then only extends them to other early-return paths).
|
|
190
|
+
- `'accept-all-first'`: run `acceptTrackedChangesInOoxml(oxml, { allAuthors: true })`,
|
|
191
|
+
re-parse, and proceed with the cleaned document. The caller's `original` text
|
|
192
|
+
must then match the post-accept text (the normal targeting logic already
|
|
193
|
+
verifies this and falls back to no-change if it doesn't).
|
|
194
|
+
3. Log which path was taken via the `log` adapter.
|
|
195
|
+
|
|
196
|
+
Do NOT attempt to diff *through* existing revisions in this phase. Normalizing
|
|
197
|
+
first (or refusing clearly) is the reliable behavior; transparent merge of new
|
|
198
|
+
revisions into already-revised text is out of scope.
|
|
199
|
+
|
|
200
|
+
### 2.3 Audit ingestion's treatment of `w:del`
|
|
201
|
+
|
|
202
|
+
`pipeline/ingestion-paragraph.js` (around lines 320–340) collects `w:delText`
|
|
203
|
+
content when flattening runs. Audit every caller of that code path and confirm
|
|
204
|
+
deleted text is **excluded** from the plain text used for diffing and from
|
|
205
|
+
`ingestWordOoxmlToPlainText` output (deleted text is invisible in Word's
|
|
206
|
+
"accepted" view and must not appear in `original` matching). If it is currently
|
|
207
|
+
included anywhere, fix it and add a regression test. If it is intentionally
|
|
208
|
+
included for some revision-management path, add a comment at the collection site
|
|
209
|
+
stating which caller needs it and why.
|
|
210
|
+
|
|
211
|
+
### 2.4 Tests
|
|
212
|
+
|
|
213
|
+
Create `tests/existing_revisions_policy_tests.mjs`:
|
|
214
|
+
|
|
215
|
+
- Paragraph containing a `w:del` + `w:ins` pair → default call returns
|
|
216
|
+
`status: 'error'`, code `EXISTING_REVISIONS`, original oxml unchanged.
|
|
217
|
+
- Same input with `existingRevisions: 'accept-all-first'` and `original` set to
|
|
218
|
+
the post-accept text → succeeds, and the result passes `assertRoundTrip`
|
|
219
|
+
structural checks from Phase 1.
|
|
220
|
+
- `ingestWordOoxmlToPlainText` on a paragraph with `w:del` returns text WITHOUT
|
|
221
|
+
the deleted content, and WITH `w:ins` content.
|
|
222
|
+
- `containsTrackedChanges` unit tests: positive for each marker type listed in
|
|
223
|
+
2.1 (including paragraph-mark `w:del` inside `w:pPr/w:rPr`), negative for a
|
|
224
|
+
clean paragraph and for a paragraph with only comments/bookmarks.
|
|
225
|
+
|
|
226
|
+
**Acceptance:** new tests pass; case 11 from Phase 1 still passes; README API
|
|
227
|
+
table gains a row for the `existingRevisions` option.
|
|
228
|
+
|
|
229
|
+
---
|
|
230
|
+
|
|
231
|
+
## Phase 3 — Paragraph-mark revisions
|
|
232
|
+
|
|
233
|
+
**Status:** Complete.
|
|
234
|
+
|
|
235
|
+
- Added `markParagraphMarkInserted(...)` and `markParagraphMarkDeleted(...)` in `engine/run-builders.js`.
|
|
236
|
+
- Wired reconstruction-mode paragraph-boundary insert/delete output to add `w:pPr/w:rPr/w:ins` and `w:pPr/w:rPr/w:del` paragraph-mark revisions.
|
|
237
|
+
- Fixed reconstruction writer paragraph state propagation across newline boundaries while wiring paragraph marks.
|
|
238
|
+
- Wired text-to-table transformation source paragraphs to mark deleted paragraph marks when redlines are generated.
|
|
239
|
+
- Extended `acceptTrackedChangesInOoxml` / `rejectTrackedChangesInOoxml` to handle paragraph-mark `w:ins`/`w:del` separately from normal run-level revisions.
|
|
240
|
+
- Added `tests/paragraph_mark_revision_tests.mjs` for inserted/deleted paragraph round trips and structural assertions.
|
|
241
|
+
- Final verification: `npm test` passed (21/21); `npm run test:isolation` passed.
|
|
242
|
+
|
|
243
|
+
**Why:** When an edit inserts or deletes whole paragraphs (or splits/merges
|
|
244
|
+
them), the paragraph *mark* itself must be revised. A deleted paragraph's mark
|
|
245
|
+
needs `w:pPr > w:rPr > w:del`; an inserted paragraph's mark needs
|
|
246
|
+
`w:pPr > w:rPr > w:ins`. Without this, accept/reject in Word leaves stray empty
|
|
247
|
+
paragraphs or fails to merge paragraphs — highly visible breakage.
|
|
248
|
+
|
|
249
|
+
### 3.1 Builders
|
|
250
|
+
|
|
251
|
+
Add to `engine/run-builders.js`:
|
|
252
|
+
|
|
253
|
+
```js
|
|
254
|
+
/** Marks a paragraph's mark as inserted: ensures w:pPr exists, ensures w:rPr
|
|
255
|
+
* inside it, appends <w:ins w:id w:author w:date/> (empty element). */
|
|
256
|
+
export function markParagraphMarkInserted(xmlDoc, paragraph, author)
|
|
257
|
+
|
|
258
|
+
/** Marks a paragraph's mark as deleted: same shape with <w:del/>. */
|
|
259
|
+
export function markParagraphMarkDeleted(xmlDoc, paragraph, author)
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
Rules:
|
|
263
|
+
- `w:pPr` must be the FIRST child of `w:p`; `w:rPr` must be the LAST child of
|
|
264
|
+
`w:pPr` per schema order (check existing pPr handling in
|
|
265
|
+
`engine/reconstruction-writer.js` for how the codebase orders pPr children,
|
|
266
|
+
and reuse any existing ordering helper).
|
|
267
|
+
- Use `createRevisionMetadata` for attributes. Remove any pre-existing
|
|
268
|
+
`w:ins`/`w:del` in that `w:rPr` before adding (idempotent).
|
|
269
|
+
|
|
270
|
+
### 3.2 Wire into the engine
|
|
271
|
+
|
|
272
|
+
Find every site that inserts or removes a whole `w:p` while
|
|
273
|
+
`generateRedlines` is true. Search hints:
|
|
274
|
+
`grep -n "createElement.*w:p\b\|appendChild(paragraph\|removeChild(paragraph" engine/ pipeline/`
|
|
275
|
+
plus read `engine/reconstruction-mode.js`, `engine/reconstruction-writer.js`,
|
|
276
|
+
`pipeline/list-generation.js`, and `engine/table-mode.js` (row/cell paragraph
|
|
277
|
+
creation). At each site:
|
|
278
|
+
|
|
279
|
+
- New paragraph created as part of a redline → call `markParagraphMarkInserted`.
|
|
280
|
+
- Paragraph whose entire content is wrapped in `w:del` (paragraph is going away
|
|
281
|
+
on accept) → call `markParagraphMarkDeleted` instead of removing the `w:p` node.
|
|
282
|
+
The paragraph node must REMAIN in the document with its content in `w:del` —
|
|
283
|
+
Word removes it on accept.
|
|
284
|
+
|
|
285
|
+
### 3.3 Accept/reject support
|
|
286
|
+
|
|
287
|
+
In `services/revision-comment-management.js`, verify (and fix if missing) that:
|
|
288
|
+
|
|
289
|
+
- **Accept** of a paragraph-mark `w:del` merges the paragraph with the following
|
|
290
|
+
paragraph (move this paragraph's remaining children, except `w:pPr`, into the
|
|
291
|
+
next `w:p`, then remove this `w:p`; if it is the last paragraph in its parent,
|
|
292
|
+
just remove the mark revision).
|
|
293
|
+
- **Reject** of a paragraph-mark `w:del` simply removes the `w:del` element
|
|
294
|
+
from `w:pPr/w:rPr`.
|
|
295
|
+
- **Accept** of a paragraph-mark `w:ins` removes the `w:ins` element.
|
|
296
|
+
- **Reject** of a paragraph-mark `w:ins` merges the paragraph into the next one
|
|
297
|
+
(inverse of accept-del).
|
|
298
|
+
|
|
299
|
+
### 3.4 Tests
|
|
300
|
+
|
|
301
|
+
Create `tests/paragraph_mark_revision_tests.mjs`:
|
|
302
|
+
|
|
303
|
+
- Modified text adds a new paragraph (`"one"` → `"one\n\ntwo"` or via markdown
|
|
304
|
+
list): output's new `w:p` has `w:pPr/w:rPr/w:ins`; accept-all yields two
|
|
305
|
+
paragraphs; reject-all yields one paragraph with original text.
|
|
306
|
+
- Modified text deletes a paragraph (`"one\n\ntwo"` → `"one"`): the second `w:p`
|
|
307
|
+
still exists in the redlined output, its runs are in `w:del`, its mark has
|
|
308
|
+
`w:pPr/w:rPr/w:del`; accept-all yields one paragraph; reject-all yields two.
|
|
309
|
+
- Round-trip via `assertRoundTrip` for both, with multi-paragraph-aware text
|
|
310
|
+
comparison (join paragraphs with `\n`).
|
|
311
|
+
|
|
312
|
+
**Acceptance:** new tests pass; no existing test regresses (list generation
|
|
313
|
+
tests in `tests/list_tests.mjs` are the most likely to be affected — if they
|
|
314
|
+
assert exact XML, update expectations to include the new mark revisions).
|
|
315
|
+
|
|
316
|
+
---
|
|
317
|
+
|
|
318
|
+
## Phase 4 — Inert and structural markup safety
|
|
319
|
+
|
|
320
|
+
**Status:** Complete.
|
|
321
|
+
|
|
322
|
+
- Added reliability coverage for hyperlink-contained revisions, bookmark/comment marker preservation, `w:tab` survival, and footnote reference preservation.
|
|
323
|
+
- De-duplicated zero-width comment marker replay in reconstruction mode.
|
|
324
|
+
- Updated run builders to synthesize visible `w:tab`, `w:br`, and `w:noBreakHyphen` elements instead of writing those characters into plain `w:t` text.
|
|
325
|
+
- Added reconstruction preservation for missing footnote/endnote placeholder references when modified text edits adjacent content without explicitly including internal tokens.
|
|
326
|
+
- Added README packaging note for hyperlink/bookmark/comment/tab/break/footnote safety.
|
|
327
|
+
- Final verification: `npm test` passed (21/21); `npm run test:isolation` passed.
|
|
328
|
+
|
|
329
|
+
**Why:** README already documents stripping `w:fldChar`/`w:instrText`/`w:proofErr`
|
|
330
|
+
from the matched paragraph before diffing. The same care is needed for other
|
|
331
|
+
non-text and container markup, or redlines split/orphan them and Word repairs
|
|
332
|
+
(or rejects) the file.
|
|
333
|
+
|
|
334
|
+
Work through these sub-items one at a time, each with its own tests appended to
|
|
335
|
+
`tests/engine_reliability_tests.mjs` (this file already exists):
|
|
336
|
+
|
|
337
|
+
### 4.1 Hyperlinks (`w:hyperlink`)
|
|
338
|
+
|
|
339
|
+
- **Invariant:** runs created by splitting a run that lives inside a
|
|
340
|
+
`w:hyperlink` must remain inside that same `w:hyperlink` element. New `w:ins`/
|
|
341
|
+
`w:del` wrappers go INSIDE the hyperlink, wrapping the runs.
|
|
342
|
+
- Audit `engine/surgical-run-splitting.js` and `engine/surgical-diff-application.js`:
|
|
343
|
+
wherever a new node is inserted with `insertBefore(node, run)` or appended to
|
|
344
|
+
`run.parentNode`, the parent may be `w:hyperlink`, not `w:p`. Verify insertion
|
|
345
|
+
uses `run.parentNode` (correct) and never hoists to the paragraph level
|
|
346
|
+
(incorrect). Fix any site that assumes the run's parent is `w:p`.
|
|
347
|
+
- Test: paragraph `before [link text] after`; edit `link` → `hyperlink`. Assert
|
|
348
|
+
the `w:ins`/`w:del` elements are descendants of `w:hyperlink`, `r:id`
|
|
349
|
+
attribute is untouched, and round-trip holds. Also test an edit that spans
|
|
350
|
+
from before the hyperlink into it (this may legitimately fall back to
|
|
351
|
+
reconstruction mode — then assert the hyperlink element survives in output).
|
|
352
|
+
|
|
353
|
+
### 4.2 Bookmarks and comment range markers
|
|
354
|
+
|
|
355
|
+
- `w:bookmarkStart`/`w:bookmarkEnd`/`w:commentRangeStart`/`w:commentRangeEnd`/
|
|
356
|
+
`w:commentReference` must SURVIVE the edit (never deleted, never wrapped in
|
|
357
|
+
`w:del`) and must not contribute characters to diff text.
|
|
358
|
+
- Audit `pipeline/ingestion-paragraph.js` (text extraction) and the surgical
|
|
359
|
+
splitting path. When a run range being replaced contains such markers as
|
|
360
|
+
siblings, the markers must be left in place between the `w:del` and `w:ins`
|
|
361
|
+
output.
|
|
362
|
+
- Test: paragraph with a comment range spanning a word that gets edited; assert
|
|
363
|
+
`commentRangeStart/End/Reference` still present exactly once each, and
|
|
364
|
+
`tests/comment_tests.mjs` still passes.
|
|
365
|
+
|
|
366
|
+
### 4.3 `w:lastRenderedPageBreak`, `w:tab`, `w:br`, `w:noBreakHyphen`
|
|
367
|
+
|
|
368
|
+
- `w:lastRenderedPageBreak` is render cache: safe to strip from the matched
|
|
369
|
+
paragraph before diffing (extend the existing fldChar/proofErr stripping site —
|
|
370
|
+
find it with `grep -rn "proofErr" engine/ pipeline/`).
|
|
371
|
+
- `w:tab` and `w:br` are VISIBLE content. Decide and document one mapping in
|
|
372
|
+
ingestion: `w:tab` → `\t`, `w:br` → `\n` in extracted plain text, and ensure
|
|
373
|
+
the diff/patch path can reproduce them (if the engine cannot synthesize them
|
|
374
|
+
on the insert side, at minimum it must not corrupt runs containing them:
|
|
375
|
+
verify a no-op edit elsewhere in the paragraph leaves them intact).
|
|
376
|
+
- Test: paragraph `A<w:tab/>B`, edit `B`→`C`; assert `w:tab` survives and
|
|
377
|
+
round-trip holds on the textual parts.
|
|
378
|
+
|
|
379
|
+
### 4.4 Footnote/endnote references
|
|
380
|
+
|
|
381
|
+
- A run containing `w:footnoteReference`/`w:endnoteReference` must never be
|
|
382
|
+
split through, deleted, or duplicated by the diff (deleting it would orphan
|
|
383
|
+
the footnote part in a way this package cannot clean up).
|
|
384
|
+
- Implement: treat such runs like field scaffolding — exclude them from the
|
|
385
|
+
editable span ranges in `engine/surgical-spans.js` (anchor text before/after
|
|
386
|
+
them, same approach as existing field handling).
|
|
387
|
+
- Test: edit text after a footnote reference; assert exactly one
|
|
388
|
+
`w:footnoteReference` with unchanged `w:id` in output.
|
|
389
|
+
|
|
390
|
+
**Acceptance:** all Phase 4 tests pass; un-skip Phase 1 corpus cases marked
|
|
391
|
+
`KNOWN-GAP: Phase 4`; add a sentence to README's packaging Do/Don't section
|
|
392
|
+
noting hyperlink/bookmark/footnote safety.
|
|
393
|
+
|
|
394
|
+
---
|
|
395
|
+
|
|
396
|
+
## Phase 5 — Move revision (`w:moveFrom` / `w:moveTo`) consumption
|
|
397
|
+
|
|
398
|
+
**Status:** Complete for move consumption.
|
|
399
|
+
|
|
400
|
+
- Ingestion treats `w:moveFrom` as deleted/invisible text and `w:moveTo` as inserted/visible text.
|
|
401
|
+
- `ingestWordOoxmlToPlainText` excludes moved-from text while retaining moved-to text.
|
|
402
|
+
- `acceptTrackedChangesInOoxml` now removes `w:moveFrom`, unwraps `w:moveTo`, and removes matching move range markers.
|
|
403
|
+
- `rejectTrackedChangesInOoxml` now unwraps `w:moveFrom` after converting `w:delText` back to `w:t`, removes `w:moveTo`, and removes matching move range markers.
|
|
404
|
+
- `containsTrackedChanges` detects move range markers in addition to `w:moveFrom`/`w:moveTo`.
|
|
405
|
+
- Added `tests/move_revision_tests.mjs`.
|
|
406
|
+
- Final verification: `npm test` passed (21/21); `npm run test:isolation` passed.
|
|
407
|
+
|
|
408
|
+
**Why:** Documents from human reviewers contain move revisions. The library
|
|
409
|
+
currently has zero handling, so ingestion, accept/reject, and the Phase 2
|
|
410
|
+
detection gate would mis-handle them. Goal of this phase is to CONSUME moves
|
|
411
|
+
safely. (Emitting moves from the differ is a stretch goal — see 5.4 — do not
|
|
412
|
+
start it unless 5.1–5.3 are done and green.)
|
|
413
|
+
|
|
414
|
+
### 5.1 Ingestion
|
|
415
|
+
|
|
416
|
+
In `pipeline/ingestion-paragraph.js` (and the table equivalent if it reads runs
|
|
417
|
+
independently): treat `w:moveFrom` content as deleted (excluded from plain
|
|
418
|
+
text, consistent with Phase 2.3) and `w:moveTo` content as inserted (included).
|
|
419
|
+
`w:moveFromRangeStart/End`, `w:moveToRangeStart/End` are markers — ignore for
|
|
420
|
+
text, preserve as nodes (Phase 4.2 rules).
|
|
421
|
+
|
|
422
|
+
### 5.2 Accept/reject
|
|
423
|
+
|
|
424
|
+
In `services/revision-comment-management.js` extend both transforms:
|
|
425
|
+
|
|
426
|
+
- **Accept:** `w:moveFrom` → remove element and contents (like `w:del`);
|
|
427
|
+
`w:moveTo` → unwrap (like `w:ins`); remove all four range marker types for the
|
|
428
|
+
matched author.
|
|
429
|
+
- **Reject:** `w:moveFrom` → unwrap, converting any `w:delText` inside back to
|
|
430
|
+
`w:t`; `w:moveTo` → remove element and contents; remove range markers.
|
|
431
|
+
- Reuse the existing `removeNode`/`unwrapNode` helpers and the author-filter
|
|
432
|
+
machinery (`resolveAuthorFilter`/`authorMatchesNode`) already in that file.
|
|
433
|
+
Note: move *range markers* carry their author on the `RangeStart` element;
|
|
434
|
+
the matching `RangeEnd` has only an id — match ends to starts by `w:id`.
|
|
435
|
+
|
|
436
|
+
### 5.3 Tests
|
|
437
|
+
|
|
438
|
+
Create `tests/move_revision_tests.mjs`: a two-paragraph fixture where a sentence
|
|
439
|
+
is wrapped in `w:moveFrom` (+ range markers) in paragraph 1 and `w:moveTo`
|
|
440
|
+
(+ markers) in paragraph 2.
|
|
441
|
+
|
|
442
|
+
- Accept-all → sentence appears only in paragraph 2; no move markup remains.
|
|
443
|
+
- Reject-all → sentence appears only in paragraph 1; no move markup remains.
|
|
444
|
+
- Author-filtered accept with a non-matching author → fixture unchanged.
|
|
445
|
+
- `ingestWordOoxmlToPlainText` shows the moved sentence exactly once (at the
|
|
446
|
+
moveTo location).
|
|
447
|
+
- `containsTrackedChanges` (Phase 2) returns true for this fixture.
|
|
448
|
+
|
|
449
|
+
### 5.4 (Stretch, separate PR) Move emission from the differ
|
|
450
|
+
|
|
451
|
+
Only after 5.1–5.3: in `pipeline/diff-engine.js`, post-process the diff to find
|
|
452
|
+
delete/insert pairs with identical normalized text ≥ 15 characters; emit them as
|
|
453
|
+
`w:moveFrom`/`w:moveTo` pairs sharing a `w:name` attribute and linked range
|
|
454
|
+
markers. Verify via the Phase 1 harness plus manual Word inspection. If this
|
|
455
|
+
proves unstable, ship 5.1–5.3 alone — consumption is the safety-critical half.
|
|
456
|
+
|
|
457
|
+
**Acceptance:** 5.1–5.3 tests pass; README revision-management rows updated to
|
|
458
|
+
mention move support.
|
|
459
|
+
|
|
460
|
+
---
|
|
461
|
+
|
|
462
|
+
## Phase 6 — Hardening details
|
|
463
|
+
|
|
464
|
+
**Status:** Complete.
|
|
465
|
+
|
|
466
|
+
- Added non-breaking `status` / `error` fields to the engine result path, including `PARSE_ERROR`, `TARGET_NOT_FOUND`, and `EXISTING_REVISIONS`.
|
|
467
|
+
- Seeded generated revision IDs from existing document IDs via `seedRevisionIdsFromDocument(xmlDoc)`.
|
|
468
|
+
- Seeded revision IDs in `applyRedlineToOxml` and the standalone redline operation runner.
|
|
469
|
+
- Added `tests/hardening_status_tests.mjs`.
|
|
470
|
+
- Added `index.d.ts`, package `"types"` metadata, and `npm run check:types`.
|
|
471
|
+
- Added `scripts/export-validation-fixtures.mjs` and `docs/VALIDATION.md`, linked from README.
|
|
472
|
+
- README now documents `status`/`error`, move revision consumption, included types, and validation workflow.
|
|
473
|
+
- Final verification: `npm test` passed (21/21); `npm run test:isolation` passed; `npm run check:types` passed; `node scripts/export-validation-fixtures.mjs` passed.
|
|
474
|
+
|
|
475
|
+
### 6.1 Explicit error status instead of silent no-op
|
|
476
|
+
|
|
477
|
+
`applyRedlineToOxml` in `engine/oxml-engine.js` returns `{ oxml, hasChanges: false }`
|
|
478
|
+
on XML parse failure (the `noChanges()` early returns near the top), which is
|
|
479
|
+
indistinguishable from "nothing to change."
|
|
480
|
+
|
|
481
|
+
- Add optional result fields, non-breaking:
|
|
482
|
+
`status: 'ok' | 'no-op' | 'error'` and
|
|
483
|
+
`error?: { code: string, message: string }`.
|
|
484
|
+
- Error codes to introduce: `PARSE_ERROR`, `TARGET_NOT_FOUND` (where targeting
|
|
485
|
+
fails and the engine currently logs + returns unchanged), `EXISTING_REVISIONS`
|
|
486
|
+
(Phase 2). Successful-but-unchanged paths return `status: 'no-op'`.
|
|
487
|
+
- Thread the same fields through `applyRedlineToOxmlWithListFallback`,
|
|
488
|
+
`reconcileMarkdownTableOoxml`, and
|
|
489
|
+
`services/standalone-operation-runner.js` (`applyOperationToDocumentXml`
|
|
490
|
+
result already has its own shape — add `status`/`error` alongside, do not
|
|
491
|
+
rename existing fields).
|
|
492
|
+
- Tests: feed malformed XML → `status: 'error'`, code `PARSE_ERROR`; feed
|
|
493
|
+
`original` text that doesn't exist in the document → `status` is `'error'` or
|
|
494
|
+
`'no-op'` per the chosen semantics (pick one, document it in the JSDoc).
|
|
495
|
+
- Update README API tables to document `status`/`error`.
|
|
496
|
+
|
|
497
|
+
### 6.2 Document-unique revision IDs
|
|
498
|
+
|
|
499
|
+
`core/types.js` uses a module-global counter starting at 1000
|
|
500
|
+
(`revisionIdCounter`). A source document that already contains `w:id` values
|
|
501
|
+
≥ 1000 can collide with newly generated ids.
|
|
502
|
+
|
|
503
|
+
- Add `export function seedRevisionIdsFromDocument(xmlDoc)` in `core/types.js`:
|
|
504
|
+
scan all elements for a `w:id` attribute, parse as integer, and if
|
|
505
|
+
`maxFound >= revisionIdCounter`, set `revisionIdCounter = maxFound + 1`.
|
|
506
|
+
- Call it once in `applyRedlineToOxml` right after successful parse, and at the
|
|
507
|
+
equivalent spot in `services/standalone-operation-runner.js`.
|
|
508
|
+
- Test: input fragment containing `w:ins w:id="5000"`; assert every generated
|
|
509
|
+
revision id in the output is > 5000 and `assertUniqueRevisionIds` (Phase 1)
|
|
510
|
+
passes.
|
|
511
|
+
|
|
512
|
+
### 6.3 TypeScript declarations
|
|
513
|
+
|
|
514
|
+
- Create `index.d.ts` at the repo root typing every export of `index.js`.
|
|
515
|
+
Key shapes: the options bag for `applyRedlineToOxml`
|
|
516
|
+
(`{ generateRedlines?, author?, targetParagraphId?, existingRevisions? }`),
|
|
517
|
+
the result (`{ oxml, hasChanges, sourceType?, status?, error? }`), the
|
|
518
|
+
accept/reject options (`{ author?, allAuthors? }`) and their result shape
|
|
519
|
+
(read the actual return in `services/revision-comment-management.js` —
|
|
520
|
+
it includes warnings), and the config functions.
|
|
521
|
+
- Add `"types": "index.d.ts"` to `package.json` and include it in the published
|
|
522
|
+
`files` list if one exists.
|
|
523
|
+
- Verification: add a `scripts/check-types.mjs` step or simply run
|
|
524
|
+
`npx tsc --noEmit --checkJs false index.d.ts` once locally; at minimum ensure
|
|
525
|
+
the file parses (`npx tsc index.d.ts --noEmit`). Do not convert the codebase
|
|
526
|
+
to TypeScript.
|
|
527
|
+
|
|
528
|
+
### 6.4 Cross-consumer fixture validation (manual, documented)
|
|
529
|
+
|
|
530
|
+
- Add `scripts/export-validation-fixtures.mjs`: runs a handful of Phase 1 corpus
|
|
531
|
+
cases through `applyOperationToDocumentXml`, wraps each result in a minimal
|
|
532
|
+
`.docx` using the existing package-builder/plumbing helpers
|
|
533
|
+
(`services/package-builder.js`, `validateDocxPackage`), and writes them to
|
|
534
|
+
`tmp/validation-docx/`. Use only existing dependencies — if zip writing isn't
|
|
535
|
+
possible with what's in `package.json`, write the `word/document.xml` parts
|
|
536
|
+
plus a README instructing how to assemble, instead of adding a dependency.
|
|
537
|
+
- If LibreOffice is installed locally, `soffice --headless --convert-to pdf`
|
|
538
|
+
over the folder is a cheap "does another consumer parse it" check — document
|
|
539
|
+
this (and the Word COM script from 1.3) in a new `docs/VALIDATION.md`, linked
|
|
540
|
+
from README. This stays a manual/release-time step, not CI.
|
|
541
|
+
|
|
542
|
+
**Acceptance:** 6.1 and 6.2 tests pass; `index.d.ts` exists and parses;
|
|
543
|
+
`docs/VALIDATION.md` exists; README updated.
|
|
544
|
+
|
|
545
|
+
### Final verification
|
|
546
|
+
|
|
547
|
+
- `npm test` passed (21/21).
|
|
548
|
+
- `npm run test:isolation` passed.
|
|
549
|
+
- `npm run check:types` passed.
|
|
550
|
+
- `node scripts/export-validation-fixtures.mjs` passed and wrote fixtures to
|
|
551
|
+
`tmp/validation-docx/`.
|
|
552
|
+
- Convention audit passed for `core/`, `engine/`, `pipeline/`, `services/`, and
|
|
553
|
+
`index.js`: no direct `document.createElement('w:*')` or
|
|
554
|
+
`createElementNS(NS_W, 'w:*')` call sites remain outside `createWordElement`,
|
|
555
|
+
and tracked-change revision metadata is routed through `createRevisionMetadata`.
|
|
556
|
+
- Word COM validation remains a manual/release-time smoke step because it needs
|
|
557
|
+
Microsoft Word and an exported `.docx` path: `npm run smoke:word -- path/to/file.docx`.
|
|
558
|
+
|
|
559
|
+
---
|
|
560
|
+
|
|
561
|
+
## Execution order and dependencies
|
|
562
|
+
|
|
563
|
+
```
|
|
564
|
+
Phase 1 (harness) ← everything depends on this
|
|
565
|
+
Phase 2 (existing revisions) ← needs 1; introduces status/error early if 6.1 not done
|
|
566
|
+
Phase 3 (paragraph marks) ← needs 1
|
|
567
|
+
Phase 4 (inert markup) ← needs 1; un-skips Phase 1 KNOWN-GAP cases
|
|
568
|
+
Phase 5 (moves) ← needs 1, 2
|
|
569
|
+
Phase 6 (hardening) ← 6.1/6.2 anytime after 1; 6.3/6.4 last
|
|
570
|
+
```
|
|
571
|
+
|
|
572
|
+
Each phase should be a separate commit (or PR) with the test suite green.
|
|
573
|
+
Suggested commit messages: `test: add accept/reject round-trip harness`,
|
|
574
|
+
`feat: gate redlining on pre-existing revisions`, `feat: revise paragraph marks
|
|
575
|
+
on paragraph insert/delete`, `fix: preserve hyperlinks, bookmarks, and footnote
|
|
576
|
+
refs across redlines`, `feat: consume w:moveFrom/w:moveTo in ingestion and
|
|
577
|
+
accept/reject`, `feat: explicit status/error result fields and seeded revision ids`.
|
|
578
|
+
|
|
579
|
+
## Global guardrails
|
|
580
|
+
|
|
581
|
+
- Never emit `w:t` inside `w:del` (must be `w:delText`) — covered by
|
|
582
|
+
`assertDelUsesDelText`; run it on every new output-producing test.
|
|
583
|
+
- Never nest `w:ins`/`w:del` inside each other.
|
|
584
|
+
- Never produce an empty `w:t`/`w:delText` element or an empty `w:ins`/`w:del`
|
|
585
|
+
wrapper.
|
|
586
|
+
- Word merges adjacent same-author revisions in its review pane only when the
|
|
587
|
+
author strings are byte-identical — always source the author through
|
|
588
|
+
`createRevisionMetadata`, never trim/transform it at call sites.
|
|
589
|
+
- When in doubt about element ordering inside `w:rPr`, use `RPR_SCHEMA_ORDER`
|
|
590
|
+
from `engine/rpr-helpers.js`; for `w:pPr`, `w:pPr` is first child of `w:p` and
|
|
591
|
+
`w:rPr` is its last child.
|
package/index.d.ts
CHANGED
|
@@ -126,6 +126,30 @@ export function rejectTrackedChangesInOoxml(oxml: string, options?: RevisionFilt
|
|
|
126
126
|
export function deleteCommentsByAuthorInOoxml(oxml: string, options?: RevisionFilterOptions): DeleteCommentsResult;
|
|
127
127
|
export function containsTrackedChanges(xmlDoc: Document | Element): boolean;
|
|
128
128
|
|
|
129
|
+
export type RedlineValidationSeverity = 'error' | 'warning';
|
|
130
|
+
|
|
131
|
+
export interface RedlineValidationIssue {
|
|
132
|
+
code:
|
|
133
|
+
| 'PARSE_ERROR'
|
|
134
|
+
| 'NESTED_REVISION'
|
|
135
|
+
| 'DEL_CONTAINS_T'
|
|
136
|
+
| 'MISSING_REVISION_METADATA'
|
|
137
|
+
| 'DUPLICATE_REVISION_ID'
|
|
138
|
+
| 'MISSING_SPACE_PRESERVE'
|
|
139
|
+
| 'EMPTY_TEXT_ELEMENT'
|
|
140
|
+
| 'EMPTY_REVISION_WRAPPER'
|
|
141
|
+
| string;
|
|
142
|
+
severity: RedlineValidationSeverity;
|
|
143
|
+
message: string;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export interface RedlineValidationResult {
|
|
147
|
+
valid: boolean;
|
|
148
|
+
issues: RedlineValidationIssue[];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function validateRedlineOoxml(oxml: string): RedlineValidationResult;
|
|
152
|
+
|
|
129
153
|
export function applyHighlightToOoxml(oxml: string, targetText: string, color: string, options?: Record<string, unknown>): string;
|
|
130
154
|
export function generateTableOoxml(headersOrData: unknown, rowsOrOptions?: unknown, options?: Record<string, unknown>): string;
|
|
131
155
|
export function extractReplacementNodesFromOoxml(oxml: string): unknown;
|