@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/docs/TESTING.md
CHANGED
|
@@ -29,6 +29,7 @@ fixtures rarely contain.
|
|
|
29
29
|
| Target hot-path parity | `node tests/performance_phase3_target_hot_paths_tests.mjs` | Cached and uncached resolution metadata parity, duplicate safety, snapshots, session invalidation, and pointer-based revision-ID seeding | Machine-independent latency thresholds |
|
|
30
30
|
| List and text-walker parity | `node tests/performance_phase4_list_and_text_parity_tests.mjs` | One shared marker vocabulary and canonical/specialized visible-text agreement across structural characters and revision views | That all specialized mappings are interchangeable |
|
|
31
31
|
| Route compatibility | `node tests/performance_phase5_route_consolidation_tests.mjs` | Direct/legacy list accepted-rejected parity, numbering artifacts, route selection, capability records, and stable public exports | Permission to migrate the retained multi-paragraph compatibility route |
|
|
32
|
+
| Cross-author revision slicing | `node tests/cross_author_slicing_fixtures_tests.mjs`, `node tests/cross_author_carrier_splitting_tests.mjs`, `node tests/cross_author_slicing_synthetic_tests.mjs`, `node tests/cross_author_slicing_real_tests.mjs`, `node tests/cross_author_slicing_hyperlink_roundtrip_tests.mjs`, `node tests/cross_author_slicing_insertion_stress_tests.mjs` | Word-native `<w:ins>` splitting, `<w:del>` nesting, multi-author stacking, straddle boundaries, synthetic matrix (SYN-01..12d), strict package differential replay (PKG-01..06), exact hyperlink/NBSP round trips, and 76 insertion stress scenarios | Non-Word consumers beyond OpenXML XSD validation |
|
|
32
33
|
|
|
33
34
|
The package-facade regression opens a real ZIP buffer, adds a comment beside an
|
|
34
35
|
existing high ID, validates OPC wiring, and checks an unrelated binary part is
|
|
@@ -78,6 +79,23 @@ not correctness gates. Per-operation DOM savepoints are retained because
|
|
|
78
79
|
redline accuracy, no-op isolation, and rollback fidelity take precedence over
|
|
79
80
|
the aspirational speed target.
|
|
80
81
|
|
|
82
|
+
## Cross-author revision slicing test suite
|
|
83
|
+
|
|
84
|
+
The cross-author revision slicing subsystem introduces six complementary test lanes:
|
|
85
|
+
|
|
86
|
+
1. **Native Word Desktop Fixtures (`tests/cross_author_slicing_fixtures_tests.mjs`)**:
|
|
87
|
+
Verifies 36 reference `.docx` and `.xml` files generated via Microsoft Word Desktop 365 COM automation across 6 golden scenarios (`insert-interior`, `delete-interior`, `delete-boundary-start`, `delete-boundary-end`, `delete-straddle-baseline-insertion`, and `multi-author-stacked`) in pending, accepted, and rejected states. Proves Word-native OOXML patterns (carrier `<w:ins>` splitting for insertions, direct `<w:del>` nesting inside `<w:ins>` for deletions) and lifecycle parity under `acceptTrackedChangesInOoxml` and `rejectTrackedChangesInOoxml`.
|
|
88
|
+
2. **Carrier Splitting Unit Suite (`tests/cross_author_carrier_splitting_tests.mjs`)**:
|
|
89
|
+
Tests `splitTrackChangeCarrier` across interior and boundary cuts, multi-run formatting preservation, stable metadata cloning, independent revision ID allocation via `RevisionIdAllocator`, and immutable input handling.
|
|
90
|
+
3. **Synthetic Boundary Matrix (`tests/cross_author_slicing_synthetic_tests.mjs`)**:
|
|
91
|
+
Executes the full SYN-01 through SYN-12d test matrix covering pure interior inserts, pure interior deletes, boundary start/end cuts, full-content annihilation, baseline/insertion straddles, multi-carrier straddles, formatting preservation, paired replacements, 3-author stacked revisions, and selective author accept/reject lifecycle oracles.
|
|
92
|
+
4. **Checked-In Word Package Differential Suite (`tests/cross_author_slicing_real_tests.mjs`)**:
|
|
93
|
+
Reconstructs the pre-mutation document packages, applies `slice-cross-author` via the strict `openDocx` package facade, enforces atomic package validation, and compares engine `AcceptAll` and `RejectAll` output against Word Desktop-generated golden packages (PKG-01 through PKG-06).
|
|
94
|
+
5. **Hyperlink and Exact-Text Regression (`tests/cross_author_slicing_hyperlink_roundtrip_tests.mjs`)**:
|
|
95
|
+
Reproduces the repeated-phrase, hyperlink-boundary, and NBSP-to-space failure through both the low-level engine and document operation runner. It requires exact accepted-view equality, preserves both hyperlink relationship containers, and proves that an unreconstructable slicing mutation returns `PATCH_ROUNDTRIP_MISMATCH` with the original OOXML unchanged.
|
|
96
|
+
6. **Insertion Stress Matrix (`tests/cross_author_slicing_insertion_stress_tests.mjs`)**:
|
|
97
|
+
Runs 76 deterministic scenarios across carrier start/end/interior positions, repeated phrases, multi-run and formatted carriers, spaces/tabs/NBSP, XML-sensitive characters, emoji/combining Unicode, hyperlink interiors and boundaries, bookmarks, comments, nested deletions, adjacent authors, mixed same/foreign-author carriers, multiple structural containers, consecutive reviewer rounds, and the atomic document runner. Every supported case requires exact current-view, Accept-All, Reject-Current, schema, metadata, and non-empty-wrapper invariants; unsupported nested structures must fail without throwing or returning changed output.
|
|
98
|
+
|
|
81
99
|
## Coverage matrix and test selection
|
|
82
100
|
|
|
83
101
|
Run the deterministic task-by-structure report before choosing the next Word
|
|
@@ -439,6 +439,76 @@ type ExistingRevisionsPolicy =
|
|
|
439
439
|
- The repository does not contain the private `agreement.docx` referenced by REAL-01/REAL-02 or the exact `c5bb43ede5...` corpus package referenced by REAL-03. Those named cases remain external acceptance scenarios rather than silently skipped automated tests.
|
|
440
440
|
- REAL-04/REAL-05 require Microsoft Word Desktop COM and visual review. The checked-in fixtures were produced by Word COM, while the normal automated suite deliberately remains deterministic and non-interactive.
|
|
441
441
|
|
|
442
|
+
### Bug Follow-Up: Hyperlink Boundary Round-Trip Mismatch [FIXED 2026-09-08]
|
|
443
|
+
* **Report Reproduced**: A slicing edit with repeated text, hyperlink runs, and NBSP-to-space substitutions could return `status: "ok"` even though its accepted-view text differed from `modified`.
|
|
444
|
+
* **Root Cause**: `applySurgicalMode` discarded whitespace-only insertion diff segments by checking `textWithoutNewlines.trim().length`. The corresponding NBSP deletion still committed, changing `located at\u00a0example.com` to `located atexample.com`. Structural replacement paths could also proceed after `PAIRING_SKIPPED_STRUCTURAL_BOUNDARY` without a final exact-text oracle.
|
|
445
|
+
* **Fix**:
|
|
446
|
+
- Whitespace-only insertions are now applied rather than silently skipped.
|
|
447
|
+
- Every `slice-cross-author` surgical result reconstructs canonical accepted-view text and compares it exactly with the requested clean modified text.
|
|
448
|
+
- A mismatch returns `PATCH_ROUNDTRIP_MISMATCH`, `hasChanges: false`, diagnostic excerpts and offset, and the exact original OOXML. The document runner/facade therefore treats the operation as unapplied and preserves transactional rollback.
|
|
449
|
+
* **Files Touched**:
|
|
450
|
+
- `engine/surgical-mode.js`
|
|
451
|
+
- `engine/oxml-engine.js`
|
|
452
|
+
- `tests/cross_author_slicing_hyperlink_roundtrip_tests.mjs` (NEW)
|
|
453
|
+
- `CHANGELOG.md`
|
|
454
|
+
- `README.md`
|
|
455
|
+
- `AGENTS.md`
|
|
456
|
+
- `docs/TESTING.md`
|
|
457
|
+
- `docs/plans/2026-09-08-cross-author-revision-slicing.md`
|
|
458
|
+
* **Functions Touched / Created**:
|
|
459
|
+
- `applySurgicalMode` (MODIFIED): retains whitespace-only insertions and enforces the slicing accepted-view postcondition.
|
|
460
|
+
- `firstMismatchOffset` (NEW): locates the first exact-text divergence.
|
|
461
|
+
- `excerptAt` (NEW): provides bounded expected/actual diagnostics without returning entire contract paragraphs.
|
|
462
|
+
- `applyRedlineToOxml` surgical result handling (MODIFIED): restores the exact input OOXML on `PATCH_ROUNDTRIP_MISMATCH`.
|
|
463
|
+
- Test helpers `run`, `insertion`, `hyperlink`, and `acceptedParagraphText` (NEW).
|
|
464
|
+
* **Regression Coverage**:
|
|
465
|
+
- Low-level reproduction with two hyperlink relationship containers, repeated `Widget Policy`, and three NBSP-to-space edits.
|
|
466
|
+
- Full `applyOperationsToDocumentXml` atomic runner reproduction matching the CLI execution path.
|
|
467
|
+
- Exact Accept-All equality with the submitted modified string.
|
|
468
|
+
- Explicit fail-closed test proving mismatch status, error code, mismatch offset, and byte-exact original OOXML rollback.
|
|
469
|
+
* **Verification**:
|
|
470
|
+
- `node tests/cross_author_slicing_hyperlink_roundtrip_tests.mjs` — PASS.
|
|
471
|
+
- `npm test` — PASS, 93 test files passed and 0 failed.
|
|
472
|
+
- `npm run lint` — PASS.
|
|
473
|
+
- `npm run check:types` — PASS; all 123 runtime exports have declarations.
|
|
474
|
+
- `git diff --check` — PASS (line-ending conversion notices only; no whitespace errors).
|
|
475
|
+
|
|
476
|
+
### Insertion Stress Follow-Up [COMPLETED 2026-09-08]
|
|
477
|
+
* **Motivation**: Real usage reported failures across a wider variety of insertions after the first hyperlink/NBSP bug. A generated matrix was added to exercise location, payload, structure, lifecycle, and repeated-review dimensions rather than relying on a few fixed examples.
|
|
478
|
+
* **Defects Exposed and Fixed**:
|
|
479
|
+
1. Leading/trailing spaces, tabs, and NBSP-only additions were classified as no-ops because slicing inherited trim-based text-change detection. `applyRedlineToOxml` now uses exact comparison for `slice-cross-author`.
|
|
480
|
+
2. Word-token semantic diff cleanup could relocate a pure insertion between repeated phrases, especially inside a hyperlink. `computeInsertionOnlyDiffs` now selects a character-local, no-deletion diff whenever the original is an exact subsequence of the modified text; replacements retain the established word diff and exact round-trip guard.
|
|
481
|
+
3. In a paragraph containing both current-author and foreign insertion carriers, inserting into the current-author carrier produced illegal nested `w:ins`. `processInsert` now adds a normal run to that existing carrier while continuing to split foreign carriers into siblings.
|
|
482
|
+
* **Coverage Added**:
|
|
483
|
+
- 76 deterministic scenarios spanning carrier start/end/interior positions; single-, multi-, and formatted runs; repeated tokens; double spaces, tabs, NBSP, XML-sensitive characters, emoji, ZWJ emoji, combining characters, citations, and punctuation.
|
|
484
|
+
- Hyperlink interiors and both hyperlink boundaries; bookmarks; comment anchors; nested prior deletions; adjacent foreign authors; mixed current/foreign authors; three-container edits; and consecutive second-/third-reviewer rounds.
|
|
485
|
+
- Exact accepted-view, Accept-All, Reject-Current, validation, unique metadata, hyperlink preservation, and zero-empty-insertion assertions.
|
|
486
|
+
- Twenty scenarios also execute through `applyOperationsToDocumentXml` with atomic and strict-target settings, matching the CLI runner path.
|
|
487
|
+
- Nested hyperlink/field structures are required either to produce exact valid output or fail closed without throwing.
|
|
488
|
+
* **Files Touched**:
|
|
489
|
+
- `pipeline/diff-engine.js`
|
|
490
|
+
- `engine/oxml-engine.js`
|
|
491
|
+
- `engine/surgical-mode.js`
|
|
492
|
+
- `engine/surgical-diff-application.js`
|
|
493
|
+
- `tests/cross_author_slicing_insertion_stress_tests.mjs` (NEW)
|
|
494
|
+
- `CHANGELOG.md`
|
|
495
|
+
- `README.md`
|
|
496
|
+
- `docs/TESTING.md`
|
|
497
|
+
- `docs/plans/2026-09-08-cross-author-revision-slicing.md`
|
|
498
|
+
* **Functions Touched / Created**:
|
|
499
|
+
- `computeInsertionOnlyDiffs` (NEW): detects insertion-only transforms and returns a character-local diff only when it contains no deletion.
|
|
500
|
+
- `applyRedlineToOxml` (MODIFIED): uses exact slicing change detection, including boundary whitespace.
|
|
501
|
+
- `applySurgicalMode` (MODIFIED): selects insertion-only versus word diff without changing replacement semantics.
|
|
502
|
+
- `processInsert` (MODIFIED): inserts directly into an existing same-author carrier in mixed-author paragraphs.
|
|
503
|
+
- `isSameAuthorInsertion` (NEW): namespace-safe author comparison for carrier coalescing.
|
|
504
|
+
- Stress helpers `escapeXml`, `run`, `insertion`, `paragraph`, `parsed`, `acceptedText`, `authorOf`, `assertValid`, and `assertInsertionRoundTrip` (NEW).
|
|
505
|
+
* **Verification**:
|
|
506
|
+
- `node tests/cross_author_slicing_insertion_stress_tests.mjs` — PASS, 76 scenarios.
|
|
507
|
+
- `npm test` — PASS, 94 test files passed and 0 failed.
|
|
508
|
+
- `npm run lint` — PASS.
|
|
509
|
+
- `npm run check:types` — PASS; all 123 runtime exports have declarations.
|
|
510
|
+
- `git diff --check` — PASS (line-ending conversion notices only; no whitespace errors).
|
|
511
|
+
|
|
442
512
|
---
|
|
443
513
|
|
|
444
514
|
## 6. Comprehensive Verification Plan (Synthetic & Real Test Series)
|
package/engine/oxml-engine.js
CHANGED
|
@@ -307,7 +307,9 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
|
|
|
307
307
|
}
|
|
308
308
|
const { cleanText: cleanModifiedText, formatHints } = preprocessMarkdown(sanitizedText);
|
|
309
309
|
|
|
310
|
-
const hasTextChanges =
|
|
310
|
+
const hasTextChanges = existingRevisionsPolicy === 'slice-cross-author'
|
|
311
|
+
? cleanModifiedText !== originalText
|
|
312
|
+
: cleanModifiedText.trim() !== originalText.trim();
|
|
311
313
|
const hasFormatHints = formatHints.length > 0;
|
|
312
314
|
|
|
313
315
|
const { existingFormatHints, textSpans, paragraphs } = extractFormattingFromOoxml(xmlDoc);
|
|
@@ -492,7 +494,7 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
|
|
|
492
494
|
log('[OxmlEngine] Table cell edit: scoping surgical mode to target paragraph');
|
|
493
495
|
}
|
|
494
496
|
|
|
495
|
-
const result = applySurgicalMode(
|
|
497
|
+
const result = applySurgicalMode(
|
|
496
498
|
xmlDoc,
|
|
497
499
|
originalText,
|
|
498
500
|
cleanModifiedText,
|
|
@@ -502,10 +504,14 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
|
|
|
502
504
|
generateRedlines,
|
|
503
505
|
surgicalTarget,
|
|
504
506
|
{},
|
|
505
|
-
options
|
|
506
|
-
);
|
|
507
|
-
|
|
508
|
-
if (
|
|
507
|
+
options
|
|
508
|
+
);
|
|
509
|
+
|
|
510
|
+
if (result.status === 'error' && result.error?.code === 'PATCH_ROUNDTRIP_MISMATCH') {
|
|
511
|
+
return finalize({ ...result, oxml: inputOoxml, hasChanges: false });
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if (tableCellContext.hasTableWrapper && result.hasChanges && tableCellContext.targetParagraph) {
|
|
509
515
|
log('[OxmlEngine] Stripping table wrapper for table cell paragraph (surgical mode)');
|
|
510
516
|
return finalize({ oxml: serializeParagraphOnly(xmlDoc, tableCellContext.targetParagraph, serializer), hasChanges: true });
|
|
511
517
|
}
|
|
@@ -237,6 +237,12 @@ export function processInsert(xmlDoc, spanIndex, pos, text, author, formatHints
|
|
|
237
237
|
return true;
|
|
238
238
|
}
|
|
239
239
|
|
|
240
|
+
const generateNestedRevision = !(
|
|
241
|
+
generateRedlines
|
|
242
|
+
&& existingRevisions === 'slice-cross-author'
|
|
243
|
+
&& isSameAuthorInsertion(parent, author)
|
|
244
|
+
);
|
|
245
|
+
|
|
240
246
|
if (
|
|
241
247
|
generateRedlines
|
|
242
248
|
&& existingRevisions === 'slice-cross-author'
|
|
@@ -267,14 +273,14 @@ export function processInsert(xmlDoc, spanIndex, pos, text, author, formatHints
|
|
|
267
273
|
const afterPieces = sliceRunPieces(xmlDoc, pieces, localInsertPos, getRunTextLength(pieces), false);
|
|
268
274
|
|
|
269
275
|
insertRunPiecesBefore(xmlDoc, parent, targetSpan.runElement, beforePieces, targetSpan.rPr);
|
|
270
|
-
insertTextRuns(xmlDoc, parent, targetSpan.runElement, text, targetSpan.rPr, author, formatHints, insertOffset,
|
|
276
|
+
insertTextRuns(xmlDoc, parent, targetSpan.runElement, text, targetSpan.rPr, author, formatHints, insertOffset, generateNestedRevision, revisionMetadata);
|
|
271
277
|
insertRunPiecesBefore(xmlDoc, parent, targetSpan.runElement, afterPieces, targetSpan.rPr);
|
|
272
278
|
parent.removeChild(targetSpan.runElement);
|
|
273
279
|
return true;
|
|
274
280
|
}
|
|
275
281
|
|
|
276
282
|
const referenceNode = pos <= targetSpan.charStart ? targetSpan.runElement : targetSpan.runElement.nextSibling;
|
|
277
|
-
insertTextRuns(xmlDoc, parent, referenceNode, text, targetSpan.rPr, author, formatHints, insertOffset,
|
|
283
|
+
insertTextRuns(xmlDoc, parent, referenceNode, text, targetSpan.rPr, author, formatHints, insertOffset, generateNestedRevision, revisionMetadata);
|
|
278
284
|
return true;
|
|
279
285
|
}
|
|
280
286
|
|
|
@@ -523,6 +529,12 @@ function isForeignInsertion(node, author) {
|
|
|
523
529
|
return carrierAuthor.trim().toLowerCase() !== String(author || '').trim().toLowerCase();
|
|
524
530
|
}
|
|
525
531
|
|
|
532
|
+
function isSameAuthorInsertion(node, author) {
|
|
533
|
+
if (!isWordElement(node, 'ins')) return false;
|
|
534
|
+
const carrierAuthor = node.getAttribute('w:author') || node.getAttributeNS?.(NS_W, 'author') || '';
|
|
535
|
+
return carrierAuthor.trim().toLowerCase() === String(author || '').trim().toLowerCase();
|
|
536
|
+
}
|
|
537
|
+
|
|
526
538
|
function nextElementSibling(node) {
|
|
527
539
|
let sibling = node?.nextSibling || null;
|
|
528
540
|
while (sibling && sibling.nodeType !== 1) sibling = sibling.nextSibling;
|
package/engine/surgical-mode.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { getApplicableFormatHints } from '../pipeline/markdown-processor.js';
|
|
9
|
-
import { computeWordDiffs } from '../pipeline/diff-engine.js';
|
|
9
|
+
import { computeInsertionOnlyDiffs, computeWordDiffs } from '../pipeline/diff-engine.js';
|
|
10
10
|
import { getDocumentParagraphs } from './format-extraction.js';
|
|
11
11
|
import { buildSpanIndex, buildSurgicalTextSpans, forEachOverlappingSpan } from './surgical-spans.js';
|
|
12
12
|
import {
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
} from './surgical-diff-application.js';
|
|
17
17
|
import { withOoxmlSourceType } from '../core/word-xml.js';
|
|
18
18
|
import { createReplacementRevisionEvent } from '../core/types.js';
|
|
19
|
+
import { extractCanonicalParagraphText } from '../core/paragraph-text.js';
|
|
19
20
|
|
|
20
21
|
function checkSafeAdjacencyForPairing(spanIndex, startPos, endPos, allowInsertionCarrier = false) {
|
|
21
22
|
const spans = [];
|
|
@@ -111,7 +112,10 @@ export function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer
|
|
|
111
112
|
: getDocumentParagraphs(xmlDoc);
|
|
112
113
|
|
|
113
114
|
const { fullText, textSpans } = buildSurgicalTextSpans(allParagraphs);
|
|
114
|
-
const
|
|
115
|
+
const insertionOnlyDiffs = options.existingRevisions === 'slice-cross-author'
|
|
116
|
+
? computeInsertionOnlyDiffs(fullText, modifiedText)
|
|
117
|
+
: null;
|
|
118
|
+
const diffs = insertionOnlyDiffs || computeWordDiffs(fullText, modifiedText, diffOptions);
|
|
115
119
|
const spanIndex = buildSpanIndex(textSpans);
|
|
116
120
|
const pairReplacements = options.pairReplacements === true;
|
|
117
121
|
const warnings = [];
|
|
@@ -151,7 +155,7 @@ export function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer
|
|
|
151
155
|
if (pairReplacements && generateRedlines && hasNextInsert) {
|
|
152
156
|
const nextText = diffs[i + 1][1];
|
|
153
157
|
const textWithoutNewlines = nextText.replace(/\n/g, ' ');
|
|
154
|
-
if (textWithoutNewlines.
|
|
158
|
+
if (textWithoutNewlines.length > 0) {
|
|
155
159
|
const checkResult = checkSafeAdjacencyForPairing(
|
|
156
160
|
spanIndex,
|
|
157
161
|
originalPos,
|
|
@@ -178,7 +182,7 @@ export function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer
|
|
|
178
182
|
i++;
|
|
179
183
|
const [, nextText] = diffs[i];
|
|
180
184
|
const textWithoutNewlines = nextText.replace(/\n/g, ' ');
|
|
181
|
-
if (textWithoutNewlines.
|
|
185
|
+
if (textWithoutNewlines.length > 0) {
|
|
182
186
|
const insertResult = processInsert(xmlDoc, spanIndex, originalPos, textWithoutNewlines, author, formatHints, newPos, generateRedlines, allParagraphs[0] || null, insMetadata, options?.insertionAffinity || null, options?.existingRevisions || 'merge-same-author');
|
|
183
187
|
if (insertResult && typeof insertResult === 'object' && insertResult.error) {
|
|
184
188
|
return withOoxmlSourceType({
|
|
@@ -196,7 +200,7 @@ export function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer
|
|
|
196
200
|
}
|
|
197
201
|
} else if (op === 1) {
|
|
198
202
|
const textWithoutNewlines = text.replace(/\n/g, ' ');
|
|
199
|
-
if (textWithoutNewlines.
|
|
203
|
+
if (textWithoutNewlines.length > 0) {
|
|
200
204
|
const insertResult = processInsert(xmlDoc, spanIndex, originalPos, textWithoutNewlines, author, formatHints, newPos, generateRedlines, allParagraphs[0] || null, null, options?.insertionAffinity || null, options?.existingRevisions || 'merge-same-author');
|
|
201
205
|
if (insertResult && typeof insertResult === 'object' && insertResult.error) {
|
|
202
206
|
return withOoxmlSourceType({
|
|
@@ -214,9 +218,42 @@ export function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer
|
|
|
214
218
|
}
|
|
215
219
|
}
|
|
216
220
|
|
|
221
|
+
const actualText = allParagraphs.map(paragraph => extractCanonicalParagraphText(paragraph)).join('\n');
|
|
222
|
+
const expectedText = String(modifiedText).replace(/\r\n/g, '\n');
|
|
223
|
+
if (options.existingRevisions === 'slice-cross-author' && actualText !== expectedText) {
|
|
224
|
+
const mismatchOffset = firstMismatchOffset(expectedText, actualText);
|
|
225
|
+
return withOoxmlSourceType({
|
|
226
|
+
oxml: serializer.serializeToString(xmlDoc),
|
|
227
|
+
hasChanges: false,
|
|
228
|
+
status: 'error',
|
|
229
|
+
error: {
|
|
230
|
+
code: 'PATCH_ROUNDTRIP_MISMATCH',
|
|
231
|
+
message: 'Generated OOXML accepted-view text does not match the requested modified text; the mutation was rejected.',
|
|
232
|
+
mismatchOffset,
|
|
233
|
+
expectedExcerpt: excerptAt(expectedText, mismatchOffset),
|
|
234
|
+
actualExcerpt: excerptAt(actualText, mismatchOffset)
|
|
235
|
+
},
|
|
236
|
+
...(warnings.length > 0 ? { warnings: [...new Set(warnings)] } : {})
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
217
240
|
return withOoxmlSourceType({
|
|
218
241
|
oxml: serializer.serializeToString(xmlDoc),
|
|
219
242
|
hasChanges,
|
|
220
243
|
...(warnings.length > 0 ? { warnings: [...new Set(warnings)] } : {})
|
|
221
244
|
});
|
|
222
245
|
}
|
|
246
|
+
|
|
247
|
+
function firstMismatchOffset(expected, actual) {
|
|
248
|
+
const limit = Math.min(expected.length, actual.length);
|
|
249
|
+
for (let index = 0; index < limit; index++) {
|
|
250
|
+
if (expected[index] !== actual[index]) return index;
|
|
251
|
+
}
|
|
252
|
+
return limit;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function excerptAt(text, offset, radius = 40) {
|
|
256
|
+
const start = Math.max(0, offset - radius);
|
|
257
|
+
const end = Math.min(text.length, offset + radius);
|
|
258
|
+
return text.slice(start, end);
|
|
259
|
+
}
|
package/package.json
CHANGED
package/pipeline/diff-engine.js
CHANGED
|
@@ -232,6 +232,28 @@ export function computeWordDiffs(originalText, newText, options = {}) {
|
|
|
232
232
|
return decodeBmpDiffs(charDiffs, wordArray);
|
|
233
233
|
}
|
|
234
234
|
|
|
235
|
+
/**
|
|
236
|
+
* Returns a character-local diff only when the modified string can be made
|
|
237
|
+
* solely by inserting into the original. This prevents word-token cleanup
|
|
238
|
+
* from relocating small insertions between repeated phrases or containers.
|
|
239
|
+
*
|
|
240
|
+
* @param {string} originalText
|
|
241
|
+
* @param {string} newText
|
|
242
|
+
* @returns {Array<[number, string]>|null}
|
|
243
|
+
*/
|
|
244
|
+
export function computeInsertionOnlyDiffs(originalText, newText) {
|
|
245
|
+
if (originalText === newText) return [[0, originalText]];
|
|
246
|
+
if (!originalText) return [[1, newText]];
|
|
247
|
+
let originalIndex = 0;
|
|
248
|
+
for (let modifiedIndex = 0; modifiedIndex < newText.length && originalIndex < originalText.length; modifiedIndex++) {
|
|
249
|
+
if (newText[modifiedIndex] === originalText[originalIndex]) originalIndex++;
|
|
250
|
+
}
|
|
251
|
+
if (originalIndex !== originalText.length) return null;
|
|
252
|
+
|
|
253
|
+
const diffs = createDiffEngine().diff_main(originalText, newText);
|
|
254
|
+
return diffs.some(([op]) => op === -1) ? null : diffs;
|
|
255
|
+
}
|
|
256
|
+
|
|
235
257
|
/**
|
|
236
258
|
* Computes word-level diff operations with offset tracking.
|
|
237
259
|
*
|