@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.
- package/dist/ai-edits/apply.js +370 -118
- package/dist/ai-edits/headless.js +16 -10
- package/dist/ai-edits/index.d.ts +2 -2
- package/dist/ai-edits/index.js +2 -2
- package/dist/ai-edits/snapshot.js +6 -1
- package/dist/ai-edits/types.d.ts +19 -6
- package/dist/ai-edits/word-diff.d.ts +18 -3
- package/dist/ai-edits/word-diff.js +556 -67
- package/dist/compare/compare.js +7 -16
- package/dist/compare/plan.js +8 -5
- package/dist/compare/verification.d.ts +8 -9
- package/dist/compare/verification.js +51 -46
- package/dist/compat/eigenpal.d.ts +2 -2
- package/dist/compat/eigenpal.js +2 -2
- package/dist/controller/headerFooterEditorManager.js +5 -5
- package/dist/controller/hiddenEditorApi.js +1 -1
- package/dist/controller/noteEditorManager.js +2 -2
- package/dist/document-operations.d.ts +5 -3
- package/dist/document-operations.js +32 -5
- package/dist/docx/commentParser.js +2 -4
- package/dist/docx/commentRangeIntegrity.js +2 -4
- package/dist/docx/documentClone.d.ts +2 -0
- package/dist/docx/documentClone.js +2 -0
- package/dist/docx/headerFooterParser.js +2 -4
- package/dist/docx/normalizeBaseDirection.js +5 -7
- package/dist/docx/paragraphParser.js +27 -29
- package/dist/docx/paragraphPropertySource.d.ts +49 -0
- package/dist/docx/paragraphPropertySource.js +113 -0
- package/dist/docx/runConsolidator.js +2 -4
- package/dist/docx/runParser.js +2 -15
- package/dist/docx/sectionParser.js +1 -15
- package/dist/docx/serializer/paragraphSerializer.js +287 -30
- package/dist/docx/serializer/runSerializer.js +2 -10
- package/dist/docx/serializer/sectionPropertiesSerializer.js +2 -10
- package/dist/docx/serializer/tableSerializer.js +4 -23
- package/dist/docx/serializer/trackedChangeAttributes.d.ts +9 -0
- package/dist/docx/serializer/trackedChangeAttributes.js +21 -0
- package/dist/docx/server/createBilingualDocument.js +8 -10
- package/dist/docx/server/evaluateDocxXmlPatchProposal.js +22 -13
- package/dist/docx/server/validateDocxConformance.js +17 -13
- package/dist/docx/tableParser.js +1 -37
- package/dist/docx/trackedChangeInfo.d.ts +11 -0
- package/dist/docx/trackedChangeInfo.js +38 -0
- package/dist/docx/verbatimCapture.d.ts +20 -9
- package/dist/docx/verbatimCapture.js +208 -8
- package/dist/docx/xmlParser.d.ts +3 -1
- package/dist/docx/xmlParser.js +6 -1
- package/dist/docx/xmlSafety.d.ts +1 -1
- package/dist/docx/xmlSafety.js +157 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/layout-bridge/convert/headerFooterLayout.js +4 -4
- package/dist/layout-bridge/convert/toFlowBlocks.js +12 -7
- package/dist/markdown/renderBlock.js +7 -10
- package/dist/prosemirror/attrs/index.js +15 -0
- package/dist/prosemirror/commands/comments.js +365 -32
- package/dist/prosemirror/commands/pageBreak.js +1 -1
- package/dist/prosemirror/commands/propertyChangeScope.d.ts +26 -4
- package/dist/prosemirror/commands/propertyChangeScope.js +65 -3
- package/dist/prosemirror/conversion/fromProseDoc.d.ts +2 -4
- package/dist/prosemirror/conversion/fromProseDoc.js +130 -18
- package/dist/prosemirror/conversion/toProseDoc.js +27 -12
- package/dist/prosemirror/extensions/core/ParagraphExtension.js +48 -4
- package/dist/prosemirror/extensions/features/AutoBidiDetectionExtension.js +7 -1
- package/dist/prosemirror/extensions/features/ParaIdAllocatorExtension.js +20 -7
- package/dist/prosemirror/extensions/marks/TrackedChangeExtensions.js +2 -0
- package/dist/prosemirror/paragraphAlignment.d.ts +12 -0
- package/dist/prosemirror/paragraphAlignment.js +13 -0
- package/dist/prosemirror/plugins/suggestionMode.js +1 -1
- package/dist/prosemirror/schema/marks.d.ts +2 -0
- package/dist/prosemirror/schema/nodes.d.ts +12 -1
- package/dist/prosemirror/styles/resolvedStyleAttrs.js +1 -0
- package/dist/redline.js +4 -1
- package/dist/server.d.ts +2 -2
- package/dist/server.js +2 -2
- package/dist/utils/mergeDocumentContent.js +3 -3
- package/dist/utils/replaceText.js +2 -1
- package/dist/version-comparison.js +6 -4
- package/dist/watermark/index.js +2 -4
- 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
|
|
67
|
-
* attacker-controlled document text (a
|
|
68
|
-
* unbounded pair of large strings would
|
|
69
|
-
*
|
|
70
|
-
*
|
|
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
|
-
/**
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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[
|
|
94
|
-
after: after[
|
|
336
|
+
before: before[beforeCursor] ?? "",
|
|
337
|
+
after: after[afterCursor] ?? "",
|
|
95
338
|
units: 1
|
|
96
339
|
});
|
|
97
|
-
|
|
98
|
-
|
|
340
|
+
beforeCursor++;
|
|
341
|
+
afterCursor++;
|
|
99
342
|
continue;
|
|
100
343
|
}
|
|
101
|
-
if (
|
|
102
|
-
|
|
344
|
+
if (operation === ALIGNMENT_OPERATION.Delete) {
|
|
345
|
+
pushRun(runs, {
|
|
103
346
|
type: "del",
|
|
104
|
-
text: before[
|
|
347
|
+
text: before[beforeCursor] ?? ""
|
|
105
348
|
});
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
116
|
-
|
|
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:
|
|
465
|
+
text: beforeText.slice(beforePrefixLength, beforeText.length - beforeSuffixLength)
|
|
119
466
|
});
|
|
120
|
-
|
|
121
|
-
}
|
|
122
|
-
while (j > 0) {
|
|
123
|
-
reversed.push({
|
|
467
|
+
if (afterPrefixLength + afterSuffixLength < afterText.length) pushRun(runs, {
|
|
124
468
|
type: "ins",
|
|
125
|
-
text:
|
|
469
|
+
text: afterText.slice(afterPrefixLength, afterText.length - afterSuffixLength)
|
|
126
470
|
});
|
|
127
|
-
|
|
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
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
270
|
-
|
|
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 };
|