@ansonlai/docx-redline-js 0.5.2 → 0.5.4

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 CHANGED
@@ -144,12 +144,51 @@ const result = await applyOperationsToDocumentXml(documentXml, operations, 'Agen
144
144
  The operation runner uses these field names:
145
145
 
146
146
  ```js
147
- const operations = [
147
+ const operations = [
148
148
  { type: 'redline', target: 'Old paragraph text', modified: 'New paragraph text', targetRef: 12 },
149
149
  { type: 'comment', target: 'Paragraph text', textToComment: 'anchor text', commentContent: 'Comment body', targetRef: 18 },
150
150
  { type: 'highlight', target: 'Paragraph text', textToHighlight: 'anchor text', color: 'yellow', targetRef: 24 }
151
- ];
152
- ```
151
+ ];
152
+ ```
153
+
154
+ To counterpropose text for a paragraph wholly deleted by another reviewer,
155
+ use explicit restoration intent. A normal `redline` remains fail-closed with
156
+ `FOREIGN_PARAGRAPH_MARK_DELETION`:
157
+
158
+ ```js
159
+ const restoration = {
160
+ type: 'restore',
161
+ target: { paragraphId: '1A2B3C4D', revisionView: 'rejected' },
162
+ modified: 'Restored or adjusted paragraph text.',
163
+ author: 'Editor'
164
+ };
165
+ ```
166
+
167
+ For a contiguous range, provide `targetEnd`/`targetEndRef` and one string per
168
+ source paragraph in `modified`. Restoration always uses tracked changes,
169
+ preserves the deleted source paragraph, and inserts the counterproposal after
170
+ the complete deleted source block with a fresh paragraph ID. Unchanged
171
+ pre-existing validation defects remain baseline diagnostics; a restore fails
172
+ with `GENERATED_OOXML_INVALID` only when it introduces a new validation error.
173
+
174
+ To insert run-level text inside content visible only in the rejected view, use
175
+ an explicit rejected-view `insert` operation:
176
+
177
+ ```js
178
+ const insertion = {
179
+ type: 'insert',
180
+ target: { paragraphId: '1A2B3C4D', revisionView: 'rejected' },
181
+ anchor: { exactText: 'must pay', occurrence: 1, offset: 5 },
182
+ modified: '[clarification] ',
183
+ author: 'Editor',
184
+ existingRevisions: 'slice-cross-author'
185
+ };
186
+ ```
187
+
188
+ The anchor offset is relative to `anchor.exactText`. Repeated anchors require an
189
+ explicit `occurrence`. The engine preserves the foreign deletion as sibling
190
+ `w:del` carriers around a top-level `w:ins`; unsupported comments, bookmarks,
191
+ fields, hyperlinks, moves, or non-text split boundaries fail closed.
153
192
 
154
193
  `targetRef` is an optional 1-based paragraph reference used to disambiguate
155
194
  duplicate text. An operation-level `author` overrides the batch author; batch
@@ -275,8 +314,9 @@ Key CLI defaults and behaviors:
275
314
  - **Existing revisions**: Defaults to `'merge-same-author'`. Pass `--existing-revisions slice-cross-author` to edit inside another reviewer's pending insertions with native carrier slicing.
276
315
  - **Overwrite behavior**: Destination files provided via `--output` overwrite by default. To protect existing destination files, pass `--no-overwrite` or `--no-clobber`. The source document is never overwritten unless `--in-place` is specified.
277
316
  - **Tracked changes**: Defaults to `generateRedlines: true`. When clean direct text is needed, pass `--no-redlines`.
278
- - **Atomic rollback (optional)**: Operations apply progressively by default (`atomic: false`). For all-or-nothing transactional rollback where any error halts and reverts all changes, pass `--atomic`.
279
- - Check `written: true` on stdout. If an error occurs, inspect `error.code` or `results[i].error.code` (e.g. `TARGET_NOT_FOUND`, `ANCHOR_NOT_FOUND`) to correct the target text and re-apply.
317
+ - **Atomic rollback (optional)**: Operations apply progressively by default (`atomic: false`). For all-or-nothing transactional rollback where any error halts and reverts all changes, pass `--atomic`.
318
+ - **Compact mutation JSON**: `apply`, `accept`, `reject`, and `delete-comments` omit document/package XML and full validation arrays. `validation.originalIssues` and `validation.generatedIssues` are code/count summaries; run `validate` for full issue records.
319
+ - Check `completion: true`, `written: true`, and a non-null `outputPath` on stdout. `completion` is derived from the write result, top-level status, and every operation status, so failed, partial, and unwritten work cannot appear complete. If an error occurs, inspect `error.code` or `results[i].error.code` (e.g. `TARGET_NOT_FOUND`, `ANCHOR_NOT_FOUND`) before correcting the cause and re-applying.
280
320
 
281
321
  For multi-clause or multi-page reviews, apply edits **section-by-section** or clause-by-clause (e.g., using `--in-place` on a working copy) rather than bundling dozens of edits into one massive batch. This keeps context compact, simplifies error diagnosis, and prevents cascading anchor drift.
282
322
 
@@ -394,10 +434,15 @@ When the CLI or runner returns an error code, follow these specific recovery act
394
434
  |---|---|---|
395
435
  | `TARGET_NOT_FOUND` | Target text did not match any paragraph. | **Do NOT retry with paraphrased text.** Re-run `extract`/`inspect`, copy `exactText` verbatim (including exact whitespace/punctuation), and add a discriminator (`paragraphId`, `fingerprint`, or `occurrence`). |
396
436
  | `AMBIGUOUS_TARGET` | Multiple paragraphs match identical text. | Disambiguate by supplying `paragraphId`, `fingerprint`, `occurrence`, or `index` in the target descriptor. |
397
- | `ANCHOR_NOT_FOUND` / `AMBIGUOUS_ANCHOR` | Comment anchor text was not uniquely matched in paragraph. | Narrow `textToComment` to a unique exact substring, or omit `textToComment` to anchor the comment to the entire paragraph. |
437
+ | `ANCHOR_NOT_FOUND` / `AMBIGUOUS_ANCHOR` | A comment or rejected-view insertion anchor was not uniquely matched. | For comments, narrow `textToComment` or omit it to anchor the whole paragraph. For rejected-view insertion, copy exact rejected text and provide `anchor.occurrence`. |
398
438
  | `OVERLAPPING_TEXT_EDITS` | Multiple operations target the same paragraph concurrently. | Consolidate all changes to the same paragraph into a single `redline` or `replace` operation. |
399
439
  | `EXISTING_REVISIONS` | Target paragraph contains tracked changes from another author. | Fails closed to protect third-party review marks. If editing inside that reviewer's pending insertion is intended, pass `--existing-revisions slice-cross-author` (or `existingRevisions: 'slice-cross-author'`). Do not pass `accept-all-first` without explicit user authorization. |
400
440
  | `PATCH_ROUNDTRIP_MISMATCH` | A cross-author surgical edit did not reconstruct the requested modified text exactly. | Treat the operation as unapplied. Re-extract the exact paragraph text and split the edit into a narrower operation that does not cross the reported structural boundary. |
441
+ | `FOREIGN_PARAGRAPH_MARK_DELETION` | A normal edit attempted to write into a paragraph wholly deleted by another reviewer. | Use an explicit `restore` operation if the user intends to counterpropose that paragraph; otherwise leave the deletion unresolved. |
442
+ | `RESTORATION_STATE_REQUIRED` / `RESTORATION_COUNT_MISMATCH` | A `restore` target is not a wholly foreign-deleted paragraph, or its replacement count does not match the paragraph range. | Re-inspect the document and target the deleted paragraph by stable descriptor; provide exactly one replacement string per source paragraph. |
443
+ | `REJECTED_INSERTION_STATE_REQUIRED` / `UNSAFE_REVISION_BOUNDARY` | An explicit rejected-view insertion did not resolve to supported plain run text inside a wholly foreign-deleted paragraph. | Do not fall back to a generic edit. Narrow the exact anchor/offset, or handle comments, bookmarks, fields, hyperlinks, moves, or other structural boundaries manually. |
444
+ | `GENERATED_OOXML_INVALID` | The operation introduced a new validation error relative to its baseline. | Treat the operation as unapplied and inspect `generatedIssues`; correct the generating operation or builder rather than repairing or accepting the source document's unrelated baseline defects. |
445
+ | `UNSAFE_DELETED_TABLE_ROW` / `UNSUPPORTED_MOVE_REVISION` / `SECTION_BREAK_PARAGRAPH` / `UNSAFE_PARAGRAPH_PLACEMENT` | Paragraph restoration cannot preserve the source structural boundary safely. | Do not retry as an ordinary redline. Resolve the row/move/section/placement condition manually or narrow the restoration to a safe paragraph. |
401
446
  | `COMMENTED_CONTENT_MERGE` / `COMMENTED_CONTENT_DELETE` | Operation would overwrite, revert, or delete content with comments. | Fails closed to prevent orphaned comment threads. Report the comment author and text to the user; resolve the comment before re-editing. |
402
447
  | `INVALID_OPERATION` | Operation object violates schema or has incompatible fields. | Validate the JSON structure against [`document-operations.schema.json`](file:///c:/Users/Phara/Desktop/Projects/Docx%20Redline%20JS/docs/schemas/document-operations.schema.json) before targeting is attempted. |
403
448
  | `STRUCTURED_CONTENT_INVALID` | Malformed Markdown table or structure in replacement text. | Ensure tables include a separator row (`\| --- \| --- \|`) and consistent column counts; do not downgrade to raw text. |
@@ -580,12 +625,22 @@ retain their string-only return type and return `''` for parse failures.
580
625
 
581
626
  ### Target text versus replacement text
582
627
 
583
- Target resolution may normalize surrounding or repeated whitespace while
584
- matching a paragraph. Replacement text is not normalized: tabs, line breaks,
585
- non-breaking spaces, repeated spaces, and leading/trailing whitespace become
586
- part of the requested edit. When editing extracted document text, copy the
587
- exact paragraph text and modify it in place rather than round-tripping it
588
- through a formatter that may change whitespace.
628
+ Target resolution may normalize surrounding or repeated whitespace while
629
+ matching a paragraph. Replacement text is not normalized: tabs, line breaks,
630
+ non-breaking spaces, repeated spaces, and leading/trailing whitespace become
631
+ part of the requested edit. When editing extracted document text, copy the
632
+ exact paragraph text and modify it in place rather than round-tripping it
633
+ through a formatter that may change whitespace.
634
+
635
+ The normalized caller target is never used for mutation offsets in a text-bearing
636
+ edit. After target selection, the engine uses the resolved paragraph's byte-exact
637
+ JavaScript string as the source coordinate system. The legacy format-only
638
+ fallback for field-code paragraphs with no extractable accepted-view spans is
639
+ not a text-replacement path. If an ASCII-space target selected an NBSP
640
+ source, `resolvedTarget.targetTextMatch` reports `space_equivalent`, escaped
641
+ source/request excerpts, and differing code points. An NBSP-to-space request is
642
+ tracked as a replacement; it must not retain the NBSP and append another space.
643
+ The CLI keeps these bounded diagnostics but removes resolved clause text.
589
644
 
590
645
  For ordinary insertions and deletions, target the visible accepted view:
591
646
  inserted `w:t` text is visible and deleted `w:delText` is not. Move revisions
package/ARCHITECTURE.md CHANGED
@@ -94,6 +94,12 @@ No Word add-in entrypoints or host-specific integration layers are part of this
94
94
  fingerprints, document order, and table context for deterministic reuse.
95
95
  - `core/redline-validation.js`
96
96
  - 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.
97
+ - `core/validation-delta.js`
98
+ - Stable issue signatures and multiset subtraction for classifying baseline
99
+ versus generated validation issues without hiding added duplicate errors.
100
+ - `core/revision-cloning.js`
101
+ - Shared effective-property cloning strips historical revision descendants;
102
+ intentional revision-bearing splits refresh cloned property-change IDs.
97
103
  - `engine/oxml-engine.js`
98
104
  - Main reconciliation router, mode selection, existing-revision policy gate, and status/error result handling.
99
105
  - `engine/route-selection.js`
@@ -102,7 +108,10 @@ No Word add-in entrypoints or host-specific integration layers are part of this
102
108
  - `engine/run-builders.js`
103
109
  - Shared builders for insertion/deletion wrappers, paragraph-mark revisions, visible run content, and run-property changes.
104
110
  - `engine/surgical-*.js`
105
- - Surgical run splitting, diff application, and span helpers for localized edits that preserve surrounding markup.
111
+ - Surgical run splitting, diff application, and span helpers for localized edits that preserve surrounding markup. Plain-text edit groups execute from right to left with a fresh live span index, while space/NBSP-only replacement hunks are refined to character-local changes so unchanged hyperlink containers survive.
112
+ The shared carrier splitter also handles explicit rejected-view deletion
113
+ splits, retaining `w:delText`, tabs, breaks, soft/non-breaking hyphens,
114
+ formatting, foreign metadata, and fresh trailing/property-change IDs.
106
115
  - `engine/formatting-removal.js`
107
116
  - Shared formatting removal and highlight helpers.
108
117
  - `pipeline/list-markers.js`
@@ -130,10 +139,21 @@ No Word add-in entrypoints or host-specific integration layers are part of this
130
139
  preflight, single-operation application, batch application, and scheduling.
131
140
  - `services/document-operation-applier.js`
132
141
  - Canonical single-operation validation, author resolution, dispatch, and
133
- result metadata assembly.
142
+ result metadata assembly. Before commit it validates the entire live
143
+ document against the operation savepoint and refuses newly generated
144
+ structural errors, including duplicate revision IDs.
134
145
  - `services/document-operation-mutations.js`
135
146
  - Coupled OOXML mutation implementations for redline, highlight, and comment
136
- operations. These use leaf-module imports and never import the root entry.
147
+ operations. For text-bearing edits, the resolved paragraph's exact
148
+ accepted-view text is the source coordinate system even when target
149
+ selection used normalized whitespace;
150
+ bounded match-mode/code-point diagnostics are attached to resolved target
151
+ metadata. Explicit rejected-view insertion splits a supported direct
152
+ foreign `w:del` at an anchor-relative offset into sibling deletion,
153
+ insertion, and deletion carriers. Paragraph restoration emits its inserted
154
+ block after the untouched source range and verifies baseline-delta,
155
+ mutation-envelope, and lifecycle postconditions. These use leaf-module
156
+ imports and never import the root entry.
137
157
  - `services/batch-operation-orchestrator.js`
138
158
  - Comment-first stable scheduling, atomic policy, artifact aggregation,
139
159
  per-operation results, one final document serialization, and deferred
@@ -157,11 +177,16 @@ No Word add-in entrypoints or host-specific integration layers are part of this
157
177
  revision authors, table context, and advisory visible numbering.
158
178
  - `node/docx-document.js`
159
179
  - Transactional whole-DOCX editing, artifact wiring, validation, and rollback.
180
+ OOXML and package issues are classified as baseline/generated multisets, so
181
+ unchanged source defects remain diagnostics while new defects block writes.
160
182
  This surface is excluded from the browser/root dependency graph.
161
183
  - `node/cli.js` and `bin/docx-redline.js`
162
184
  - Cross-platform, JSON-only agent command boundary. Read commands never
163
185
  mutate; write commands require attribution, use package transactions, and
164
186
  only overwrite source files under explicit `--in-place` authorization.
187
+ Mutation commands expose a compact contract: package/XML payloads and full
188
+ validation arrays remain internal, while stdout contains durability fields,
189
+ per-operation evidence, validation counts, and a derived completion flag.
165
190
  - `orchestration/*`
166
191
  - Route planning and list fallback orchestration utilities.
167
192
 
@@ -309,6 +334,16 @@ still be re-exported from `index.js`.
309
334
  - Operation-level authors override the batch author. Runtime results expose
310
335
  `authorUsed`, `authorsUsed`, `operationType`, `resolvedBy`, and resolved target
311
336
  metadata so integrations can audit what the engine actually selected.
337
+ - A normalized target match does not become an edit coordinate system for a
338
+ text-bearing mutation. Mutation uses canonical accepted-view source text, and `resolvedTarget.targetTextMatch`
339
+ records `exact`, `space_equivalent`, or `normalized` selection plus bounded
340
+ invisible-character diagnostics.
341
+ - CLI contract version 3 is deliberately narrower than library result objects.
342
+ `apply`, `accept`, `reject`, and `delete-comments` omit `documentXml`, package
343
+ parts, inspection text, and full issue arrays. `completion` is true only when
344
+ `written === true`, top-level status is neither error nor partial, and every
345
+ operation result is non-error. The `validate` command remains the full issue
346
+ reporting surface.
312
347
  - `preflightOperations` is the read-only safety boundary for agent-generated
313
348
  batches. It uses strict targeting by default; mutation APIs retain permissive
314
349
  legacy targeting unless `strictTargets: true` is requested. In v1.0.0,
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ### Safety Fixes
6
+
7
+ - **Foreign deleted-paragraph resurrection guard**: Refuses non-empty same-paragraph edits when another author owns the paragraph-mark deletion and all existing paragraph content is deleted. The operation now returns `FOREIGN_PARAGRAPH_MARK_DELETION` with the owning author instead of emitting lifecycle-unsafe OOXML; atomic document operations roll back byte-for-byte. `validateRedlineOoxml` reports already-authored instances as warnings.
8
+ - **Source-truth whitespace replacement alignment (WP09a)**: Strict target resolution may equate ordinary spaces with NBSPs, but mutation offsets now always come from the resolved paragraph's exact accepted view. Space-equivalent word replacement hunks are refined to character-local edits, and multiple plain-text edits are applied right-to-left against refreshed live spans. This tracks NBSP-to-space substitutions exactly while preserving unchanged hyperlinks and prevents earlier run splits from invalidating later anchors.
9
+ - **Exact mismatch diagnostics**: `PATCH_ROUNDTRIP_MISMATCH` now includes the expected and actual code points at the first mismatch. Document-operation results also report bounded `targetTextMatch` diagnostics when target selection used equivalent whitespace.
10
+ - **Baseline-delta validation (WP09c)**: Restoration, operation, and package validation now compare issue multisets against the source. Unchanged legacy defects remain visible without blocking safe work, while any added occurrence or mutation-envelope error fails closed with `GENERATED_OOXML_INVALID` before an operation is reported as applied.
11
+ - **Revision identity sanitation (WP09d)**: New paragraph/list builders inherit effective `pPr`/`rPr` formatting without cloning historical revision descendants. Operation-level whole-document validation catches duplicate revision IDs and rolls back the operation savepoint before committing its receipt.
12
+
13
+ ### New Features
14
+
15
+ - **Explicit paragraph restoration (`type: 'restore'`)**: Restores or counterproposes another reviewer's pending whole-paragraph deletion as a separately tracked sibling paragraph. The source deletion remains untouched; the restored paragraph receives its own inserted paragraph mark, content insertion, sanitized paragraph properties, and fresh `w14:paraId`. Single paragraphs and contiguous ranges are supported, with full Accept/Reject lifecycle verification and structured refusals at unsafe table-row, move, section-break, and terminal-paragraph boundaries.
16
+ - **Compact mutation CLI contract (WP09b)**: CLI contract version 3 removes `documentXml`, OOXML/package artifacts, full inspection data, and full validation issue arrays from normal `apply`, `accept`, `reject`, and `delete-comments` stdout. Mutation responses retain actionable errors, per-operation receipts, output durability fields, code/count validation summaries, and a derived `completion` flag that cannot report success for failed, partial, or unwritten work.
17
+ - **Word-native deleted-section editing (WP09e)**: An explicit rejected-view `insert` operation can split a foreign deletion at an exact anchor-relative offset into sibling `del(A) / ins(B) / del(A)` carriers. Contiguous paragraph restorations now follow their untouched deleted source block, matching Microsoft Word's ordering. Ambiguous anchors and unsupported structural split boundaries remain fail-closed.
18
+
3
19
  ## 0.5.1
4
20
 
5
21
  ### Highlights & New Features
@@ -19,9 +35,11 @@
19
35
 
20
36
  - **Validation Update (`validateRedlineOoxml`)**: Refined `NESTED_REVISION` checks to permit direct `<w:ins><w:del>...</w:del></w:ins>` nesting (standard ECMA-376 and Word Desktop behavior), while continuing to strictly reject `ins/ins`, `del/del`, and `del/ins` nesting.
21
37
  - **Slicing Round-Trip Guard**: Cross-author surgical edits now preserve whitespace-only insertions (including ordinary-space replacements for NBSP characters beside hyperlinks) and verify the exact accepted-view text before reporting success. A mismatch fails closed with `PATCH_ROUNDTRIP_MISMATCH` and returns the original OOXML unchanged.
38
+ - **Hyperlink-Adjacent Replacement Anchoring**: Paired replacements immediately before or after a hyperlink now retain a stable insertion point after the deletion run is split, preventing qualifiers from being relocated past the hyperlink or following formatted runs.
39
+ - **Multiple Same-Run Insertions**: Insertion-only slicing operations with multiple edit points now apply from right to left against a refreshed live span index, preventing an earlier run split from relocating later insertions.
22
40
  - **Insertion Stress Hardening**: Slicing now detects edge whitespace changes exactly, uses a character-local insertion-only diff when the original text is an exact subsequence of the modified text, and coalesces new text into an existing same-author carrier when foreign revisions are also present. This prevents repeated phrases from relocating insertions and prevents invalid `w:ins/w:ins` nesting in mixed-author paragraphs.
23
41
  - **Preflight Inspection**: `preflightOperations` now inspects and validates `slice-cross-author` batches, reporting pending foreign-author carrier targets as `ready` instead of `EXISTING_REVISIONS`.
24
- - **Test Suite Expansion**: Added 6 new test suites covering 36 Word Desktop COM golden fixtures, carrier splitting invariants, the SYN-01..12d synthetic test matrix, PKG-01..06 strict package differential replay, the repeated-text/hyperlink whitespace regression, and 76 deterministic insertion stress scenarios (expanding the suite from 88 to 94 passing suites).
42
+ - **Test Suite Expansion**: Added 7 new test suites covering 36 Word Desktop COM golden fixtures, carrier splitting invariants, the SYN-01..12d synthetic test matrix, PKG-01..06 strict package differential replay, the repeated-text/hyperlink whitespace regression, 76 deterministic insertion stress scenarios, and 12 replacement-anchor lifecycle scenarios (expanding the suite from 88 to 95 passing suites).
25
43
 
26
44
  ## 0.5.0
27
45
 
package/README.md CHANGED
@@ -159,13 +159,14 @@ docx-redline apply contract.docx --target "Another author's clause" --modified "
159
159
  docx-redline apply contract.docx --operations operations.json --atomic --output reviewed.docx
160
160
  ```
161
161
 
162
- All commands emit JSON on stdout. `apply` defaults:
162
+ All commands emit JSON on stdout. `apply` defaults:
163
163
  - **Author**: Defaults to `'AI Redliner'` (or `DOCX_REDLINE_AUTHOR` environment variable).
164
164
  - **Output overwrite**: Destination files provided via `--output` overwrite by default. Pass `--no-overwrite` or `--no-clobber` to safeguard existing destination files. The source input is never overwritten unless `--in-place` is specified.
165
165
  - **Existing revisions**: Defaults to `'merge-same-author'`. Pass `--existing-revisions slice-cross-author` to edit inside another reviewer's pending insertions with native carrier slicing.
166
166
  - **Transactionality**: Defaults to `atomic: false` (applies valid operations and reports any failures). Pass `--atomic` for all-or-nothing rollback on any operation error.
167
167
  - **Tracked changes**: Defaults to `generateRedlines: true`. Pass `--no-redlines` when clean direct text edits are desired.
168
- - **Inline edits**: Use `--target <text>` with `--modified <text>` or `--comment <text>` for quick one-liners without creating a JSON file.
168
+ - **Inline edits**: Use `--target <text>` with `--modified <text>` or `--comment <text>` for quick one-liners without creating a JSON file.
169
+ - **Compact mutation results**: `apply`, `accept`, `reject`, and `delete-comments` omit full OOXML/package payloads and inspection text from stdout. They report `written`, `outputPath`, per-operation results and receipts, compact validation counts, and a derived `completion` boolean. Use `validate` when full issue arrays are needed.
169
170
 
170
171
  See [the agent workflow in AGENTS.md](./AGENTS.md#agent-document-workflow-cli) and the
171
172
  [operation JSON Schema](docs/schemas/document-operations.schema.json).
@@ -203,8 +204,9 @@ Common result fields:
203
204
  |-------|---------|
204
205
  | `status` | Operation status: `'ok'`, `'partial'`, `'no-op'`, or `'error'`. |
205
206
  | `error` | Present on failure; includes a stable `code` such as `PARSE_ERROR`, `TARGET_NOT_FOUND`, `PARTIAL_TARGET`, `EXISTING_REVISIONS`, `DIFF_TOKEN_LIMIT`, or `BATCH_OPERATION_FAILED`. |
206
- | `written` | CLI/facade boolean indicating whether the output file was successfully written to disk. |
207
- | `rolledBack` | Present and `true` when an atomic batch encountered an error and rolled back all changes. |
207
+ | `written` | CLI/facade boolean indicating whether the output file was successfully written to disk. |
208
+ | `completion` | CLI-only boolean that is `true` only when a destination was written, top-level status is neither error nor partial, and no operation result failed. |
209
+ | `rolledBack` | Present and `true` when an atomic batch encountered an error and rolled back all changes. |
208
210
 
209
211
  Word diffs are deterministic by default (no wall-clock timeout). Inputs above
210
212
  the safe ceiling of 262,144 unique diff tokens return `DIFF_TOKEN_LIMIT` with
@@ -216,11 +218,11 @@ silent text loss.
216
218
  During multi-round legal negotiations, a reviewer often needs to edit text that was previously inserted by another reviewer whose revision is still pending. Pass `existingRevisions: 'slice-cross-author'` (or `--existing-revisions slice-cross-author` via CLI) to edit inside another author's pending insertion without erasing their attribution or requiring prior acceptance:
217
219
 
218
220
  ```js
219
- const result = await applyRedlineToOxml(paragraphOoxml, originalText, modifiedText, {
220
- generateRedlines: true,
221
- author: 'Anson Lai',
222
- existingRevisions: 'slice-cross-author'
223
- });
221
+ const result = await applyRedlineToOxml(paragraphOoxml, originalText, modifiedText, {
222
+ generateRedlines: true,
223
+ author: 'Reviewer B',
224
+ existingRevisions: 'slice-cross-author'
225
+ });
224
226
  ```
225
227
 
226
228
  The engine applies Microsoft Word Desktop-native tracked change structures:
@@ -346,6 +348,12 @@ If a structural boundary prevents exact reconstruction, the transform returns
346
348
  Pure insertion-only slicing uses an exact character-local diff so repeated words
347
349
  cannot move an insertion to a different occurrence. Leading/trailing spaces,
348
350
  tabs, and non-breaking spaces are treated as real changes rather than no-ops.
351
+ For text-bearing replacements, the exact accepted-view text of the resolved
352
+ paragraph—not a space-normalized caller target—defines mutation offsets. Word-level replacement
353
+ hunks that differ only by ordinary spaces and NBSPs are refined to character
354
+ edits so unchanged hyperlinks and their relationship attributes stay in place.
355
+ Runner results expose bounded `resolvedTarget.targetTextMatch` code-point
356
+ diagnostics when equivalent whitespace was used to identify the target.
349
357
 
350
358
  ### Deep Imports
351
359
 
@@ -520,14 +528,46 @@ import { applyOperationToDocumentXml } from '@ansonlai/docx-redline-js/standalon
520
528
  const zip = await JSZip.loadAsync(docxBuffer);
521
529
  const documentXml = await zip.file('word/document.xml').async('string');
522
530
 
523
- const opResult = await applyOperationToDocumentXml(
531
+ const opResult = await applyOperationToDocumentXml(
524
532
  documentXml,
525
533
  { type: 'redline', target: 'old text', modified: 'new text' },
526
534
  'Editor'
527
- );
535
+ );
536
+
537
+ // Restoring another reviewer's pending whole-paragraph deletion requires
538
+ // explicit intent. The restored counterproposal becomes a separately tracked
539
+ // sibling paragraph; a normal redline operation remains fail-closed.
540
+ const restoration = await applyOperationToDocumentXml(
541
+ documentXml,
542
+ {
543
+ type: 'restore',
544
+ target: { paragraphId: '1A2B3C4D', revisionView: 'rejected' },
545
+ modified: 'Restored or adjusted paragraph text.'
546
+ },
547
+ 'Editor'
548
+ );
549
+
550
+ // A single restoration follows its deleted source paragraph. A range
551
+ // restoration follows the complete deleted source block. Unchanged legacy
552
+ // validation defects are retained as baseline issues; newly generated errors
553
+ // fail closed before commit.
554
+
555
+ // To insert run-level text at a location visible only in the rejected view,
556
+ // provide explicit rejected-view intent and an exact anchor-relative offset.
557
+ const deletedTextInsertion = await applyOperationToDocumentXml(
558
+ documentXml,
559
+ {
560
+ type: 'insert',
561
+ target: { paragraphId: '1A2B3C4D', revisionView: 'rejected' },
562
+ anchor: { exactText: 'must pay', occurrence: 1, offset: 5 },
563
+ modified: '[clarification] ',
564
+ existingRevisions: 'slice-cross-author'
565
+ },
566
+ 'Editor'
567
+ );
528
568
 
529
569
  // applyOperationToDocumentXml(...) returns a full w:document payload.
530
- zip.file('word/document.xml', opResult.documentXml);
570
+ zip.file('word/document.xml', opResult.documentXml);
531
571
 
532
572
  const fragmentResult = await applyRedlineToOxml(
533
573
  paragraphOoxml,
@@ -0,0 +1,215 @@
1
+ const NON_CONTENT_CHILDREN = new Set([
2
+ 'pPr',
3
+ 'bookmarkStart', 'bookmarkEnd',
4
+ 'commentRangeStart', 'commentRangeEnd', 'commentReference',
5
+ 'customXmlInsRangeStart', 'customXmlInsRangeEnd',
6
+ 'customXmlDelRangeStart', 'customXmlDelRangeEnd',
7
+ 'moveFromRangeStart', 'moveFromRangeEnd',
8
+ 'moveToRangeStart', 'moveToRangeEnd',
9
+ 'permStart', 'permEnd', 'proofErr'
10
+ ]);
11
+
12
+ function localNameOf(node) {
13
+ return String(node?.localName || node?.nodeName || '').replace(/^.*:/, '');
14
+ }
15
+
16
+ function directElementChildren(node) {
17
+ return Array.from(node?.childNodes || []).filter(child => child.nodeType === 1);
18
+ }
19
+
20
+ function directChild(node, localName) {
21
+ return directElementChildren(node).find(child => localNameOf(child) === localName) || null;
22
+ }
23
+
24
+ function wordAttribute(node, localName) {
25
+ return node?.getAttribute?.(`w:${localName}`)
26
+ || node?.getAttribute?.(localName)
27
+ || '';
28
+ }
29
+
30
+ function normalizedAuthor(author) {
31
+ return String(author || '').trim().toLowerCase();
32
+ }
33
+
34
+ function isAnchorOnlyRun(node) {
35
+ if (localNameOf(node) !== 'r') return false;
36
+ return directElementChildren(node).every(child => [
37
+ 'rPr', 'commentReference',
38
+ 'bookmarkStart', 'bookmarkEnd',
39
+ 'commentRangeStart', 'commentRangeEnd',
40
+ 'proofErr'
41
+ ].includes(localNameOf(child)));
42
+ }
43
+
44
+ function isNonContentChild(node) {
45
+ return NON_CONTENT_CHILDREN.has(localNameOf(node)) || isAnchorOnlyRun(node);
46
+ }
47
+
48
+ function isWhollyDeletedContentNode(node) {
49
+ if (localNameOf(node) === 'del') return true;
50
+ if (!['customXml', 'smartTag', 'sdt', 'sdtContent'].includes(localNameOf(node))) return false;
51
+ const contentChildren = directElementChildren(node).filter(child => (
52
+ !isNonContentChild(child) && localNameOf(child) !== 'sdtPr'
53
+ ));
54
+ return contentChildren.every(isWhollyDeletedContentNode);
55
+ }
56
+
57
+ function paragraphFallsWithinMoveFromRange(paragraph) {
58
+ const root = paragraph?.ownerDocument?.documentElement || null;
59
+ if (!root) return false;
60
+ const openIds = new Set();
61
+ for (const node of [root, ...Array.from(root.getElementsByTagName?.('*') || [])]) {
62
+ if (node === paragraph && openIds.size > 0) return true;
63
+ const name = localNameOf(node);
64
+ const id = wordAttribute(node, 'id');
65
+ if (name === 'moveFromRangeStart' && id !== '') openIds.add(id);
66
+ if (name === 'moveFromRangeEnd' && id !== '') openIds.delete(id);
67
+ }
68
+ return false;
69
+ }
70
+
71
+ function paragraphMarkDeletion(paragraph) {
72
+ const pPr = directChild(paragraph, 'pPr');
73
+ const rPr = directChild(pPr, 'rPr');
74
+ return directChild(rPr, 'del');
75
+ }
76
+
77
+ function hasVisibleInsertionContent(insertion) {
78
+ for (const node of Array.from(insertion?.getElementsByTagName?.('*') || [])) {
79
+ const localName = localNameOf(node);
80
+ if (!['t', 'tab', 'br', 'cr', 'noBreakHyphen', 'softHyphen'].includes(localName)) continue;
81
+ let ancestor = node.parentNode;
82
+ let hidden = false;
83
+ while (ancestor && ancestor !== insertion) {
84
+ const ancestorName = localNameOf(ancestor);
85
+ if (ancestorName === 'del' || ancestorName === 'moveFrom') {
86
+ hidden = true;
87
+ break;
88
+ }
89
+ ancestor = ancestor.parentNode;
90
+ }
91
+ if (hidden) continue;
92
+ if (localName !== 't' || (node.textContent || '').length > 0) return true;
93
+ }
94
+ return false;
95
+ }
96
+
97
+ /**
98
+ * Detects the pre-mutation resurrection target defined by WP08: a paragraph
99
+ * mark deleted by another author with no surviving content in that paragraph.
100
+ */
101
+ export function inspectForeignDeletedParagraphTarget(paragraph, mutationAuthor) {
102
+ const markDeletion = paragraphMarkDeletion(paragraph);
103
+ if (!markDeletion) {
104
+ return {
105
+ matches: false,
106
+ hasParagraphMarkDeletion: false,
107
+ foreignParagraphMarkDeletion: false,
108
+ allContentDeleted: false,
109
+ ownerAuthor: null,
110
+ markDeletion: null
111
+ };
112
+ }
113
+
114
+ const ownerAuthor = wordAttribute(markDeletion, 'author') || null;
115
+ const foreignParagraphMarkDeletion = !ownerAuthor
116
+ || normalizedAuthor(ownerAuthor) !== normalizedAuthor(mutationAuthor);
117
+
118
+ const contentChildren = directElementChildren(paragraph)
119
+ .filter(child => !isNonContentChild(child));
120
+ const allContentDeleted = contentChildren.every(isWhollyDeletedContentNode);
121
+ return {
122
+ matches: foreignParagraphMarkDeletion && allContentDeleted,
123
+ hasParagraphMarkDeletion: true,
124
+ foreignParagraphMarkDeletion,
125
+ allContentDeleted,
126
+ ownerAuthor,
127
+ markDeletion
128
+ };
129
+ }
130
+
131
+ export function getParagraphRestorationRefusal(paragraph, options = {}) {
132
+ const pPr = directChild(paragraph, 'pPr');
133
+ if (directChild(pPr, 'sectPr')) {
134
+ return {
135
+ code: 'SECTION_BREAK_PARAGRAPH',
136
+ message: 'Refusing to restore a deleted paragraph whose paragraph properties contain a section break.'
137
+ };
138
+ }
139
+
140
+ let ancestor = paragraph?.parentNode || null;
141
+ while (ancestor) {
142
+ if (localNameOf(ancestor) === 'moveFrom') {
143
+ return {
144
+ code: 'UNSUPPORTED_MOVE_REVISION',
145
+ message: 'Refusing to restore a paragraph that is part of a pending move-from revision.'
146
+ };
147
+ }
148
+ ancestor = ancestor.parentNode;
149
+ }
150
+ if (
151
+ paragraphFallsWithinMoveFromRange(paragraph)
152
+ ||
153
+ paragraph?.getElementsByTagName?.('*')
154
+ && Array.from(paragraph.getElementsByTagName('*')).some(node => ['moveFrom', 'moveFromRangeStart', 'moveFromRangeEnd'].includes(localNameOf(node)))
155
+ ) {
156
+ return {
157
+ code: 'UNSUPPORTED_MOVE_REVISION',
158
+ message: 'Refusing to restore a paragraph that is part of a pending move-from revision.'
159
+ };
160
+ }
161
+
162
+ let row = paragraph?.parentNode || null;
163
+ while (row && localNameOf(row) !== 'tr') row = row.parentNode;
164
+ const rowProperties = directChild(row, 'trPr');
165
+ if (rowProperties && directChild(rowProperties, 'del')) {
166
+ return {
167
+ code: 'UNSAFE_DELETED_TABLE_ROW',
168
+ message: 'Refusing to restore a paragraph inside a table row with a pending row deletion.'
169
+ };
170
+ }
171
+
172
+ if (options.requireFollowingParagraph !== false) {
173
+ let sibling = paragraph?.nextSibling || null;
174
+ while (sibling && (sibling.nodeType !== 1 || localNameOf(sibling) !== 'p')) sibling = sibling.nextSibling;
175
+ if (!sibling) {
176
+ return {
177
+ code: 'UNSAFE_PARAGRAPH_PLACEMENT',
178
+ message: 'Refusing to restore a deleted paragraph without a following paragraph in the same structural container.'
179
+ };
180
+ }
181
+ }
182
+
183
+ return null;
184
+ }
185
+
186
+ /**
187
+ * Finds already-authored same-paragraph resurrection shapes. This is a
188
+ * warning-only validation predicate because standalone validation has no
189
+ * mutation baseline with which to prove when a foreign insertion was added.
190
+ */
191
+ export function findForeignDeletedParagraphResurrections(root) {
192
+ const paragraphs = localNameOf(root) === 'p'
193
+ ? [root]
194
+ : Array.from(root?.getElementsByTagName?.('*') || []).filter(node => localNameOf(node) === 'p');
195
+ const matches = [];
196
+
197
+ for (const paragraph of paragraphs) {
198
+ const markDeletion = paragraphMarkDeletion(paragraph);
199
+ if (!markDeletion) continue;
200
+ const ownerAuthor = wordAttribute(markDeletion, 'author') || null;
201
+ const contentChildren = directElementChildren(paragraph)
202
+ .filter(child => !isNonContentChild(child));
203
+ const foreignInsertions = contentChildren.filter(child => {
204
+ if (localNameOf(child) !== 'ins' || !hasVisibleInsertionContent(child)) return false;
205
+ return normalizedAuthor(wordAttribute(child, 'author')) !== normalizedAuthor(ownerAuthor);
206
+ });
207
+ const onlyDeletedContentAndForeignInsertions = contentChildren.every(child => {
208
+ return isWhollyDeletedContentNode(child) || foreignInsertions.includes(child);
209
+ });
210
+ if (foreignInsertions.length > 0 && onlyDeletedContentAndForeignInsertions) {
211
+ matches.push({ paragraph, markDeletion, ownerAuthor, foreignInsertions });
212
+ }
213
+ }
214
+ return matches;
215
+ }
@@ -459,6 +459,25 @@ export function resolveTargetParagraph(xmlDoc, options = {}) {
459
459
  return { paragraph: byId, resolvedBy: 'paragraph_id' };
460
460
  }
