@ansonlai/docx-redline-js 0.5.1 → 0.5.3

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
@@ -55,12 +55,12 @@ const result = await applyRedlineToOxml(oxml, originalText, modifiedText, {
55
55
  contains tracked changes from the same author, prior revisions by that author are
56
56
  reverted to the pre-revision baseline and re-diffed to the new text, cleanly
57
57
  merging the edits without accumulating intermediate revisions or nesting markup.
58
- If the paragraph contains revisions from a different reviewer, the edit fails
59
- with `EXISTING_REVISIONS` to safeguard third-party marks. Pass
60
- `existingRevisions: 'slice-cross-author'` (or `--existing-revisions slice-cross-author`)
61
- to preserve the other reviewer's attribution while applying Word-native
62
- insertions and deletions inside their pending insertion. Pass
63
- `existingRevisions: 'accept-all-first'` (or `--existing-revisions accept-all-first`
58
+ If the paragraph contains revisions from a different reviewer, the edit fails
59
+ with `EXISTING_REVISIONS` to safeguard third-party marks. Pass
60
+ `existingRevisions: 'slice-cross-author'` (or `--existing-revisions slice-cross-author`)
61
+ to preserve the other reviewer's attribution while applying Word-native
62
+ insertions and deletions inside their pending insertion. Pass
63
+ `existingRevisions: 'accept-all-first'` (or `--existing-revisions accept-all-first`
64
64
  via CLI) to normalize all prior revisions first, or `'reject-input'` to refuse any
65
65
  paragraph with open revisions. Use `'accept-all-first-keep-normalized'` only when
66
66
  accepted revisions should be returned as a real change even on a no-op edit.
@@ -144,12 +144,30 @@ 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' },
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 before
170
+ it with a fresh paragraph ID.
153
171
 
154
172
  `targetRef` is an optional 1-based paragraph reference used to disambiguate
155
173
  duplicate text. An operation-level `author` overrides the batch author; batch
@@ -263,12 +281,16 @@ docx-redline apply contract.docx --target "Original clause" --modified "New clau
263
281
  # 2. Direct edit without tracked changes (clean text, no revision clutter)
264
282
  docx-redline apply contract.docx --target "Typo fix" --modified "Fixed typo" --no-redlines --output clean.docx
265
283
 
266
- # 3. Batch operations with ops.json
284
+ # 3. Cross-author edit inside another reviewer's pending insertion
285
+ docx-redline apply contract.docx --target "Pending clause text" --modified "Updated clause text" --existing-revisions slice-cross-author --output reviewed.docx
286
+
287
+ # 4. Batch operations with ops.json
267
288
  docx-redline apply contract.docx --operations operations.json --output reviewed.docx
268
289
  ```
269
290
 
270
291
  Key CLI defaults and behaviors:
271
292
  - **Author**: Automatically defaults to `'AI Redliner'` (overridable via `--author` or `DOCX_REDLINE_AUTHOR` environment variable).
293
+ - **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.
272
294
  - **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.
273
295
  - **Tracked changes**: Defaults to `generateRedlines: true`. When clean direct text is needed, pass `--no-redlines`.
274
296
  - **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`.
@@ -392,7 +414,11 @@ When the CLI or runner returns an error code, follow these specific recovery act
392
414
  | `AMBIGUOUS_TARGET` | Multiple paragraphs match identical text. | Disambiguate by supplying `paragraphId`, `fingerprint`, `occurrence`, or `index` in the target descriptor. |
393
415
  | `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. |
394
416
  | `OVERLAPPING_TEXT_EDITS` | Multiple operations target the same paragraph concurrently. | Consolidate all changes to the same paragraph into a single `redline` or `replace` operation. |
395
- | `EXISTING_REVISIONS` | Target paragraph contains tracked changes from another author. | Fails closed to protect third-party review marks. Report the other reviewer's name to the user. Do not pass `accept-all-first` without explicit authorization. |
417
+ | `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. |
418
+ | `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. |
419
+ | `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. |
420
+ | `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. |
421
+ | `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. |
396
422
  | `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. |
397
423
  | `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. |
398
424
  | `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. |
@@ -531,7 +557,7 @@ orchestration/
531
557
  | `structuredContent` | `boolean` | `true` | Auto-detects Markdown tables, headings (`#`), and lists in replacement text and renders them as native Word elements (`w:tbl`, `w:pStyle`, `w:numPr`). Pass `false` to treat replacement text strictly as plain text. |
532
558
  | `pairReplacements` | `boolean` | `true` | Links adjacent `<w:del>` and `<w:ins>` revisions with matching timestamps so Word groups them as a single replacement in the Reviewing Pane. |
533
559
  | `strictTargets` | `boolean` | `true` (CLI/facade) | Requires exact target descriptors (`exactText`, `paragraphId`, `index`, `occurrence`, `fingerprint`) and forbids ambiguous matching. Defaults to `false` in low-level runner for backwards compatibility. |
534
- | `existingRevisions` | `string` | `'merge-same-author'` | How to handle paragraphs with existing tracked changes. `'merge-same-author'` merges the same author's work and protects other authors with `EXISTING_REVISIONS`. `'slice-cross-author'` retains same-author merging while allowing Word-native edits inside another author's pending insertion. Pass `'accept-all-first'` to normalize prior revisions or `'reject-input'` to refuse editing revised paragraphs. |
560
+ | `existingRevisions` | `string` | `'merge-same-author'` | How to handle paragraphs with existing tracked changes. `'merge-same-author'` merges the same author's work and protects other authors with `EXISTING_REVISIONS`. `'slice-cross-author'` retains same-author merging while allowing Word-native edits inside another author's pending insertion. Pass `'accept-all-first'` to normalize prior revisions or `'reject-input'` to refuse editing revised paragraphs. |
535
561
  | `removeFormatting` | `boolean` | `false` | When `true` and the text is unchanged with no Markdown hints, strips existing bold/italic/underline/strikethrough formatting. |
536
562
  | `sanitizeInput` | `boolean` | `false` | Opt-in removal of standalone leading assistant-preface lines. Literal dollar signs and `\n` sequences are always preserved. |
537
563
 
@@ -617,7 +643,7 @@ directly into `word/document.xml`.
617
643
  5. `useNativeApi: true` means standalone mode cannot fully handle that operation path.
618
644
  6. `deleteCommentsByAuthorInOoxml` removes definitions and linked anchors only when they are present in the same OOXML payload. In a real `.docx`, `word/comments.xml` and `word/document.xml` are separate parts and must both be updated by the package integration layer.
619
645
  7. If output begins with `<pkg:package`, treat it as package-level OOXML and normalize it before writing anything back to `word/document.xml`.
620
- 8. Existing revisions from the same author are merged by default against the pre-revision baseline (`merge-same-author`), while third-party revisions fail closed with `EXISTING_REVISIONS`. Pass `existingRevisions: 'slice-cross-author'` to preserve third-party attribution while editing inside pending insertions, `'accept-all-first'` to normalize all prior revisions first, or `'reject-input'` to refuse any revised paragraph.
646
+ 8. Existing revisions from the same author are merged by default against the pre-revision baseline (`merge-same-author`), while third-party revisions fail closed with `EXISTING_REVISIONS`. Pass `existingRevisions: 'slice-cross-author'` to preserve third-party attribution while editing inside pending insertions, `'accept-all-first'` to normalize all prior revisions first, or `'reject-input'` to refuse any revised paragraph.
621
647
  9. Caller content is not sanitized by default. Pass `sanitizeInput: true` only for raw assistant output; literal dollar delimiters and `\\n` sequences are never rewritten.
622
648
  10. Hyperlinks, bookmarks, comment markers, tabs/breaks, and footnote/endnote references are structural OOXML and should survive adjacent redline edits.
623
649
  11. Internally, create Word elements through `createWordElement` and tracked-change metadata through `createRevisionMetadata`.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,40 @@
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
+
9
+ ### New Features
10
+
11
+ - **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.
12
+
13
+ ## 0.5.1
14
+
15
+ ### Highlights & New Features
16
+
17
+ - **Cross-Author Revision Slicing (`existingRevisions: 'slice-cross-author'`)**: Adds native support for editing text inside another reviewer's pending tracked insertion without erasing their attribution or requiring prior acceptance.
18
+ - **Word-Native Insertion Slicing**: When inserting text inside another author's pending `<w:ins>`, the engine splits the outer carrier into sibling `<w:ins>` elements at the paragraph level (`[ins(A), ins(B), ins(A)]`), ensuring strict ECMA-376 schema compliance without illegal `ins/ins` nesting.
19
+ - **Word-Native Deletion Slicing**: When deleting text inside another author's pending `<w:ins>`, the engine nests `<w:del>` directly inside `<w:ins>` per ECMA-376 Part 1 `CT_RunTrackChange` Section 17.13.5.21 and Microsoft Word Desktop 365 native behavior.
20
+ - **Boundary & Straddle Deletions**: Deletions straddling baseline text and pending insertions cleanly partition into separate top-level and nested `<w:del>` containers while sharing unified event/author attribution.
21
+ - **Multi-Author Stacked Revisions**: Multiple reviewers can independently delete or insert content within the same carrier insertion without cross-author interference.
22
+ - **Cascading & Coalescing Lifecycles**: Full round-trip lifecycle parity with Microsoft Word Desktop:
23
+ - Rejecting Author A cleanly discards Author A's insertion and any dependent nested deletions by Author B.
24
+ - Accepting Author A unwraps the insertion into baseline text while leaving Author B's deletions pending against the baseline.
25
+ - Rejecting Author B restores Author B's deleted text within Author A's insertion, and coalesces adjacent split `<w:ins>` fragments back into a single continuous carrier.
26
+ - **CLI & Facade Integration**: Fully exposed via the `docx-redline` CLI (`--existing-revisions slice-cross-author`), Node facade (`openDocx`), and batch operation runner.
27
+
28
+ ### Non-breaking Changes & Improvements
29
+
30
+ - **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.
31
+ - **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.
32
+ - **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.
33
+ - **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.
34
+ - **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.
35
+ - **Preflight Inspection**: `preflightOperations` now inspects and validates `slice-cross-author` batches, reporting pending foreign-author carrier targets as `ready` instead of `EXISTING_REVISIONS`.
36
+ - **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).
37
+
3
38
  ## 0.5.0
4
39
 
5
40
  ### ⚠️ Breaking changes
package/README.md CHANGED
@@ -143,6 +143,8 @@ docx-redline extract contract.docx --range 10:30
143
143
  docx-redline preflight contract.docx --operations operations.json --author "Editor"
144
144
  docx-redline apply contract.docx --operations operations.json --author "Editor" --output reviewed.docx
145
145
  docx-redline validate reviewed.docx
146
+ ```
147
+
146
148
  ```bash
147
149
  # Inline one-liner edit (no operations file needed)
148
150
  docx-redline apply contract.docx --target "Original clause text" --modified "New clause text" --output reviewed.docx
@@ -150,6 +152,9 @@ docx-redline apply contract.docx --target "Original clause text" --modified "New
150
152
  # Direct edit without tracked changes
151
153
  docx-redline apply contract.docx --target "Typo fix" --modified "Fixed typo" --no-redlines --output clean.docx
152
154
 
155
+ # Cross-author edit inside another reviewer's pending insertion
156
+ docx-redline apply contract.docx --target "Another author's clause" --modified "Revised clause" --existing-revisions slice-cross-author --output reviewed.docx
157
+
153
158
  # High-assurance atomic batch
154
159
  docx-redline apply contract.docx --operations operations.json --atomic --output reviewed.docx
155
160
  ```
@@ -157,6 +162,7 @@ docx-redline apply contract.docx --operations operations.json --atomic --output
157
162
  All commands emit JSON on stdout. `apply` defaults:
158
163
  - **Author**: Defaults to `'AI Redliner'` (or `DOCX_REDLINE_AUTHOR` environment variable).
159
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
+ - **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.
160
166
  - **Transactionality**: Defaults to `atomic: false` (applies valid operations and reports any failures). Pass `--atomic` for all-or-nothing rollback on any operation error.
161
167
  - **Tracked changes**: Defaults to `generateRedlines: true`. Pass `--no-redlines` when clean direct text edits are desired.
162
168
  - **Inline edits**: Use `--target <text>` with `--modified <text>` or `--comment <text>` for quick one-liners without creating a JSON file.
@@ -183,13 +189,13 @@ See [the agent workflow in AGENTS.md](./AGENTS.md#agent-document-workflow-cli) a
183
189
  | `structuredContent` | `boolean` | `true` | Auto-detects Markdown tables, headings (`#`), and lists in replacement text and renders them as native Word elements (`w:tbl`, `w:pStyle`, `w:numPr`). Pass `false` to treat replacement text strictly as plain text. |
184
190
  | `pairReplacements` | `boolean` | `true` | Links adjacent `<w:del>` and `<w:ins>` revisions with matching timestamps so Word groups them as a single replacement in the Reviewing Pane. |
185
191
  | `strictTargets` | `boolean` | `true` (CLI/facade) | Requires exact target descriptors (`exactText`, `paragraphId`, `index`, `occurrence`, `fingerprint`) and forbids ambiguous matching. Defaults to `false` in low-level runner for backwards compatibility. |
186
- | `existingRevisions` | `string` | `'merge-same-author'` | How to handle paragraphs with existing tracked changes. `'merge-same-author'` merges revisions from the same author and protects other authors with `EXISTING_REVISIONS`. `'slice-cross-author'` keeps that same-author merge behavior while allowing Word-native edits inside another author's pending insertion. Pass `'accept-all-first'` to normalize prior revisions or `'reject-input'` to refuse editing revised paragraphs. |
192
+ | `existingRevisions` | `string` | `'merge-same-author'` | How to handle paragraphs with existing tracked changes. `'merge-same-author'` merges revisions from the same author and protects other authors with `EXISTING_REVISIONS`. `'slice-cross-author'` keeps that same-author merge behavior while allowing Word-native edits inside another author's pending insertion. Pass `'accept-all-first'` to normalize prior revisions or `'reject-input'` to refuse editing revised paragraphs. |
187
193
  | `removeFormatting` | `boolean` | `false` | When `true` and the text is unchanged with no Markdown hints, strips existing bold/italic/underline/strikethrough formatting. |
188
- | `sanitizeInput` | `boolean` | `false` | Opt-in removal of standalone leading assistant-preface lines. Literal dollar signs and `\n` sequences are always preserved. |
189
-
190
- Same-author revision merging refuses paragraphs containing comment anchors with
191
- `COMMENTED_CONTENT_MERGE`; resolve those comments first so the merge cannot
192
- remove or orphan their anchors.
194
+ | `sanitizeInput` | `boolean` | `false` | Opt-in removal of standalone leading assistant-preface lines. Literal dollar signs and `\n` sequences are always preserved. |
195
+
196
+ Same-author revision merging refuses paragraphs containing comment anchors with
197
+ `COMMENTED_CONTENT_MERGE`; resolve those comments first so the merge cannot
198
+ remove or orphan their anchors.
193
199
 
194
200
  Common result fields:
195
201
 
@@ -205,6 +211,24 @@ the safe ceiling of 262,144 unique diff tokens return `DIFF_TOKEN_LIMIT` with
205
211
  the original OOXML unchanged so callers can split the operation without risking
206
212
  silent text loss.
207
213
 
214
+ ### Editing inside existing revisions (cross-author slicing)
215
+
216
+ 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
+
218
+ ```js
219
+ const result = await applyRedlineToOxml(paragraphOoxml, originalText, modifiedText, {
220
+ generateRedlines: true,
221
+ author: 'Anson Lai',
222
+ existingRevisions: 'slice-cross-author'
223
+ });
224
+ ```
225
+
226
+ The engine applies Microsoft Word Desktop-native tracked change structures:
227
+ - **Insertions inside pending insertions**: The carrier `<w:ins>` is split into sibling `<w:ins>` containers at the paragraph level (`[ins(Author A), ins(Author B), ins(Author A)]`), maintaining strict schema compliance without illegal `ins/ins` nesting.
228
+ - **Deletions inside pending insertions**: The new `<w:del>` is nested directly inside the carrier `<w:ins>` (valid under ECMA-376 Part 1 `CT_RunTrackChange`), ensuring that if Author A's insertion is rejected, Author B's dependent deletion is cleanly removed with it.
229
+ - **Straddle deletions**: Deletions spanning between baseline text and pending insertions cleanly partition across their respective container contexts without invalid coalescing.
230
+ - **Lifecycle parity**: Accepting or rejecting either reviewer independently produces identical results to Microsoft Word Desktop's native review pane.
231
+
208
232
  ### Replacing a heading with a tracked list
209
233
 
210
234
  The list route treats a one-paragraph heading expanded into multiple markdown
@@ -309,11 +333,19 @@ an earlier block has already replaced.
309
333
  | `ensureCommentsExtendedArtifactsInZip(zip, commentsExtendedXml)` | Add or replace modern Word comment-thread metadata in a `.docx` package. |
310
334
  | `validateDocxPackage(zip)` | Validate `.docx` structural consistency. |
311
335
 
312
- Malformed OOXML never escapes these public transform APIs as a raw parser
313
- exception. Transforms return `status: 'error'` with `error.code === 'PARSE_ERROR'`;
314
- validators return a `PARSE_ERROR` issue. Recoverable XML parser
315
- diagnostics are forwarded through the configured logger and included in
316
- `warnings` where the result shape supports them.
336
+ Malformed OOXML never escapes these public transform APIs as a raw parser
337
+ exception. Transforms return `status: 'error'` with `error.code === 'PARSE_ERROR'`;
338
+ validators return a `PARSE_ERROR` issue. Recoverable XML parser
339
+ diagnostics are forwarded through the configured logger and included in
340
+ `warnings` where the result shape supports them.
341
+
342
+ Cross-author slicing also verifies its exact accepted-view text before success.
343
+ If a structural boundary prevents exact reconstruction, the transform returns
344
+ `status: 'error'` with `error.code === 'PATCH_ROUNDTRIP_MISMATCH'`,
345
+ `hasChanges: false`, and the original OOXML unchanged.
346
+ Pure insertion-only slicing uses an exact character-local diff so repeated words
347
+ cannot move an insertion to a different occurrence. Leading/trailing spaces,
348
+ tabs, and non-breaking spaces are treated as real changes rather than no-ops.
317
349
 
318
350
  ### Deep Imports
319
351
 
@@ -488,14 +520,27 @@ import { applyOperationToDocumentXml } from '@ansonlai/docx-redline-js/standalon
488
520
  const zip = await JSZip.loadAsync(docxBuffer);
489
521
  const documentXml = await zip.file('word/document.xml').async('string');
490
522
 
491
- const opResult = await applyOperationToDocumentXml(
523
+ const opResult = await applyOperationToDocumentXml(
492
524
  documentXml,
493
525
  { type: 'redline', target: 'old text', modified: 'new text' },
494
526
  'Editor'
495
- );
527
+ );
528
+
529
+ // Restoring another reviewer's pending whole-paragraph deletion requires
530
+ // explicit intent. The restored counterproposal becomes a separately tracked
531
+ // sibling paragraph; a normal redline operation remains fail-closed.
532
+ const restoration = await applyOperationToDocumentXml(
533
+ documentXml,
534
+ {
535
+ type: 'restore',
536
+ target: { paragraphId: '1A2B3C4D' },
537
+ modified: 'Restored or adjusted paragraph text.'
538
+ },
539
+ 'Editor'
540
+ );
496
541
 
497
542
  // applyOperationToDocumentXml(...) returns a full w:document payload.
498
- zip.file('word/document.xml', opResult.documentXml);
543
+ zip.file('word/document.xml', opResult.documentXml);
499
544
 
500
545
  const fragmentResult = await applyRedlineToOxml(
501
546
  paragraphOoxml,
@@ -0,0 +1,213 @@
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) {
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
+ let sibling = paragraph?.nextSibling || null;
173
+ while (sibling && (sibling.nodeType !== 1 || localNameOf(sibling) !== 'p')) sibling = sibling.nextSibling;
174
+ if (!sibling) {
175
+ return {
176
+ code: 'UNSAFE_PARAGRAPH_PLACEMENT',
177
+ message: 'Refusing to restore a deleted paragraph without a following paragraph in the same structural container.'
178
+ };
179
+ }
180
+
181
+ return null;
182
+ }
183
+
184
+ /**
185
+ * Finds already-authored same-paragraph resurrection shapes. This is a
186
+ * warning-only validation predicate because standalone validation has no
187
+ * mutation baseline with which to prove when a foreign insertion was added.
188
+ */
189
+ export function findForeignDeletedParagraphResurrections(root) {
190
+ const paragraphs = localNameOf(root) === 'p'
191
+ ? [root]
192
+ : Array.from(root?.getElementsByTagName?.('*') || []).filter(node => localNameOf(node) === 'p');
193
+ const matches = [];
194
+
195
+ for (const paragraph of paragraphs) {
196
+ const markDeletion = paragraphMarkDeletion(paragraph);
197
+ if (!markDeletion) continue;
198
+ const ownerAuthor = wordAttribute(markDeletion, 'author') || null;
199
+ const contentChildren = directElementChildren(paragraph)
200
+ .filter(child => !isNonContentChild(child));
201
+ const foreignInsertions = contentChildren.filter(child => {
202
+ if (localNameOf(child) !== 'ins' || !hasVisibleInsertionContent(child)) return false;
203
+ return normalizedAuthor(wordAttribute(child, 'author')) !== normalizedAuthor(ownerAuthor);
204
+ });
205
+ const onlyDeletedContentAndForeignInsertions = contentChildren.every(child => {
206
+ return isWhollyDeletedContentNode(child) || foreignInsertions.includes(child);
207
+ });
208
+ if (foreignInsertions.length > 0 && onlyDeletedContentAndForeignInsertions) {
209
+ matches.push({ paragraph, markDeletion, ownerAuthor, foreignInsertions });
210
+ }
211
+ }
212
+ return matches;
213
+ }
@@ -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/;
@@ -180,5 +181,17 @@ export function validateRedlineOoxml(oxml) {
180
181
  }
181
182
  }
182
183
 
184
+ // Structurally valid but lifecycle-unsafe: accepting the paragraph-mark
185
+ // deletion can merge or discard a foreign insertion placed into a
186
+ // paragraph whose pre-existing content is otherwise wholly deleted.
187
+ for (const resurrection of findForeignDeletedParagraphResurrections(doc)) {
188
+ const ownerAuthor = resurrection.ownerAuthor || 'unattributed';
189
+ addIssue(
190
+ 'FOREIGN_PARAGRAPH_MARK_DELETION',
191
+ 'warning',
192
+ `Paragraph deleted by ${ownerAuthor} also contains non-empty insertion content from another author; Accept/Reject lifecycle may not preserve the apparent restoration.`
193
+ );
194
+ }
195
+
183
196
  return { valid: !issues.some(issue => issue.severity === 'error'), issues };
184
197
  }