@ansonlai/docx-redline-js 0.8.1 → 0.8.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ansonlai/docx-redline-js",
3
- "version": "0.8.1",
3
+ "version": "0.8.3",
4
4
  "description": "Host-independent OOXML reconciliation engine for .docx manipulation with track changes",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -4,105 +4,121 @@
4
4
  * Word-level diffing with offset tracking for precise run splitting.
5
5
  */
6
6
 
7
- import { diff_match_patch } from 'diff-match-patch';
8
- import { DiffOp } from '../core/types.js';
9
-
10
- export const TOKEN_CODE_POINT_BASE = 0x10000;
11
- export const MAX_DIFF_TOKENS = 0x40000;
12
-
13
- // diff-match-patch's JavaScript port operates on UTF-16 code units, not
14
- // Unicode code points. Keep its private encoding in the BMP while skipping
15
- // surrogates; larger token sets take the deterministic token-array fallback.
16
- const BMP_FIRST_CODE = 1;
17
- const BMP_BEFORE_SURROGATES = 0xD800 - BMP_FIRST_CODE;
18
- const BMP_AFTER_SURROGATES = 0x10000 - 0xE000;
19
- const DMP_SAFE_TOKEN_LIMIT = BMP_BEFORE_SURROGATES + BMP_AFTER_SURROGATES;
20
-
21
- export class DiffTokenLimitError extends Error {
22
- constructor(limit = MAX_DIFF_TOKENS) {
23
- super(`Word diff exceeds the safe limit of ${limit} unique tokens.`);
24
- this.name = 'DiffTokenLimitError';
25
- this.code = 'DIFF_TOKEN_LIMIT';
26
- this.limit = limit;
27
- }
28
- }
29
-
30
- export function isDiffTokenLimitError(error) {
31
- return error?.code === 'DIFF_TOKEN_LIMIT';
32
- }
33
-
34
- export function createDiffEngine(options = {}) {
35
- const timeout = options.diffTimeoutSeconds ?? 0;
36
- if (!Number.isFinite(timeout) || timeout < 0) {
37
- throw new TypeError('diffTimeoutSeconds must be a finite non-negative number.');
38
- }
39
- const engine = new diff_match_patch();
40
- engine.Diff_Timeout = timeout;
41
- return engine;
42
- }
43
-
44
- function tokenize(text) {
45
- const tokens = [];
46
- const leading = text.match(/^\s+/);
47
- if (leading) tokens.push(leading[0]);
48
-
49
- const regex = /(\S+)(\s*)/g;
50
- regex.lastIndex = leading?.[0].length || 0;
51
- let match;
52
- while ((match = regex.exec(text)) !== null) {
53
- if (match[1]) tokens.push(match[1]);
54
- if (match[2]) tokens.push(match[2]);
55
- }
56
- return tokens;
57
- }
58
-
7
+ import { diff_match_patch } from 'diff-match-patch';
8
+ import { DiffOp } from '../core/types.js';
9
+
10
+ export const TOKEN_CODE_POINT_BASE = 0x10000;
11
+ export const MAX_DIFF_TOKENS = 0x40000;
12
+
13
+ // diff-match-patch's JavaScript port operates on UTF-16 code units, not
14
+ // Unicode code points. Keep its private encoding in the BMP while skipping
15
+ // surrogates; larger token sets take the deterministic token-array fallback.
16
+ const BMP_FIRST_CODE = 1;
17
+ const BMP_BEFORE_SURROGATES = 0xD800 - BMP_FIRST_CODE;
18
+ const BMP_AFTER_SURROGATES = 0x10000 - 0xE000;
19
+ const DMP_SAFE_TOKEN_LIMIT = BMP_BEFORE_SURROGATES + BMP_AFTER_SURROGATES;
20
+
21
+ export class DiffTokenLimitError extends Error {
22
+ constructor(limit = MAX_DIFF_TOKENS) {
23
+ super(`Word diff exceeds the safe limit of ${limit} unique tokens.`);
24
+ this.name = 'DiffTokenLimitError';
25
+ this.code = 'DIFF_TOKEN_LIMIT';
26
+ this.limit = limit;
27
+ }
28
+ }
29
+
30
+ export function isDiffTokenLimitError(error) {
31
+ return error?.code === 'DIFF_TOKEN_LIMIT';
32
+ }
33
+
34
+ export function createDiffEngine(options = {}) {
35
+ const timeout = options.diffTimeoutSeconds ?? 0;
36
+ if (!Number.isFinite(timeout) || timeout < 0) {
37
+ throw new TypeError('diffTimeoutSeconds must be a finite non-negative number.');
38
+ }
39
+ const engine = new diff_match_patch();
40
+ engine.Diff_Timeout = timeout;
41
+ return engine;
42
+ }
43
+
44
+ function tokenize(text, atomicChars = null) {
45
+ const tokens = [];
46
+ const pushWord = word => {
47
+ if (!atomicChars || atomicChars.size === 0) {
48
+ tokens.push(word);
49
+ return;
50
+ }
51
+ // Structural placeholders (for example manual line breaks) stand
52
+ // alone so a change beside one never swallows text across it.
53
+ let start = 0;
54
+ for (let index = 0; index < word.length; index++) {
55
+ if (!atomicChars.has(word[index])) continue;
56
+ if (index > start) tokens.push(word.slice(start, index));
57
+ tokens.push(word[index]);
58
+ start = index + 1;
59
+ }
60
+ if (start < word.length) tokens.push(word.slice(start));
61
+ };
62
+ const leading = text.match(/^\s+/);
63
+ if (leading) tokens.push(leading[0]);
64
+
65
+ const regex = /(\S+)(\s*)/g;
66
+ regex.lastIndex = leading?.[0].length || 0;
67
+ let match;
68
+ while ((match = regex.exec(text)) !== null) {
69
+ if (match[1]) pushWord(match[1]);
70
+ if (match[2]) tokens.push(match[2]);
71
+ }
72
+ return tokens;
73
+ }
74
+
59
75
  /**
60
76
  * Converts text into word tokens represented as unique characters.
61
77
  * This allows DMP to diff at word-level instead of character-level.
62
78
  *
63
79
  * @param {string} text1 - First text to tokenize
64
80
  * @param {string} text2 - Second text to tokenize
65
- * @param {{ maxTokens?: number }} [options={}] - Internal/test capacity override
66
- * @returns {{ chars1: string, chars2: string, wordArray: string[], tokenIds1: number[], tokenIds2: number[] }}
67
- */
68
- export function wordsToChars(text1, text2, options = {}) {
69
- const wordArray = [];
70
- const wordHash = new Map();
71
- const maxTokens = options.maxTokens ?? MAX_DIFF_TOKENS;
72
- if (!Number.isInteger(maxTokens) || maxTokens < 1 || maxTokens > MAX_DIFF_TOKENS) {
73
- throw new RangeError(`maxTokens must be an integer from 1 to ${MAX_DIFF_TOKENS}.`);
74
- }
75
-
76
- function mapTokensToChars(tokens) {
77
- let chars = '';
78
- const tokenIds = [];
79
- for (const token of tokens) {
80
- let tokenId = wordHash.get(token);
81
- if (tokenId === undefined) {
82
- if (wordArray.length >= maxTokens) throw new DiffTokenLimitError(maxTokens);
83
- tokenId = wordArray.length;
84
- wordArray.push(token);
85
- wordHash.set(token, tokenId);
86
- }
87
- tokenIds.push(tokenId);
88
- chars += String.fromCodePoint(TOKEN_CODE_POINT_BASE + tokenId);
89
- }
90
- return { chars, tokenIds };
91
- }
92
-
93
- const tokens1 = tokenize(text1);
94
- const tokens2 = tokenize(text2);
95
- const encoded1 = mapTokensToChars(tokens1);
96
- const encoded2 = mapTokensToChars(tokens2);
97
-
98
- return {
99
- chars1: encoded1.chars,
100
- chars2: encoded2.chars,
101
- wordArray,
102
- tokenIds1: encoded1.tokenIds,
103
- tokenIds2: encoded2.tokenIds
104
- };
105
- }
81
+ * @param {{ maxTokens?: number, atomicChars?: Set<string> }} [options={}] - Capacity override and single-character tokens
82
+ * @returns {{ chars1: string, chars2: string, wordArray: string[], tokenIds1: number[], tokenIds2: number[] }}
83
+ */
84
+ export function wordsToChars(text1, text2, options = {}) {
85
+ const wordArray = [];
86
+ const wordHash = new Map();
87
+ const maxTokens = options.maxTokens ?? MAX_DIFF_TOKENS;
88
+ if (!Number.isInteger(maxTokens) || maxTokens < 1 || maxTokens > MAX_DIFF_TOKENS) {
89
+ throw new RangeError(`maxTokens must be an integer from 1 to ${MAX_DIFF_TOKENS}.`);
90
+ }
91
+
92
+ function mapTokensToChars(tokens) {
93
+ let chars = '';
94
+ const tokenIds = [];
95
+ for (const token of tokens) {
96
+ let tokenId = wordHash.get(token);
97
+ if (tokenId === undefined) {
98
+ if (wordArray.length >= maxTokens) throw new DiffTokenLimitError(maxTokens);
99
+ tokenId = wordArray.length;
100
+ wordArray.push(token);
101
+ wordHash.set(token, tokenId);
102
+ }
103
+ tokenIds.push(tokenId);
104
+ chars += String.fromCodePoint(TOKEN_CODE_POINT_BASE + tokenId);
105
+ }
106
+ return { chars, tokenIds };
107
+ }
108
+
109
+ const tokens1 = tokenize(text1, options.atomicChars);
110
+ const tokens2 = tokenize(text2, options.atomicChars);
111
+ const encoded1 = mapTokensToChars(tokens1);
112
+ const encoded2 = mapTokensToChars(tokens2);
113
+
114
+ return {
115
+ chars1: encoded1.chars,
116
+ chars2: encoded2.chars,
117
+ wordArray,
118
+ tokenIds1: encoded1.tokenIds,
119
+ tokenIds2: encoded2.tokenIds
120
+ };
121
+ }
106
122
 
