@stll/folio-core 0.35.1 → 0.36.0

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.
Files changed (80) hide show
  1. package/dist/ai-edits/apply.js +370 -118
  2. package/dist/ai-edits/headless.js +16 -10
  3. package/dist/ai-edits/index.d.ts +2 -2
  4. package/dist/ai-edits/index.js +2 -2
  5. package/dist/ai-edits/snapshot.js +6 -1
  6. package/dist/ai-edits/types.d.ts +19 -6
  7. package/dist/ai-edits/word-diff.d.ts +18 -3
  8. package/dist/ai-edits/word-diff.js +556 -67
  9. package/dist/compare/compare.js +7 -16
  10. package/dist/compare/plan.js +8 -5
  11. package/dist/compare/verification.d.ts +8 -9
  12. package/dist/compare/verification.js +51 -46
  13. package/dist/compat/eigenpal.d.ts +2 -2
  14. package/dist/compat/eigenpal.js +2 -2
  15. package/dist/controller/headerFooterEditorManager.js +5 -5
  16. package/dist/controller/hiddenEditorApi.js +1 -1
  17. package/dist/controller/noteEditorManager.js +2 -2
  18. package/dist/document-operations.d.ts +5 -3
  19. package/dist/document-operations.js +32 -5
  20. package/dist/docx/commentParser.js +2 -4
  21. package/dist/docx/commentRangeIntegrity.js +2 -4
  22. package/dist/docx/documentClone.d.ts +2 -0
  23. package/dist/docx/documentClone.js +2 -0
  24. package/dist/docx/headerFooterParser.js +2 -4
  25. package/dist/docx/normalizeBaseDirection.js +5 -7
  26. package/dist/docx/paragraphParser.js +27 -29
  27. package/dist/docx/paragraphPropertySource.d.ts +49 -0
  28. package/dist/docx/paragraphPropertySource.js +113 -0
  29. package/dist/docx/runConsolidator.js +2 -4
  30. package/dist/docx/runParser.js +2 -15
  31. package/dist/docx/sectionParser.js +1 -15
  32. package/dist/docx/serializer/paragraphSerializer.js +287 -30
  33. package/dist/docx/serializer/runSerializer.js +2 -10
  34. package/dist/docx/serializer/sectionPropertiesSerializer.js +2 -10
  35. package/dist/docx/serializer/tableSerializer.js +4 -23
  36. package/dist/docx/serializer/trackedChangeAttributes.d.ts +9 -0
  37. package/dist/docx/serializer/trackedChangeAttributes.js +21 -0
  38. package/dist/docx/server/createBilingualDocument.js +8 -10
  39. package/dist/docx/server/evaluateDocxXmlPatchProposal.js +22 -13
  40. package/dist/docx/server/validateDocxConformance.js +17 -13
  41. package/dist/docx/tableParser.js +1 -37
  42. package/dist/docx/trackedChangeInfo.d.ts +11 -0
  43. package/dist/docx/trackedChangeInfo.js +38 -0
  44. package/dist/docx/verbatimCapture.d.ts +20 -9
  45. package/dist/docx/verbatimCapture.js +208 -8
  46. package/dist/docx/xmlParser.d.ts +3 -1
  47. package/dist/docx/xmlParser.js +6 -1
  48. package/dist/docx/xmlSafety.d.ts +1 -1
  49. package/dist/docx/xmlSafety.js +157 -0
  50. package/dist/index.d.ts +2 -2
  51. package/dist/index.js +2 -2
  52. package/dist/layout-bridge/convert/headerFooterLayout.js +4 -4
  53. package/dist/layout-bridge/convert/toFlowBlocks.js +12 -7
  54. package/dist/markdown/renderBlock.js +7 -10
  55. package/dist/prosemirror/attrs/index.js +15 -0
  56. package/dist/prosemirror/commands/comments.js +365 -32
  57. package/dist/prosemirror/commands/pageBreak.js +1 -1
  58. package/dist/prosemirror/commands/propertyChangeScope.d.ts +26 -4
  59. package/dist/prosemirror/commands/propertyChangeScope.js +65 -3
  60. package/dist/prosemirror/conversion/fromProseDoc.d.ts +2 -4
  61. package/dist/prosemirror/conversion/fromProseDoc.js +130 -18
  62. package/dist/prosemirror/conversion/toProseDoc.js +27 -12
  63. package/dist/prosemirror/extensions/core/ParagraphExtension.js +48 -4
  64. package/dist/prosemirror/extensions/features/AutoBidiDetectionExtension.js +7 -1
  65. package/dist/prosemirror/extensions/features/ParaIdAllocatorExtension.js +20 -7
  66. package/dist/prosemirror/extensions/marks/TrackedChangeExtensions.js +2 -0
  67. package/dist/prosemirror/paragraphAlignment.d.ts +12 -0
  68. package/dist/prosemirror/paragraphAlignment.js +13 -0
  69. package/dist/prosemirror/plugins/suggestionMode.js +1 -1
  70. package/dist/prosemirror/schema/marks.d.ts +2 -0
  71. package/dist/prosemirror/schema/nodes.d.ts +12 -1
  72. package/dist/prosemirror/styles/resolvedStyleAttrs.js +1 -0
  73. package/dist/redline.js +4 -1
  74. package/dist/server.d.ts +2 -2
  75. package/dist/server.js +2 -2
  76. package/dist/utils/mergeDocumentContent.js +3 -3
  77. package/dist/utils/replaceText.js +2 -1
  78. package/dist/version-comparison.js +6 -4
  79. package/dist/watermark/index.js +2 -4
  80. package/package.json +6 -1
