@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/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)
@@ -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 = cleanModifiedText.trim() !== originalText.trim();
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 (tableCellContext.hasTableWrapper && result.hasChanges && tableCellContext.targetParagraph) {
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, generateRedlines, revisionMetadata);
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, generateRedlines, revisionMetadata);
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;
@@ -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 diffs = computeWordDiffs(fullText, modifiedText, diffOptions);
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.trim().length > 0) {
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.trim().length > 0) {
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.trim().length > 0) {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ansonlai/docx-redline-js",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "description": "Host-independent OOXML reconciliation engine for .docx manipulation with track changes",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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
  *