107
123
  /**
108
124
  * Converts character-encoded diffs back to actual word diffs.
@@ -111,173 +127,173 @@ export function wordsToChars(text1, text2, options = {}) {
111
127
  * @param {string[]} wordArray - Array mapping char codes to words
112
128
  * @returns {Array} DMP-style diff array with actual words
113
129
  */
114
- export function charsToWords(diffs, wordArray) {
115
- const originalChars = diffs.filter(([op]) => op !== 1).map(([, chars]) => chars).join('');
116
- const modifiedChars = diffs.filter(([op]) => op !== -1).map(([, chars]) => chars).join('');
117
-
118
- const decode = chars => {
119
- const tokenIds = [];
120
- for (let i = 0; i < chars.length;) {
121
- const codePoint = chars.codePointAt(i);
122
- const tokenId = codePoint - TOKEN_CODE_POINT_BASE;
123
- if (!Number.isInteger(tokenId) || tokenId < 0 || tokenId >= wordArray.length) {
124
- throw new RangeError(`Diff token code point U+${codePoint.toString(16).toUpperCase()} has no mapping.`);
125
- }
126
- tokenIds.push(tokenId);
127
- i += codePoint > 0xFFFF ? 2 : 1;
128
- }
129
- return tokenIds;
130
- };
131
-
132
- // Reconstruct both encoded sides before decoding. A UTF-16-based diff
133
- // engine may split a surrogate pair across adjacent diff tuples even
134
- // though the complete original/modified streams remain valid.
135
- return deterministicLargeTokenDiff(decode(originalChars), decode(modifiedChars), wordArray);
136
- }
137
-
138
- function tokenIdToBmpChar(tokenId) {
139
- const code = tokenId < BMP_BEFORE_SURROGATES
140
- ? BMP_FIRST_CODE + tokenId
141
- : 0xE000 + (tokenId - BMP_BEFORE_SURROGATES);
142
- return String.fromCharCode(code);
143
- }
144
-
145
- function bmpCharToTokenId(charCode) {
146
- if (charCode >= BMP_FIRST_CODE && charCode < 0xD800) return charCode - BMP_FIRST_CODE;
147
- if (charCode >= 0xE000 && charCode <= 0xFFFF) return BMP_BEFORE_SURROGATES + charCode - 0xE000;
148
- throw new RangeError(`BMP diff token U+${charCode.toString(16).toUpperCase()} has no mapping.`);
149
- }
150
-
151
- function encodeTokenIdsForDmp(tokenIds) {
152
- let chars = '';
153
- for (const tokenId of tokenIds) chars += tokenIdToBmpChar(tokenId);
154
- return chars;
155
- }
156
-
157
- function decodeBmpDiffs(diffs, wordArray) {
158
- return diffs.map(([op, chars]) => {
159
- const parts = [];
160
- for (let index = 0; index < chars.length; index++) {
161
- const tokenId = bmpCharToTokenId(chars.charCodeAt(index));
162
- if (tokenId >= wordArray.length) {
163
- throw new RangeError(`BMP diff token ${tokenId} has no mapping.`);
164
- }
165
- parts.push(wordArray[tokenId]);
166
- }
167
- return [op, parts.join('')];
168
- });
169
- }
170
-
171
- function deterministicLargeTokenDiff(tokenIds1, tokenIds2, wordArray) {
172
- let prefixLength = 0;
173
- const sharedLength = Math.min(tokenIds1.length, tokenIds2.length);
174
- while (prefixLength < sharedLength && tokenIds1[prefixLength] === tokenIds2[prefixLength]) {
175
- prefixLength++;
176
- }
177
-
178
- let suffixLength = 0;
179
- while (
180
- suffixLength < sharedLength - prefixLength
181
- && tokenIds1[tokenIds1.length - 1 - suffixLength] === tokenIds2[tokenIds2.length - 1 - suffixLength]
182
- ) {
183
- suffixLength++;
184
- }
185
-
186
- const joinTokens = ids => ids.map(id => wordArray[id]).join('');
187
- const diffs = [];
188
- if (prefixLength) diffs.push([0, joinTokens(tokenIds1.slice(0, prefixLength))]);
189
-
190
- const deleted = tokenIds1.slice(prefixLength, tokenIds1.length - suffixLength);
191
- const inserted = tokenIds2.slice(prefixLength, tokenIds2.length - suffixLength);
192
- if (deleted.length) diffs.push([-1, joinTokens(deleted)]);
193
- if (inserted.length) diffs.push([1, joinTokens(inserted)]);
194
- if (suffixLength) diffs.push([0, joinTokens(tokenIds1.slice(tokenIds1.length - suffixLength))]);
195
- return diffs;
196
- }
197
-
198
- /**
199
- * Computes word-level diff tuples using a shared diff engine instance.
200
- *
201
- * @param {string} originalText - Original text
202
- * @param {string} newText - New text
203
- * @param {{ cleanupSemantic?: boolean, diffTimeoutSeconds?: number, maxTokens?: number }} [options={}] - Diff options
204
- * @returns {Array<[number, string]>}
205
- */
206
- export function computeWordDiffs(originalText, newText, options = {}) {
207
- if (originalText === newText) {
208
- return [[0, originalText]];
209
- }
210
-
211
- if (!originalText) {
212
- return [[1, newText]];
213
- }
214
-
215
- if (!newText) {
216
- return [[-1, originalText]];
217
- }
218
-
219
- const { cleanupSemantic = true } = options;
220
-
221
- const { wordArray, tokenIds1, tokenIds2 } = wordsToChars(originalText, newText, options);
222
- if (wordArray.length > DMP_SAFE_TOKEN_LIMIT) {
223
- return deterministicLargeTokenDiff(tokenIds1, tokenIds2, wordArray);
224
- }
225
-
226
- const dmp = createDiffEngine(options);
227
- const charDiffs = dmp.diff_main(encodeTokenIdsForDmp(tokenIds1), encodeTokenIdsForDmp(tokenIds2));
228
- if (cleanupSemantic) {
229
- dmp.diff_cleanupSemantic(charDiffs);
230
- }
231
-
232
- return decodeBmpDiffs(charDiffs, wordArray);
233
- }
234
-
235
- /**
236
- * Computes a character-local diff without semantic cleanup. This is used to
237
- * refine whitespace-only substitutions that a word-level token groups with
238
- * adjacent unchanged content (for example, an NBSP beside a hyperlink).
239
- *
240
- * @param {string} originalText
241
- * @param {string} newText
242
- * @param {{ diffTimeoutSeconds?: number }} [options={}]
243
- * @returns {Array<[number, string]>}
244
- */
245
- export function computeCharacterDiffs(originalText, newText, options = {}) {
246
- if (originalText === newText) return [[0, originalText]];
247
- return createDiffEngine(options).diff_main(originalText, newText);
248
- }
249
-
250
- /**
251
- * Returns a character-local diff only when the modified string can be made
252
- * solely by inserting into the original. This prevents word-token cleanup
253
- * from relocating small insertions between repeated phrases or containers.
254
- *
255
- * @param {string} originalText
256
- * @param {string} newText
257
- * @returns {Array<[number, string]>|null}
258
- */
259
- export function computeInsertionOnlyDiffs(originalText, newText) {
260
- if (originalText === newText) return [[0, originalText]];
261
- if (!originalText) return [[1, newText]];
262
- let originalIndex = 0;
263
- for (let modifiedIndex = 0; modifiedIndex < newText.length && originalIndex < originalText.length; modifiedIndex++) {
264
- if (newText[modifiedIndex] === originalText[originalIndex]) originalIndex++;
265
- }
266
- if (originalIndex !== originalText.length) return null;
267
-
268
- const diffs = createDiffEngine().diff_main(originalText, newText);
269
- return diffs.some(([op]) => op === -1) ? null : diffs;
270
- }
271
-
272
- /**
273
- * Computes word-level diff operations with offset tracking.
130
+ export function charsToWords(diffs, wordArray) {
131
+ const originalChars = diffs.filter(([op]) => op !== 1).map(([, chars]) => chars).join('');
132
+ const modifiedChars = diffs.filter(([op]) => op !== -1).map(([, chars]) => chars).join('');
133
+
134
+ const decode = chars => {
135
+ const tokenIds = [];
136
+ for (let i = 0; i < chars.length;) {
137
+ const codePoint = chars.codePointAt(i);
138
+ const tokenId = codePoint - TOKEN_CODE_POINT_BASE;
139
+ if (!Number.isInteger(tokenId) || tokenId < 0 || tokenId >= wordArray.length) {
140
+ throw new RangeError(`Diff token code point U+${codePoint.toString(16).toUpperCase()} has no mapping.`);
141
+ }
142
+ tokenIds.push(tokenId);
143
+ i += codePoint > 0xFFFF ? 2 : 1;
144
+ }
145
+ return tokenIds;
146
+ };
147
+
148
+ // Reconstruct both encoded sides before decoding. A UTF-16-based diff
149
+ // engine may split a surrogate pair across adjacent diff tuples even
150
+ // though the complete original/modified streams remain valid.
151
+ return deterministicLargeTokenDiff(decode(originalChars), decode(modifiedChars), wordArray);
152
+ }
153
+
154
+ function tokenIdToBmpChar(tokenId) {
155
+ const code = tokenId < BMP_BEFORE_SURROGATES
156
+ ? BMP_FIRST_CODE + tokenId
157
+ : 0xE000 + (tokenId - BMP_BEFORE_SURROGATES);
158
+ return String.fromCharCode(code);
159
+ }
160
+
161
+ function bmpCharToTokenId(charCode) {
162
+ if (charCode >= BMP_FIRST_CODE && charCode < 0xD800) return charCode - BMP_FIRST_CODE;
163
+ if (charCode >= 0xE000 && charCode <= 0xFFFF) return BMP_BEFORE_SURROGATES + charCode - 0xE000;
164
+ throw new RangeError(`BMP diff token U+${charCode.toString(16).toUpperCase()} has no mapping.`);
165
+ }
166
+
167
+ function encodeTokenIdsForDmp(tokenIds) {
168
+ let chars = '';
169
+ for (const tokenId of tokenIds) chars += tokenIdToBmpChar(tokenId);
170
+ return chars;
171
+ }
172
+
173
+ function decodeBmpDiffs(diffs, wordArray) {
174
+ return diffs.map(([op, chars]) => {
175
+ const parts = [];
176
+ for (let index = 0; index < chars.length; index++) {
177
+ const tokenId = bmpCharToTokenId(chars.charCodeAt(index));
178
+ if (tokenId >= wordArray.length) {
179
+ throw new RangeError(`BMP diff token ${tokenId} has no mapping.`);
180
+ }
181
+ parts.push(wordArray[tokenId]);
182
+ }
183
+ return [op, parts.join('')];
184
+ });
185
+ }
186
+
187
+ function deterministicLargeTokenDiff(tokenIds1, tokenIds2, wordArray) {
188
+ let prefixLength = 0;
189
+ const sharedLength = Math.min(tokenIds1.length, tokenIds2.length);
190
+ while (prefixLength < sharedLength && tokenIds1[prefixLength] === tokenIds2[prefixLength]) {
191
+ prefixLength++;
192
+ }
193
+
194
+ let suffixLength = 0;
195
+ while (
196
+ suffixLength < sharedLength - prefixLength
197
+ && tokenIds1[tokenIds1.length - 1 - suffixLength] === tokenIds2[tokenIds2.length - 1 - suffixLength]
198
+ ) {
199
+ suffixLength++;
200
+ }
201
+
202
+ const joinTokens = ids => ids.map(id => wordArray[id]).join('');
203
+ const diffs = [];
204
+ if (prefixLength) diffs.push([0, joinTokens(tokenIds1.slice(0, prefixLength))]);
205
+
206
+ const deleted = tokenIds1.slice(prefixLength, tokenIds1.length - suffixLength);
207
+ const inserted = tokenIds2.slice(prefixLength, tokenIds2.length - suffixLength);
208
+ if (deleted.length) diffs.push([-1, joinTokens(deleted)]);
209
+ if (inserted.length) diffs.push([1, joinTokens(inserted)]);
210
+ if (suffixLength) diffs.push([0, joinTokens(tokenIds1.slice(tokenIds1.length - suffixLength))]);
211
+ return diffs;
212
+ }
213
+
214
+ /**
215
+ * Computes word-level diff tuples using a shared diff engine instance.
216
+ *
217
+ * @param {string} originalText - Original text
218
+ * @param {string} newText - New text
219
+ * @param {{ cleanupSemantic?: boolean, diffTimeoutSeconds?: number, maxTokens?: number, atomicChars?: Set<string> }} [options={}] - Diff options
220
+ * @returns {Array<[number, string]>}
221
+ */
222
+ export function computeWordDiffs(originalText, newText, options = {}) {
223
+ if (originalText === newText) {
224
+ return [[0, originalText]];
225
+ }
226
+
227
+ if (!originalText) {
228
+ return [[1, newText]];
229
+ }
230
+
231
+ if (!newText) {
232
+ return [[-1, originalText]];
233
+ }
234
+
235
+ const { cleanupSemantic = true } = options;
236
+
237
+ const { wordArray, tokenIds1, tokenIds2 } = wordsToChars(originalText, newText, options);
238
+ if (wordArray.length > DMP_SAFE_TOKEN_LIMIT) {
239
+ return deterministicLargeTokenDiff(tokenIds1, tokenIds2, wordArray);
240
+ }
241
+
242
+ const dmp = createDiffEngine(options);
243
+ const charDiffs = dmp.diff_main(encodeTokenIdsForDmp(tokenIds1), encodeTokenIdsForDmp(tokenIds2));
244
+ if (cleanupSemantic) {
245
+ dmp.diff_cleanupSemantic(charDiffs);
246
+ }
247
+
248
+ return decodeBmpDiffs(charDiffs, wordArray);
249
+ }
250
+
251
+ /**
252
+ * Computes a character-local diff without semantic cleanup. This is used to
253
+ * refine whitespace-only substitutions that a word-level token groups with
254
+ * adjacent unchanged content (for example, an NBSP beside a hyperlink).
255
+ *
256
+ * @param {string} originalText
257
+ * @param {string} newText
258
+ * @param {{ diffTimeoutSeconds?: number }} [options={}]
259
+ * @returns {Array<[number, string]>}
260
+ */
261
+ export function computeCharacterDiffs(originalText, newText, options = {}) {
262
+ if (originalText === newText) return [[0, originalText]];
263
+ return createDiffEngine(options).diff_main(originalText, newText);
264
+ }
265
+
266
+ /**
267
+ * Returns a character-local diff only when the modified string can be made
268
+ * solely by inserting into the original. This prevents word-token cleanup
269
+ * from relocating small insertions between repeated phrases or containers.
270
+ *
271
+ * @param {string} originalText
272
+ * @param {string} newText
273
+ * @returns {Array<[number, string]>|null}
274
+ */
275
+ export function computeInsertionOnlyDiffs(originalText, newText) {
276
+ if (originalText === newText) return [[0, originalText]];
277
+ if (!originalText) return [[1, newText]];
278
+ let originalIndex = 0;
279
+ for (let modifiedIndex = 0; modifiedIndex < newText.length && originalIndex < originalText.length; modifiedIndex++) {
280
+ if (newText[modifiedIndex] === originalText[originalIndex]) originalIndex++;
281
+ }
282
+ if (originalIndex !== originalText.length) return null;
283
+
284
+ const diffs = createDiffEngine().diff_main(originalText, newText);
285
+ return diffs.some(([op]) => op === -1) ? null : diffs;
286
+ }
287
+
288
+ /**
289
+ * Computes word-level diff operations with offset tracking.
274
290
  *
275
291
  * @param {string} originalText - Original text
276
292
  * @param {string} newText - New text
277
- * @param {{ cleanupSemantic?: boolean }} [options={}] - Diff options
278
- * @returns {import('../core/types.js').DiffOperation[]}
279
- */
280
- export function computeWordLevelDiffOps(originalText, newText, options = {}) {
293
+ * @param {{ cleanupSemantic?: boolean }} [options={}] - Diff options
294
+ * @returns {import('../core/types.js').DiffOperation[]}
295
+ */
296
+ export function computeWordLevelDiffOps(originalText, newText, options = {}) {
281
297
  // Handle edge cases
282
298
  if (originalText === newText) {
283
299
  return [{
@@ -306,7 +322,7 @@ export function computeWordLevelDiffOps(originalText, newText, options = {}) {
306
322
  }];
307
323
  }
308
324
 
309
- const wordDiffs = computeWordDiffs(originalText, newText, options);
325
+ const wordDiffs = computeWordDiffs(originalText, newText, options);
310
326
 
311
327
  // Convert to operations with offsets
312
328
  const operations = [];