@@ -63,85 +63,519 @@ const comparisonKey = (token, normalization) => {
63
63
  };
64
64
  const isSeparatorOnly = (text) => SEPARATOR_ONLY.test(text);
65
65
  /**
66
- * Cell budget for the O(n*m) DP table below. `before`/`after` come from
67
- * attacker-controlled document text (a `modified` block pair), so an
68
- * unbounded pair of large strings would otherwise force a quadratic-sized
69
- * allocation. Past this budget, skip the DP and fall back to a single
70
- * whole-string `del` + `ins` pair a coarser diff, but O(1) memory.
66
+ * Cell budget shared by the residual LCS gaps inside one comparison or apply
67
+ * scope. `before`/`after` come from attacker-controlled document text (a
68
+ * `modified` block pair), so an unbounded pair of large strings would
69
+ * otherwise force a quadratic-sized allocation. Package-level callers share
70
+ * one scope across every story and operation; the standalone public helper
71
+ * gets one scope per call.
71
72
  */
72
73
  const MAX_WORD_DIFF_CELLS = 4e6;
73
- /** The LCS alignment, as runs. Longest common subsequence on comparison keys. */
74
- const alignTokens = ({ before, after, normalization }) => {
75
- const beforeKeys = before.map((token) => comparisonKey(token, normalization));
76
- const afterKeys = after.map((token) => comparisonKey(token, normalization));
77
- const m = before.length;
78
- const n = after.length;
79
- const dp = Array.from({ length: m + 1 }, () => Array.from({ length: n + 1 }, () => 0));
80
- for (let i = 0; i < m; i++) for (let j = 0; j < n; j++) {
81
- const row = dp[i + 1];
82
- const previousRow = dp[i];
83
- if (!row || !previousRow) continue;
84
- row[j + 1] = beforeKeys[i] === afterKeys[j] ? (previousRow[j] ?? 0) + 1 : Math.max(row[j] ?? 0, previousRow[j + 1] ?? 0);
74
+ /**
75
+ * Maximum combined residual tokens considered for unique-anchor discovery.
76
+ * Comparison-key arrays, occurrence maps, and the candidate list are optional
77
+ * linear storage over attacker-controlled text. Common affixes are removed
78
+ * before this cap, so a small edit in a very long paragraph can still use
79
+ * anchors without duplicating the whole input as normalized strings.
80
+ */
81
+ const MAX_WORD_DIFF_ANCHOR_TOKENS = 16384;
82
+ /**
83
+ * Maximum source code units retained in comparison-key arrays. Token count
84
+ * alone is not a storage bound: one word token can itself contain megabytes,
85
+ * and case or whitespace normalization creates another string for it.
86
+ */
87
+ const MAX_WORD_DIFF_COMPARISON_KEY_CODE_UNITS = 1048576;
88
+ const ALIGNMENT_OPERATION = {
89
+ Equal: 1,
90
+ Delete: 2,
91
+ Insert: 3
92
+ };
93
+ const fitsCellBudget = (beforeLength, afterLength, budget) => beforeLength === 0 || afterLength === 0 || beforeLength <= Math.floor(budget.remainingCells / afterLength);
94
+ const pushRun = (runs, run) => {
95
+ const last = runs.at(-1);
96
+ if (last?.type === "equal" && run.type === "equal") {
97
+ last.before += run.before;
98
+ last.after += run.after;
99
+ last.units += run.units;
100
+ return;
101
+ }
102
+ if (last?.type === "del" && run.type === "del" || last?.type === "ins" && run.type === "ins") {
103
+ last.text += run.text;
104
+ return;
105
+ }
106
+ runs.push(run);
107
+ };
108
+ const joinTokenRange = (tokens, { start, end }) => {
109
+ return tokens.slice(start, end).join("");
110
+ };
111
+ const pushEqualRange = (runs, before, after, beforeRange, afterRange) => {
112
+ const units = beforeRange.end - beforeRange.start;
113
+ if (units === 0) return;
114
+ pushRun(runs, {
115
+ type: "equal",
116
+ before: joinTokenRange(before, beforeRange),
117
+ after: joinTokenRange(after, afterRange),
118
+ units
119
+ });
120
+ };
121
+ const pushChangedRange = (runs, before, after, beforeRange, afterRange) => {
122
+ if (beforeRange.start !== beforeRange.end) pushRun(runs, {
123
+ type: "del",
124
+ text: joinTokenRange(before, beforeRange)
125
+ });
126
+ if (afterRange.start !== afterRange.end) pushRun(runs, {
127
+ type: "ins",
128
+ text: joinTokenRange(after, afterRange)
129
+ });
130
+ };
131
+ /**
132
+ * Unique tokens common to both ranges, reduced to a monotone subsequence of
133
+ * target positions. Repeated boilerplate is deliberately ineligible: a unique
134
+ * clause number or name is stronger lineage evidence than another occurrence
135
+ * of "the" chosen by an arbitrary LCS tie.
136
+ */
137
+ const findPatienceAnchors = (beforeKeys, afterKeys, beforeRange, afterRange) => {
138
+ const beforeOccurrences = /* @__PURE__ */ new Map();
139
+ for (let index = beforeRange.start; index < beforeRange.end; index++) {
140
+ const key = beforeKeys[index] ?? "";
141
+ beforeOccurrences.set(key, (beforeOccurrences.get(key) ?? 0) + 1);
142
+ }
143
+ const afterOccurrences = /* @__PURE__ */ new Map();
144
+ for (let index = afterRange.start; index < afterRange.end; index++) {
145
+ const key = afterKeys[index] ?? "";
146
+ const occurrence = afterOccurrences.get(key);
147
+ if (occurrence) occurrence.count++;
148
+ else afterOccurrences.set(key, {
149
+ count: 1,
150
+ index
151
+ });
152
+ }
153
+ const candidates = [];
154
+ for (let beforeIndex = beforeRange.start; beforeIndex < beforeRange.end; beforeIndex++) {
155
+ const key = beforeKeys[beforeIndex] ?? "";
156
+ const beforeCount = beforeOccurrences.get(key);
157
+ const afterOccurrence = afterOccurrences.get(key);
158
+ if (beforeCount === 1 && afterOccurrence?.count === 1) candidates.push({
159
+ beforeIndex,
160
+ afterIndex: afterOccurrence.index
161
+ });
162
+ }
163
+ if (candidates.length < 2) return candidates;
164
+ const predecessors = new Int32Array(candidates.length);
165
+ predecessors.fill(-1);
166
+ const tailCandidateIndexes = new Int32Array(candidates.length);
167
+ let tailCount = 0;
168
+ for (const [candidateIndex, candidate] of candidates.entries()) {
169
+ let low = 0;
170
+ let high = tailCount;
171
+ while (low < high) {
172
+ const middle = low + Math.floor((high - low) / 2);
173
+ const tail = candidates[tailCandidateIndexes[middle] ?? -1];
174
+ if (tail && tail.afterIndex < candidate.afterIndex) low = middle + 1;
175
+ else high = middle;
176
+ }
177
+ if (low > 0) predecessors[candidateIndex] = tailCandidateIndexes[low - 1] ?? -1;
178
+ tailCandidateIndexes[low] = candidateIndex;
179
+ if (low === tailCount) tailCount++;
180
+ }
181
+ const anchors = [];
182
+ let candidateIndex = tailCandidateIndexes[tailCount - 1] ?? -1;
183
+ while (candidateIndex >= 0) {
184
+ const candidate = candidates[candidateIndex];
185
+ if (candidate) anchors.push(candidate);
186
+ candidateIndex = predecessors[candidateIndex] ?? -1;
187
+ }
188
+ anchors.reverse();
189
+ return anchors;
190
+ };
191
+ const keysEqual = (before, after, normalization) => comparisonKey(before, normalization) === comparisonKey(after, normalization);
192
+ const isTokenSubsequence = ({ subsequence, sequence, subsequenceRange, sequenceRange, normalization }) => {
193
+ if (subsequenceRange.start === subsequenceRange.end) return true;
194
+ let subsequenceIndex = subsequenceRange.start;
195
+ let subsequenceKey = comparisonKey(subsequence[subsequenceIndex] ?? "", normalization);
196
+ for (let sequenceIndex = sequenceRange.start; sequenceIndex < sequenceRange.end; sequenceIndex++) {
197
+ if (subsequenceIndex === subsequenceRange.end) return true;
198
+ const token = sequence[sequenceIndex] ?? "";
199
+ if (subsequenceKey !== comparisonKey(token, normalization)) continue;
200
+ subsequenceIndex++;
201
+ if (subsequenceIndex < subsequenceRange.end) {
202
+ const nextToken = subsequence[subsequenceIndex] ?? "";
203
+ subsequenceKey = comparisonKey(nextToken, normalization);
204
+ }
85
205
  }
86
- const reversed = [];
87
- let i = m;
88
- let j = n;
89
- while (i > 0 && j > 0) {
90
- if (beforeKeys[i - 1] === afterKeys[j - 1]) {
91
- reversed.push({
206
+ return subsequenceIndex === subsequenceRange.end;
207
+ };
208
+ /** Classify edits whose shorter token sequence survives whole and in order. */
209
+ const classifyMonotoneChange = ({ before, after, beforeRange, afterRange, normalization }) => {
210
+ const beforeLength = beforeRange.end - beforeRange.start;
211
+ const afterLength = afterRange.end - afterRange.start;
212
+ if (beforeLength === afterLength) {
213
+ for (let offset = 0; offset < beforeLength; offset++) if (!keysEqual(before[beforeRange.start + offset] ?? "", after[afterRange.start + offset] ?? "", normalization)) return "mixed";
214
+ return "equivalent";
215
+ }
216
+ if (beforeLength < afterLength) return isTokenSubsequence({
217
+ subsequence: before,
218
+ sequence: after,
219
+ subsequenceRange: beforeRange,
220
+ sequenceRange: afterRange,
221
+ normalization
222
+ }) ? "insertion" : "mixed";
223
+ return isTokenSubsequence({
224
+ subsequence: after,
225
+ sequence: before,
226
+ subsequenceRange: afterRange,
227
+ sequenceRange: beforeRange,
228
+ normalization
229
+ }) ? "deletion" : "mixed";
230
+ };
231
+ const alignMonotoneChange = (beforeText, afterText, before, after, normalization, change) => {
232
+ const beforeIsSubsequence = change === "insertion";
233
+ const subsequence = beforeIsSubsequence ? before : after;
234
+ const sequence = beforeIsSubsequence ? after : before;
235
+ const sequenceText = beforeIsSubsequence ? afterText : beforeText;
236
+ const runs = [];
237
+ let subsequenceIndex = 0;
238
+ let sequenceOffset = 0;
239
+ let sequenceChangeStartOffset = 0;
240
+ for (let sequenceIndex = 0; sequenceIndex < sequence.length; sequenceIndex++) {
241
+ const subsequenceToken = subsequence[subsequenceIndex];
242
+ const sequenceToken = sequence[sequenceIndex];
243
+ const sequenceTokenStart = sequenceOffset;
244
+ sequenceOffset += sequenceToken?.length ?? 0;
245
+ if (subsequenceToken === void 0 || sequenceToken === void 0 || !keysEqual(subsequenceToken, sequenceToken, normalization)) continue;
246
+ if (sequenceChangeStartOffset < sequenceTokenStart) {
247
+ const changedText = sequenceText.slice(sequenceChangeStartOffset, sequenceTokenStart);
248
+ pushRun(runs, beforeIsSubsequence ? {
249
+ type: "ins",
250
+ text: changedText
251
+ } : {
252
+ type: "del",
253
+ text: changedText
254
+ });
255
+ }
256
+ pushRun(runs, {
257
+ type: "equal",
258
+ before: beforeIsSubsequence ? subsequenceToken : sequenceToken,
259
+ after: beforeIsSubsequence ? sequenceToken : subsequenceToken,
260
+ units: 1
261
+ });
262
+ subsequenceIndex++;
263
+ sequenceChangeStartOffset = sequenceOffset;
264
+ }
265
+ if (sequenceChangeStartOffset < sequenceText.length) {
266
+ const changedText = sequenceText.slice(sequenceChangeStartOffset);
267
+ pushRun(runs, beforeIsSubsequence ? {
268
+ type: "ins",
269
+ text: changedText
270
+ } : {
271
+ type: "del",
272
+ text: changedText
273
+ });
274
+ }
275
+ return runs;
276
+ };
277
+ /** Longest-common-subsequence alignment for one residual, bounded gap. */
278
+ const alignDenseGap = ({ before, after, beforeKeys, afterKeys, beforeRange, afterRange, runs, budget }) => {
279
+ const beforeLength = beforeRange.end - beforeRange.start;
280
+ const afterLength = afterRange.end - afterRange.start;
281
+ if (beforeLength === 0 || afterLength === 0) {
282
+ pushChangedRange(runs, before, after, beforeRange, afterRange);
283
+ return;
284
+ }
285
+ if (!fitsCellBudget(beforeLength, afterLength, budget)) {
286
+ pushChangedRange(runs, before, after, beforeRange, afterRange);
287
+ return;
288
+ }
289
+ const cellCount = beforeLength * afterLength;
290
+ budget.remainingCells -= cellCount;
291
+ const lengths = new Uint32Array(cellCount);
292
+ for (let beforeOffset = 0; beforeOffset < beforeLength; beforeOffset++) for (let afterOffset = 0; afterOffset < afterLength; afterOffset++) {
293
+ const index = beforeOffset * afterLength + afterOffset;
294
+ if (beforeKeys[beforeRange.start + beforeOffset] === afterKeys[afterRange.start + afterOffset]) {
295
+ lengths[index] = (beforeOffset > 0 && afterOffset > 0 ? lengths[(beforeOffset - 1) * afterLength + afterOffset - 1] ?? 0 : 0) + 1;
296
+ continue;
297
+ }
298
+ const above = beforeOffset > 0 ? lengths[(beforeOffset - 1) * afterLength + afterOffset] ?? 0 : 0;
299
+ const left = afterOffset > 0 ? lengths[index - 1] ?? 0 : 0;
300
+ lengths[index] = Math.max(above, left);
301
+ }
302
+ const reversedOperations = new Uint8Array(beforeLength + afterLength);
303
+ let operationCount = 0;
304
+ let beforeOffset = beforeLength - 1;
305
+ let afterOffset = afterLength - 1;
306
+ while (beforeOffset >= 0 && afterOffset >= 0) {
307
+ if (beforeKeys[beforeRange.start + beforeOffset] === afterKeys[afterRange.start + afterOffset]) {
308
+ reversedOperations[operationCount++] = ALIGNMENT_OPERATION.Equal;
309
+ beforeOffset--;
310
+ afterOffset--;
311
+ continue;
312
+ }
313
+ if ((beforeOffset > 0 ? lengths[(beforeOffset - 1) * afterLength + afterOffset] ?? 0 : 0) > (afterOffset > 0 ? lengths[beforeOffset * afterLength + afterOffset - 1] ?? 0 : 0)) {
314
+ reversedOperations[operationCount++] = ALIGNMENT_OPERATION.Delete;
315
+ beforeOffset--;
316
+ } else {
317
+ reversedOperations[operationCount++] = ALIGNMENT_OPERATION.Insert;
318
+ afterOffset--;
319
+ }
320
+ }
321
+ while (beforeOffset >= 0) {
322
+ reversedOperations[operationCount++] = ALIGNMENT_OPERATION.Delete;
323
+ beforeOffset--;
324
+ }
325
+ while (afterOffset >= 0) {
326
+ reversedOperations[operationCount++] = ALIGNMENT_OPERATION.Insert;
327
+ afterOffset--;
328
+ }
329
+ let beforeCursor = beforeRange.start;
330
+ let afterCursor = afterRange.start;
331
+ for (let operationIndex = operationCount - 1; operationIndex >= 0; operationIndex--) {
332
+ const operation = reversedOperations[operationIndex];
333
+ if (operation === ALIGNMENT_OPERATION.Equal) {
334
+ pushRun(runs, {
92
335
  type: "equal",
93
- before: before[i - 1] ?? "",
94
- after: after[j - 1] ?? "",
336
+ before: before[beforeCursor] ?? "",
337
+ after: after[afterCursor] ?? "",
95
338
  units: 1
96
339
  });
97
- i--;
98
- j--;
340
+ beforeCursor++;
341
+ afterCursor++;
99
342
  continue;
100
343
  }
101
- if ((dp[i - 1]?.[j] ?? 0) > (dp[i]?.[j - 1] ?? 0)) {
102
- reversed.push({
344
+ if (operation === ALIGNMENT_OPERATION.Delete) {
345
+ pushRun(runs, {
103
346
  type: "del",
104
- text: before[i - 1] ?? ""
347
+ text: before[beforeCursor] ?? ""
105
348
  });
106
- i--;
107
- } else {
108
- reversed.push({
109
- type: "ins",
110
- text: after[j - 1] ?? ""
111
- });
112
- j--;
349
+ beforeCursor++;
350
+ continue;
113
351
  }
352
+ pushRun(runs, {
353
+ type: "ins",
354
+ text: after[afterCursor] ?? ""
355
+ });
356
+ afterCursor++;
357
+ }
358
+ };
359
+ /** Factor exact boundary matches before spending the residual DP allowance. */
360
+ const alignGap = (options) => {
361
+ const { beforeKeys, afterKeys, runs } = options;
362
+ let beforeStart = options.beforeRange.start;
363
+ let afterStart = options.afterRange.start;
364
+ const beforeEnd = options.beforeRange.end;
365
+ const afterEnd = options.afterRange.end;
366
+ while (beforeStart < beforeEnd && afterStart < afterEnd && beforeKeys[beforeStart] === afterKeys[afterStart]) {
367
+ beforeStart++;
368
+ afterStart++;
114
369
  }
115
- while (i > 0) {
116
- reversed.push({
370
+ pushEqualRange(runs, options.before, options.after, {
371
+ start: options.beforeRange.start,
372
+ end: beforeStart
373
+ }, {
374
+ start: options.afterRange.start,
375
+ end: afterStart
376
+ });
377
+ let beforeMiddleEnd = beforeEnd;
378
+ let afterMiddleEnd = afterEnd;
379
+ while (beforeMiddleEnd > beforeStart && afterMiddleEnd > afterStart && beforeKeys[beforeMiddleEnd - 1] === afterKeys[afterMiddleEnd - 1]) {
380
+ beforeMiddleEnd--;
381
+ afterMiddleEnd--;
382
+ }
383
+ alignDenseGap({
384
+ ...options,
385
+ beforeRange: {
386
+ start: beforeStart,
387
+ end: beforeMiddleEnd
388
+ },
389
+ afterRange: {
390
+ start: afterStart,
391
+ end: afterMiddleEnd
392
+ }
393
+ });
394
+ pushEqualRange(runs, options.before, options.after, {
395
+ start: beforeMiddleEnd,
396
+ end: beforeEnd
397
+ }, {
398
+ start: afterMiddleEnd,
399
+ end: afterEnd
400
+ });
401
+ };
402
+ /**
403
+ * LCS alignment split at stable, unique-token anchors. Patience anchoring keeps
404
+ * repeated boilerplate from winning a tie over a unique legal term, and makes
405
+ * a small edit inside a long paragraph pay for only its changed gap.
406
+ */
407
+ const alignTokens = ({ beforeText, afterText, before, after, normalization, budget }) => {
408
+ let commonPrefixLength = 0;
409
+ let beforePrefixLength = 0;
410
+ let afterPrefixLength = 0;
411
+ while (commonPrefixLength < before.length && commonPrefixLength < after.length && keysEqual(before[commonPrefixLength] ?? "", after[commonPrefixLength] ?? "", normalization)) {
412
+ beforePrefixLength += before[commonPrefixLength]?.length ?? 0;
413
+ afterPrefixLength += after[commonPrefixLength]?.length ?? 0;
414
+ commonPrefixLength++;
415
+ }
416
+ let beforeMiddleEnd = before.length;
417
+ let afterMiddleEnd = after.length;
418
+ let beforeSuffixLength = 0;
419
+ let afterSuffixLength = 0;
420
+ while (beforeMiddleEnd > commonPrefixLength && afterMiddleEnd > commonPrefixLength && keysEqual(before[beforeMiddleEnd - 1] ?? "", after[afterMiddleEnd - 1] ?? "", normalization)) {
421
+ beforeMiddleEnd--;
422
+ afterMiddleEnd--;
423
+ beforeSuffixLength += before[beforeMiddleEnd]?.length ?? 0;
424
+ afterSuffixLength += after[afterMiddleEnd]?.length ?? 0;
425
+ }
426
+ const beforeRange = {
427
+ start: commonPrefixLength,
428
+ end: beforeMiddleEnd
429
+ };
430
+ const afterRange = {
431
+ start: commonPrefixLength,
432
+ end: afterMiddleEnd
433
+ };
434
+ const monotoneChange = classifyMonotoneChange({
435
+ before,
436
+ after,
437
+ beforeRange,
438
+ afterRange,
439
+ normalization
440
+ });
441
+ if (monotoneChange === "equivalent") return {
442
+ runs: [{
443
+ type: "equal",
444
+ before: beforeText,
445
+ after: afterText,
446
+ units: before.length
447
+ }],
448
+ monotoneDirection: null
449
+ };
450
+ const monotoneDirection = monotoneChange === "mixed" ? null : monotoneChange;
451
+ const residualTokenCount = beforeRange.end - beforeRange.start + afterRange.end - afterRange.start;
452
+ const runs = [];
453
+ const fullInputFits = fitsCellBudget(before.length, after.length, budget);
454
+ const fullInputFitsStorage = before.length + after.length <= MAX_WORD_DIFF_ANCHOR_TOKENS && beforeText.length + afterText.length <= MAX_WORD_DIFF_COMPARISON_KEY_CODE_UNITS;
455
+ const residualCodeUnitCount = beforeText.length - beforePrefixLength - beforeSuffixLength + afterText.length - afterPrefixLength - afterSuffixLength;
456
+ if (residualTokenCount > MAX_WORD_DIFF_ANCHOR_TOKENS || residualCodeUnitCount > MAX_WORD_DIFF_COMPARISON_KEY_CODE_UNITS) {
457
+ if (commonPrefixLength > 0) pushRun(runs, {
458
+ type: "equal",
459
+ before: beforeText.slice(0, beforePrefixLength),
460
+ after: afterText.slice(0, afterPrefixLength),
461
+ units: commonPrefixLength
462
+ });
463
+ if (beforePrefixLength + beforeSuffixLength < beforeText.length) pushRun(runs, {
117
464
  type: "del",
118
- text: before[i - 1] ?? ""
465
+ text: beforeText.slice(beforePrefixLength, beforeText.length - beforeSuffixLength)
119
466
  });
120
- i--;
121
- }
122
- while (j > 0) {
123
- reversed.push({
467
+ if (afterPrefixLength + afterSuffixLength < afterText.length) pushRun(runs, {
124
468
  type: "ins",
125
- text: after[j - 1] ?? ""
469
+ text: afterText.slice(afterPrefixLength, afterText.length - afterSuffixLength)
126
470
  });
127
- j--;
471
+ if (beforeSuffixLength > 0) pushRun(runs, {
472
+ type: "equal",
473
+ before: beforeText.slice(beforeText.length - beforeSuffixLength),
474
+ after: afterText.slice(afterText.length - afterSuffixLength),
475
+ units: before.length - beforeMiddleEnd
476
+ });
477
+ return {
478
+ runs,
479
+ monotoneDirection
480
+ };
128
481
  }
129
- const runs = [];
130
- for (const run of reversed.toReversed()) {
131
- const last = runs.at(-1);
132
- if (last?.type === "equal" && run.type === "equal") {
133
- last.before += run.before;
134
- last.after += run.after;
135
- last.units += run.units;
136
- continue;
137
- }
138
- if (last?.type === "del" && run.type === "del" || last?.type === "ins" && run.type === "ins") {
139
- last.text += run.text;
140
- continue;
141
- }
142
- runs.push({ ...run });
482
+ const beforeMiddle = before.slice(beforeRange.start, beforeRange.end);
483
+ const afterMiddle = after.slice(afterRange.start, afterRange.end);
484
+ const beforeKeys = beforeMiddle.map((token) => comparisonKey(token, normalization));
485
+ const afterKeys = afterMiddle.map((token) => comparisonKey(token, normalization));
486
+ const middleRangeBefore = {
487
+ start: 0,
488
+ end: beforeMiddle.length
489
+ };
490
+ const middleRangeAfter = {
491
+ start: 0,
492
+ end: afterMiddle.length
493
+ };
494
+ const anchors = findPatienceAnchors(beforeKeys, afterKeys, middleRangeBefore, middleRangeAfter);
495
+ if (anchors.length === 0 && fullInputFits && fullInputFitsStorage) {
496
+ alignDenseGap({
497
+ before,
498
+ after,
499
+ beforeKeys: before.map((token) => comparisonKey(token, normalization)),
500
+ afterKeys: after.map((token) => comparisonKey(token, normalization)),
501
+ beforeRange: {
502
+ start: 0,
503
+ end: before.length
504
+ },
505
+ afterRange: {
506
+ start: 0,
507
+ end: after.length
508
+ },
509
+ runs,
510
+ budget
511
+ });
512
+ return {
513
+ runs,
514
+ monotoneDirection
515
+ };
143
516
  }
144
- return runs;
517
+ pushEqualRange(runs, before, after, {
518
+ start: 0,
519
+ end: commonPrefixLength
520
+ }, {
521
+ start: 0,
522
+ end: commonPrefixLength
523
+ });
524
+ let beforeStart = 0;
525
+ let afterStart = 0;
526
+ for (const anchor of anchors) {
527
+ alignGap({
528
+ before: beforeMiddle,
529
+ after: afterMiddle,
530
+ beforeKeys,
531
+ afterKeys,
532
+ beforeRange: {
533
+ start: beforeStart,
534
+ end: anchor.beforeIndex
535
+ },
536
+ afterRange: {
537
+ start: afterStart,
538
+ end: anchor.afterIndex
539
+ },
540
+ runs,
541
+ budget
542
+ });
543
+ pushRun(runs, {
544
+ type: "equal",
545
+ before: beforeMiddle[anchor.beforeIndex] ?? "",
546
+ after: afterMiddle[anchor.afterIndex] ?? "",
547
+ units: 1
548
+ });
549
+ beforeStart = anchor.beforeIndex + 1;
550
+ afterStart = anchor.afterIndex + 1;
551
+ }
552
+ alignGap({
553
+ before: beforeMiddle,
554
+ after: afterMiddle,
555
+ beforeKeys,
556
+ afterKeys,
557
+ beforeRange: {
558
+ start: beforeStart,
559
+ end: beforeMiddle.length
560
+ },
561
+ afterRange: {
562
+ start: afterStart,
563
+ end: afterMiddle.length
564
+ },
565
+ runs,
566
+ budget
567
+ });
568
+ pushEqualRange(runs, before, after, {
569
+ start: beforeMiddleEnd,
570
+ end: before.length
571
+ }, {
572
+ start: afterMiddleEnd,
573
+ end: after.length
574
+ });
575
+ return {
576
+ runs,
577
+ monotoneDirection
578
+ };
145
579
  };
146
580
  /**
147
581
  * True when the surviving matches are too short, relative to the strings they
@@ -254,21 +688,76 @@ const wholeStringReplacement = (before, after) => {
254
688
  });
255
689
  return segments;
256
690
  };
257
- const diffWordSegments = (before, after, options = {}) => {
691
+ const diffWordSegmentsWithBudget = (before, after, options, budget) => {
692
+ if (before === after) return before.length === 0 ? [] : [{
693
+ type: "equal",
694
+ text: before
695
+ }];
258
696
  const granularity = options.granularity ?? "word";
259
697
  const normalization = options.normalization ?? {};
260
698
  const beforeTokens = tokenize(before, granularity);
261
699
  const afterTokens = tokenize(after, granularity);
262
700
  if (beforeTokens.length === 0 && afterTokens.length === 0) return [];
263
- if (beforeTokens.length * afterTokens.length > MAX_WORD_DIFF_CELLS) return wholeStringReplacement(before, after);
701
+ if (beforeTokens.length === 0 || afterTokens.length === 0) return wholeStringReplacement(before, after);
264
702
  const aligned = alignTokens({
703
+ beforeText: before,
704
+ afterText: after,
265
705
  before: beforeTokens,
266
706
  after: afterTokens,
267
- normalization
707
+ normalization,
708
+ budget
268
709
  });
269
- const surviving = demoteRejectedMatches(aligned);
270
- if (isTooFragmented(surviving, (before.length + after.length) / 2)) return wholeStringReplacement(before, after);
710
+ let surviving = demoteRejectedMatches(aligned.runs);
711
+ const inventedOppositeChange = aligned.monotoneDirection === "insertion" && surviving.some(({ type }) => type === "del") || aligned.monotoneDirection === "deletion" && surviving.some(({ type }) => type === "ins");
712
+ const monotoneDirection = aligned.monotoneDirection;
713
+ if (inventedOppositeChange && monotoneDirection !== null) if (beforeTokens.length + afterTokens.length <= MAX_WORD_DIFF_ANCHOR_TOKENS && before.length + after.length <= MAX_WORD_DIFF_COMPARISON_KEY_CODE_UNITS && fitsCellBudget(beforeTokens.length, afterTokens.length, budget)) {
714
+ const unanchored = [];
715
+ alignDenseGap({
716
+ before: beforeTokens,
717
+ after: afterTokens,
718
+ beforeKeys: beforeTokens.map((token) => comparisonKey(token, normalization)),
719
+ afterKeys: afterTokens.map((token) => comparisonKey(token, normalization)),
720
+ beforeRange: {
721
+ start: 0,
722
+ end: beforeTokens.length
723
+ },
724
+ afterRange: {
725
+ start: 0,
726
+ end: afterTokens.length
727
+ },
728
+ runs: unanchored,
729
+ budget
730
+ });
731
+ surviving = unanchored;
732
+ } else surviving = monotoneDirection === "insertion" && aligned.runs.some(({ type }) => type === "del") || monotoneDirection === "deletion" && aligned.runs.some(({ type }) => type === "ins") ? alignMonotoneChange(before, after, beforeTokens, afterTokens, normalization, monotoneDirection) : aligned.runs;
733
+ if (aligned.monotoneDirection === null && isTooFragmented(surviving, (before.length + after.length) / 2)) return wholeStringReplacement(before, after);
271
734
  return toSegments(orderDeletionsFirst(surviving));
272
735
  };
736
+ /**
737
+ * One internal comparison/apply scope. Every diff shares the same quadratic
738
+ * allowance; public standalone calls below still receive a fresh allowance.
739
+ * Not re-exported from a package entry point.
740
+ */
741
+ const createWordDiffSession = (options = {}) => {
742
+ const resolvedOptions = {
743
+ granularity: options.granularity ?? "word",
744
+ normalization: { ...options.normalization }
745
+ };
746
+ const budget = { remainingCells: MAX_WORD_DIFF_CELLS };
747
+ return { diff: (before, after) => diffWordSegmentsWithBudget(before, after, resolvedOptions, budget) };
748
+ };
749
+ const WORD_DIFF_SESSION = Symbol("folio.wordDiffSession");
750
+ const hasWordDiffSession = (options) => WORD_DIFF_SESSION in options;
751
+ /** Attach one internal work scope without widening a caller-facing option type. */
752
+ const createScopedWordDiffOptions = (options) => ({
753
+ ...options,
754
+ [WORD_DIFF_SESSION]: createWordDiffSession(options)
755
+ });
756
+ /** Reuse an internal scope when present; ordinary public callers get a fresh one. */
757
+ const wordDiffSessionFromOptions = (options) => {
758
+ if (options && hasWordDiffSession(options)) return options[WORD_DIFF_SESSION];
759
+ return createWordDiffSession(options);
760
+ };
761
+ const diffWordSegments = (before, after, options = {}) => diffWordSegmentsWithBudget(before, after, options, { remainingCells: MAX_WORD_DIFF_CELLS });
273
762
  //#endregion
274
- export { WORD_DIFF_GRANULARITIES, diffWordSegments };
763
+ export { WORD_DIFF_GRANULARITIES, createScopedWordDiffOptions, createWordDiffSession, diffWordSegments, wordDiffSessionFromOptions };