461
461
 
462
+ if (descriptor?.fingerprint && !cleanTargetText && !parsedRef) {
463
+ let fingerprintCandidates = (paragraphMetadataIndex?.entries || [])
464
+ .filter(candidate => candidate.fingerprint === descriptor.fingerprint);
465
+ if (typeof descriptor.inTable === 'boolean') {
466
+ fingerprintCandidates = fingerprintCandidates.filter(candidate => candidate.inTable === descriptor.inTable);
467
+ }
468
+ if (fingerprintCandidates.length === 1) {
469
+ return { paragraph: fingerprintCandidates[0].paragraph, resolvedBy: 'fingerprint' };
470
+ }
471
+ if (fingerprintCandidates.length > 1) {
472
+ throw createTargetError(
473
+ 'AMBIGUOUS_TARGET',
474
+ 'Target fingerprint matched multiple paragraphs; provide paragraphId or index.',
475
+ fingerprintCandidates.map(serializeTargetCandidate)
476
+ );
477
+ }
478
+ throw createTargetError('TARGET_NOT_FOUND', `Target fingerprint not found: "${descriptor.fingerprint}".`);
479
+ }
480
+
462
481
  let candidates = [];
463
482
  if (cleanTargetText) {
464
483
  const unfilteredCandidates = findStrictTargetCandidates(xmlDoc, cleanTargetText, paragraphMetadataIndex);
@@ -10,6 +10,7 @@
10
10
 
11
11
  import { parseXml } from '../adapters/xml-adapter.js';
12
12
  import { NS_W } from './types.js';
13
+ import { findForeignDeletedParagraphResurrections } from './paragraph-revision-safety.js';
13
14
 
14
15
  const REVISION_ID_ELEMENTS = new Set(['ins', 'del', 'rPrChange', 'pPrChange']);
15
16
  const REVISION_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}T/;
