@ansonlai/docx-redline-js 0.5.1 → 0.5.2
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 +15 -10
- package/CHANGELOG.md +23 -0
- package/README.md +43 -11
- package/dist/docx-redline-js.esm.js +59 -8
- package/dist/docx-redline-js.esm.js.map +2 -2
- package/dist/docx-redline-js.esm.min.js +69 -67
- package/dist/docx-redline-js.esm.min.js.map +3 -3
- package/docs/TESTING.md +18 -0
- package/docs/plans/2026-09-08-cross-author-revision-slicing.md +70 -0
- package/engine/oxml-engine.js +12 -6
- package/engine/surgical-diff-application.js +14 -2
- package/engine/surgical-mode.js +42 -5
- package/package.json +1 -1
- package/pipeline/diff-engine.js +22 -0
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.
|
|
@@ -263,12 +263,16 @@ docx-redline apply contract.docx --target "Original clause" --modified "New clau
|
|
|
263
263
|
# 2. Direct edit without tracked changes (clean text, no revision clutter)
|
|
264
264
|
docx-redline apply contract.docx --target "Typo fix" --modified "Fixed typo" --no-redlines --output clean.docx
|
|
265
265
|
|
|
266
|
-
# 3.
|
|
266
|
+
# 3. Cross-author edit inside another reviewer's pending insertion
|
|
267
|
+
docx-redline apply contract.docx --target "Pending clause text" --modified "Updated clause text" --existing-revisions slice-cross-author --output reviewed.docx
|
|
268
|
+
|
|
269
|
+
# 4. Batch operations with ops.json
|
|
267
270
|
docx-redline apply contract.docx --operations operations.json --output reviewed.docx
|
|
268
271
|
```
|
|
269
272
|
|
|
270
273
|
Key CLI defaults and behaviors:
|
|
271
274
|
- **Author**: Automatically defaults to `'AI Redliner'` (overridable via `--author` or `DOCX_REDLINE_AUTHOR` environment variable).
|
|
275
|
+
- **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
276
|
- **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
277
|
- **Tracked changes**: Defaults to `generateRedlines: true`. When clean direct text is needed, pass `--no-redlines`.
|
|
274
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`.
|
|
@@ -392,7 +396,8 @@ When the CLI or runner returns an error code, follow these specific recovery act
|
|
|
392
396
|
| `AMBIGUOUS_TARGET` | Multiple paragraphs match identical text. | Disambiguate by supplying `paragraphId`, `fingerprint`, `occurrence`, or `index` in the target descriptor. |
|
|
393
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. |
|
|
394
398
|
| `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.
|
|
399
|
+
| `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
|
+
| `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. |
|
|
396
401
|
| `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
402
|
| `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
403
|
| `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 +536,7 @@ orchestration/
|
|
|
531
536
|
| `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
537
|
| `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
538
|
| `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. |
|
|
539
|
+
| `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
540
|
| `removeFormatting` | `boolean` | `false` | When `true` and the text is unchanged with no Markdown hints, strips existing bold/italic/underline/strikethrough formatting. |
|
|
536
541
|
| `sanitizeInput` | `boolean` | `false` | Opt-in removal of standalone leading assistant-preface lines. Literal dollar signs and `\n` sequences are always preserved. |
|
|
537
542
|
|
|
@@ -617,7 +622,7 @@ directly into `word/document.xml`.
|
|
|
617
622
|
5. `useNativeApi: true` means standalone mode cannot fully handle that operation path.
|
|
618
623
|
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
624
|
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.
|
|
625
|
+
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
626
|
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
627
|
10. Hyperlinks, bookmarks, comment markers, tabs/breaks, and footnote/endnote references are structural OOXML and should survive adjacent redline edits.
|
|
623
628
|
11. Internally, create Word elements through `createWordElement` and tracked-change metadata through `createRevisionMetadata`.
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.1
|
|
4
|
+
|
|
5
|
+
### Highlights & New Features
|
|
6
|
+
|
|
7
|
+
- **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.
|
|
8
|
+
- **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.
|
|
9
|
+
- **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.
|
|
10
|
+
- **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.
|
|
11
|
+
- **Multi-Author Stacked Revisions**: Multiple reviewers can independently delete or insert content within the same carrier insertion without cross-author interference.
|
|
12
|
+
- **Cascading & Coalescing Lifecycles**: Full round-trip lifecycle parity with Microsoft Word Desktop:
|
|
13
|
+
- Rejecting Author A cleanly discards Author A's insertion and any dependent nested deletions by Author B.
|
|
14
|
+
- Accepting Author A unwraps the insertion into baseline text while leaving Author B's deletions pending against the baseline.
|
|
15
|
+
- 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.
|
|
16
|
+
- **CLI & Facade Integration**: Fully exposed via the `docx-redline` CLI (`--existing-revisions slice-cross-author`), Node facade (`openDocx`), and batch operation runner.
|
|
17
|
+
|
|
18
|
+
### Non-breaking Changes & Improvements
|
|
19
|
+
|
|
20
|
+
- **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
|
+
- **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.
|
|
22
|
+
- **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
|
+
- **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).
|
|
25
|
+
|
|
3
26
|
## 0.5.0
|
|
4
27
|
|
|
5
28
|
### ⚠️ 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
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// @ansonlai/docx-redline-js v0.5.
|
|
1
|
+
// @ansonlai/docx-redline-js v0.5.2 — https://github.com/AnsonLai/docx-redline-js
|
|
2
2
|
var __create = Object.create;
|
|
3
3
|
var __defProp = Object.defineProperty;
|
|
4
4
|
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
@@ -2634,6 +2634,17 @@ function computeWordDiffs(originalText, newText, options = {}) {
|
|
|
2634
2634
|
}
|
|
2635
2635
|
return decodeBmpDiffs(charDiffs, wordArray);
|
|
2636
2636
|
}
|
|
2637
|
+
function computeInsertionOnlyDiffs(originalText, newText) {
|
|
2638
|
+
if (originalText === newText) return [[0, originalText]];
|
|
2639
|
+
if (!originalText) return [[1, newText]];
|
|
2640
|
+
let originalIndex = 0;
|
|
2641
|
+
for (let modifiedIndex = 0; modifiedIndex < newText.length && originalIndex < originalText.length; modifiedIndex++) {
|
|
2642
|
+
if (newText[modifiedIndex] === originalText[originalIndex]) originalIndex++;
|
|
2643
|
+
}
|
|
2644
|
+
if (originalIndex !== originalText.length) return null;
|
|
2645
|
+
const diffs = createDiffEngine().diff_main(originalText, newText);
|
|
2646
|
+
return diffs.some(([op]) => op === -1) ? null : diffs;
|
|
2647
|
+
}
|
|
2637
2648
|
function computeWordLevelDiffOps(originalText, newText, options = {}) {
|
|
2638
2649
|
if (originalText === newText) {
|
|
2639
2650
|
return [{
|
|
@@ -6208,6 +6219,7 @@ function processInsert(xmlDoc, spanIndex, pos, text, author, formatHints = [], i
|
|
|
6208
6219
|
insertTextRuns(xmlDoc, fallbackParagraph, null, text, targetSpan.rPr, author, formatHints, insertOffset, generateRedlines, revisionMetadata);
|
|
6209
6220
|
return true;
|
|
6210
6221
|
}
|
|
6222
|
+
const generateNestedRevision = !(generateRedlines && existingRevisions === "slice-cross-author" && isSameAuthorInsertion(parent2, author));
|
|
6211
6223
|
if (generateRedlines && existingRevisions === "slice-cross-author" && isForeignInsertion(parent2, author)) {
|
|
6212
6224
|
return spliceInsertionAtCarrierOffset(
|
|
6213
6225
|
xmlDoc,
|
|
@@ -6229,13 +6241,13 @@ function processInsert(xmlDoc, spanIndex, pos, text, author, formatHints = [], i
|
|
|
6229
6241
|
const beforePieces = sliceRunPieces(xmlDoc, pieces, 0, localInsertPos, false);
|
|
6230
6242
|
const afterPieces = sliceRunPieces(xmlDoc, pieces, localInsertPos, getRunTextLength(pieces), false);
|
|
6231
6243
|
insertRunPiecesBefore(xmlDoc, parent2, targetSpan.runElement, beforePieces, targetSpan.rPr);
|
|
6232
|
-
insertTextRuns(xmlDoc, parent2, targetSpan.runElement, text, targetSpan.rPr, author, formatHints, insertOffset,
|
|
6244
|
+
insertTextRuns(xmlDoc, parent2, targetSpan.runElement, text, targetSpan.rPr, author, formatHints, insertOffset, generateNestedRevision, revisionMetadata);
|
|
6233
6245
|
insertRunPiecesBefore(xmlDoc, parent2, targetSpan.runElement, afterPieces, targetSpan.rPr);
|
|
6234
6246
|
parent2.removeChild(targetSpan.runElement);
|
|
6235
6247
|
return true;
|
|
6236
6248
|
}
|
|
6237
6249
|
const referenceNode2 = pos <= targetSpan.charStart ? targetSpan.runElement : targetSpan.runElement.nextSibling;
|
|
6238
|
-
insertTextRuns(xmlDoc, parent2, referenceNode2, text, targetSpan.rPr, author, formatHints, insertOffset,
|
|
6250
|
+
insertTextRuns(xmlDoc, parent2, referenceNode2, text, targetSpan.rPr, author, formatHints, insertOffset, generateNestedRevision, revisionMetadata);
|
|
6239
6251
|
return true;
|
|
6240
6252
|
}
|
|
6241
6253
|
const boundary = describeInsertionBoundary(spanIndex, pos, fallbackParagraph);
|
|
@@ -6443,6 +6455,11 @@ function isForeignInsertion(node, author) {
|
|
|
6443
6455
|
const carrierAuthor = node.getAttribute("w:author") || node.getAttributeNS?.(NS_W, "author") || "";
|
|
6444
6456
|
return carrierAuthor.trim().toLowerCase() !== String(author || "").trim().toLowerCase();
|
|
6445
6457
|
}
|
|
6458
|
+
function isSameAuthorInsertion(node, author) {
|
|
6459
|
+
if (!isWordElement(node, "ins")) return false;
|
|
6460
|
+
const carrierAuthor = node.getAttribute("w:author") || node.getAttributeNS?.(NS_W, "author") || "";
|
|
6461
|
+
return carrierAuthor.trim().toLowerCase() === String(author || "").trim().toLowerCase();
|
|
6462
|
+
}
|
|
6446
6463
|
function nextElementSibling(node) {
|
|
6447
6464
|
let sibling = node?.nextSibling || null;
|
|
6448
6465
|
while (sibling && sibling.nodeType !== 1) sibling = sibling.nextSibling;
|
|
@@ -6541,7 +6558,8 @@ function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer, autho
|
|
|
6541
6558
|
void originalText;
|
|
6542
6559
|
const allParagraphs = targetParagraph ? [targetParagraph] : getDocumentParagraphs(xmlDoc);
|
|
6543
6560
|
const { fullText, textSpans } = buildSurgicalTextSpans(allParagraphs);
|
|
6544
|
-
const
|
|
6561
|
+
const insertionOnlyDiffs = options.existingRevisions === "slice-cross-author" ? computeInsertionOnlyDiffs(fullText, modifiedText) : null;
|
|
6562
|
+
const diffs = insertionOnlyDiffs || computeWordDiffs(fullText, modifiedText, diffOptions);
|
|
6545
6563
|
const spanIndex = buildSpanIndex(textSpans);
|
|
6546
6564
|
const pairReplacements = options.pairReplacements === true;
|
|
6547
6565
|
const warnings = [];
|
|
@@ -6576,7 +6594,7 @@ function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer, autho
|
|
|
6576
6594
|
if (pairReplacements && generateRedlines && hasNextInsert) {
|
|
6577
6595
|
const nextText = diffs[i + 1][1];
|
|
6578
6596
|
const textWithoutNewlines = nextText.replace(/\n/g, " ");
|
|
6579
|
-
if (textWithoutNewlines.
|
|
6597
|
+
if (textWithoutNewlines.length > 0) {
|
|
6580
6598
|
const checkResult = checkSafeAdjacencyForPairing(
|
|
6581
6599
|
spanIndex,
|
|
6582
6600
|
originalPos,
|
|
@@ -6601,7 +6619,7 @@ function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer, autho
|
|
|
6601
6619
|
i++;
|
|
6602
6620
|
const [, nextText] = diffs[i];
|
|
6603
6621
|
const textWithoutNewlines = nextText.replace(/\n/g, " ");
|
|
6604
|
-
if (textWithoutNewlines.
|
|
6622
|
+
if (textWithoutNewlines.length > 0) {
|
|
6605
6623
|
const insertResult = processInsert(xmlDoc, spanIndex, originalPos, textWithoutNewlines, author, formatHints, newPos, generateRedlines, allParagraphs[0] || null, insMetadata, options?.insertionAffinity || null, options?.existingRevisions || "merge-same-author");
|
|
6606
6624
|
if (insertResult && typeof insertResult === "object" && insertResult.error) {
|
|
6607
6625
|
return withOoxmlSourceType({
|
|
@@ -6619,7 +6637,7 @@ function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer, autho
|
|
|
6619
6637
|
}
|
|
6620
6638
|
} else if (op === 1) {
|
|
6621
6639
|
const textWithoutNewlines = text.replace(/\n/g, " ");
|
|
6622
|
-
if (textWithoutNewlines.
|
|
6640
|
+
if (textWithoutNewlines.length > 0) {
|
|
6623
6641
|
const insertResult = processInsert(xmlDoc, spanIndex, originalPos, textWithoutNewlines, author, formatHints, newPos, generateRedlines, allParagraphs[0] || null, null, options?.insertionAffinity || null, options?.existingRevisions || "merge-same-author");
|
|
6624
6642
|
if (insertResult && typeof insertResult === "object" && insertResult.error) {
|
|
6625
6643
|
return withOoxmlSourceType({
|
|
@@ -6636,12 +6654,42 @@ function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer, autho
|
|
|
6636
6654
|
newPos += text.length;
|
|
6637
6655
|
}
|
|
6638
6656
|
}
|
|
6657
|
+
const actualText = allParagraphs.map((paragraph) => extractCanonicalParagraphText(paragraph)).join("\n");
|
|
6658
|
+
const expectedText = String(modifiedText).replace(/\r\n/g, "\n");
|
|
6659
|
+
if (options.existingRevisions === "slice-cross-author" && actualText !== expectedText) {
|
|
6660
|
+
const mismatchOffset = firstMismatchOffset(expectedText, actualText);
|
|
6661
|
+
return withOoxmlSourceType({
|
|
6662
|
+
oxml: serializer.serializeToString(xmlDoc),
|
|
6663
|
+
hasChanges: false,
|
|
6664
|
+
status: "error",
|
|
6665
|
+
error: {
|
|
6666
|
+
code: "PATCH_ROUNDTRIP_MISMATCH",
|
|
6667
|
+
message: "Generated OOXML accepted-view text does not match the requested modified text; the mutation was rejected.",
|
|
6668
|
+
mismatchOffset,
|
|
6669
|
+
expectedExcerpt: excerptAt(expectedText, mismatchOffset),
|
|
6670
|
+
actualExcerpt: excerptAt(actualText, mismatchOffset)
|
|
6671
|
+
},
|
|
6672
|
+
...warnings.length > 0 ? { warnings: [...new Set(warnings)] } : {}
|
|
6673
|
+
});
|
|
6674
|
+
}
|
|
6639
6675
|
return withOoxmlSourceType({
|
|
6640
6676
|
oxml: serializer.serializeToString(xmlDoc),
|
|
6641
6677
|
hasChanges,
|
|
6642
6678
|
...warnings.length > 0 ? { warnings: [...new Set(warnings)] } : {}
|
|
6643
6679
|
});
|
|
6644
6680
|
}
|
|
6681
|
+
function firstMismatchOffset(expected, actual) {
|
|
6682
|
+
const limit = Math.min(expected.length, actual.length);
|
|
6683
|
+
for (let index = 0; index < limit; index++) {
|
|
6684
|
+
if (expected[index] !== actual[index]) return index;
|
|
6685
|
+
}
|
|
6686
|
+
return limit;
|
|
6687
|
+
}
|
|
6688
|
+
function excerptAt(text, offset, radius = 40) {
|
|
6689
|
+
const start = Math.max(0, offset - radius);
|
|
6690
|
+
const end = Math.min(text.length, offset + radius);
|
|
6691
|
+
return text.slice(start, end);
|
|
6692
|
+
}
|
|
6645
6693
|
|
|
6646
6694
|
// engine/reconstruction-mapper.js
|
|
6647
6695
|
var import_diff_match_patch2 = __toESM(require_diff_match_patch(), 1);
|
|
@@ -8243,7 +8291,7 @@ async function applyRedlineToOxml(oxml, originalText, modifiedText, options = {}
|
|
|
8243
8291
|
}
|
|
8244
8292
|
}
|
|
8245
8293
|
const { cleanText: cleanModifiedText, formatHints } = preprocessMarkdown(sanitizedText);
|
|
8246
|
-
const hasTextChanges = cleanModifiedText.trim() !== originalText.trim();
|
|
8294
|
+
const hasTextChanges = existingRevisionsPolicy === "slice-cross-author" ? cleanModifiedText !== originalText : cleanModifiedText.trim() !== originalText.trim();
|
|
8247
8295
|
const hasFormatHints = formatHints.length > 0;
|
|
8248
8296
|
const { existingFormatHints, textSpans, paragraphs } = extractFormattingFromOoxml(xmlDoc);
|
|
8249
8297
|
const hasExistingFormatting = existingFormatHints.length > 0;
|
|
@@ -8396,6 +8444,9 @@ async function applyRedlineToOxml(oxml, originalText, modifiedText, options = {}
|
|
|
8396
8444
|
{},
|
|
8397
8445
|
options
|
|
8398
8446
|
);
|
|
8447
|
+
if (result.status === "error" && result.error?.code === "PATCH_ROUNDTRIP_MISMATCH") {
|
|
8448
|
+
return finalize({ ...result, oxml: inputOoxml, hasChanges: false });
|
|
8449
|
+
}
|
|
8399
8450
|
if (tableCellContext.hasTableWrapper && result.hasChanges && tableCellContext.targetParagraph) {
|
|
8400
8451
|
log("[OxmlEngine] Stripping table wrapper for table cell paragraph (surgical mode)");
|
|
8401
8452
|
return finalize({ oxml: serializeParagraphOnly(xmlDoc, tableCellContext.targetParagraph, serializer), hasChanges: true });
|