@@ -64,19 +65,22 @@ function parseOoxmlForValidation(oxml) {
64
65
  * Issue severities: 'error' issues indicate output Word may repair or
65
66
  * mis-resolve; 'warning' issues are suspicious but tolerated by Word.
66
67
  *
67
- * @param {string} oxml - OOXML string (fragment, document, or package scope)
68
+ * @param {string|Document|Element} oxml - OOXML string or an already-parsed DOM
68
69
  * @returns {{ valid: boolean, issues: Array<{ code: string, severity: 'error'|'warning', message: string }> }}
69
70
  */
70
71
  export function validateRedlineOoxml(oxml) {
71
72
  const issues = [];
72
73
  const addIssue = (code, severity, message) => issues.push({ code, severity, message });
73
74
 
74
- if (typeof oxml !== 'string' || oxml.trim() === '') {
75
- addIssue('PARSE_ERROR', 'error', 'Input is not a non-empty OOXML string.');
75
+ const isDomNode = oxml && typeof oxml === 'object' && (oxml.nodeType === 9 || oxml.nodeType === 1);
76
+ if (!isDomNode && (typeof oxml !== 'string' || oxml.trim() === '')) {
77
+ addIssue('PARSE_ERROR', 'error', 'Input is not non-empty OOXML or a parsed XML DOM.');
76
78
  return { valid: false, issues };
77
79
  }
78
80
 
79
- const { doc, error } = parseOoxmlForValidation(oxml);
81
+ const { doc, error } = isDomNode
82
+ ? { doc: oxml.nodeType === 9 ? oxml : oxml.ownerDocument }
83
+ : parseOoxmlForValidation(oxml);
80
84
  if (!doc) {
81
85
  addIssue('PARSE_ERROR', 'error', `OOXML does not parse as XML: ${error}`);
82
86
  return { valid: false, issues };
@@ -180,5 +184,17 @@ export function validateRedlineOoxml(oxml) {
180
184
  }
181
185
  }
182
186
 
187
+ // Structurally valid but lifecycle-unsafe: accepting the paragraph-mark
188
+ // deletion can merge or discard a foreign insertion placed into a
189
+ // paragraph whose pre-existing content is otherwise wholly deleted.
190
+ for (const resurrection of findForeignDeletedParagraphResurrections(doc)) {
191
+ const ownerAuthor = resurrection.ownerAuthor || 'unattributed';
192
+ addIssue(
193
+ 'FOREIGN_PARAGRAPH_MARK_DELETION',
194
+ 'warning',
195
+ `Paragraph deleted by ${ownerAuthor} also contains non-empty insertion content from another author; Accept/Reject lifecycle may not preserve the apparent restoration.`
196
+ );
197
+ }
198
+
183
199
  return { valid: !issues.some(issue => issue.severity === 'error'), issues };
184
200
  }