@ansonlai/docx-redline-js 0.1.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 (64) hide show
  1. package/AGENTS.md +176 -0
  2. package/ARCHITECTURE.md +121 -0
  3. package/LICENSE +21 -0
  4. package/README.md +177 -0
  5. package/adapters/config.js +43 -0
  6. package/adapters/logger.js +89 -0
  7. package/adapters/xml-adapter.js +74 -0
  8. package/core/list-targeting.js +398 -0
  9. package/core/ooxml-identifiers.js +15 -0
  10. package/core/paragraph-offset-policy.js +50 -0
  11. package/core/paragraph-targeting.js +501 -0
  12. package/core/table-targeting.js +233 -0
  13. package/core/types.js +204 -0
  14. package/core/xml-query.js +99 -0
  15. package/dist/docx-redline-js.esm.js +8801 -0
  16. package/dist/docx-redline-js.esm.js.map +7 -0
  17. package/dist/docx-redline-js.esm.min.js +195 -0
  18. package/dist/docx-redline-js.esm.min.js.map +7 -0
  19. package/engine/format-application.js +358 -0
  20. package/engine/format-extraction.js +232 -0
  21. package/engine/format-paragraph-targeting.js +208 -0
  22. package/engine/format-span-application.js +178 -0
  23. package/engine/formatting-removal.js +330 -0
  24. package/engine/oxml-engine.js +279 -0
  25. package/engine/reconstruction-mapper.js +270 -0
  26. package/engine/reconstruction-mode.js +38 -0
  27. package/engine/reconstruction-writer.js +276 -0
  28. package/engine/rpr-helpers.js +194 -0
  29. package/engine/run-builders.js +235 -0
  30. package/engine/surgical-mode.js +520 -0
  31. package/engine/table-cell-context.js +151 -0
  32. package/engine/table-mode.js +172 -0
  33. package/index.js +308 -0
  34. package/orchestration/list-markdown.js +141 -0
  35. package/orchestration/list-parsing.js +73 -0
  36. package/orchestration/list-structural-fallback.js +530 -0
  37. package/orchestration/redline-operation-converter.js +141 -0
  38. package/orchestration/route-plan.js +160 -0
  39. package/package.json +76 -0
  40. package/pipeline/content-analysis.js +107 -0
  41. package/pipeline/diff-engine.js +204 -0
  42. package/pipeline/ingestion-export.js +255 -0
  43. package/pipeline/ingestion-paragraph.js +351 -0
  44. package/pipeline/ingestion-table.js +169 -0
  45. package/pipeline/ingestion-xml.js +39 -0
  46. package/pipeline/ingestion.js +8 -0
  47. package/pipeline/list-generation.js +280 -0
  48. package/pipeline/list-markers.js +77 -0
  49. package/pipeline/markdown-processor.js +160 -0
  50. package/pipeline/patching.js +408 -0
  51. package/pipeline/pipeline.js +326 -0
  52. package/pipeline/serialization.js +395 -0
  53. package/services/browser-demo-prompt-context.js +345 -0
  54. package/services/comment-builders.js +60 -0
  55. package/services/comment-engine.js +248 -0
  56. package/services/comment-locator.js +197 -0
  57. package/services/comment-package.js +113 -0
  58. package/services/numbering-helpers.js +416 -0
  59. package/services/numbering-service.js +290 -0
  60. package/services/package-builder.js +147 -0
  61. package/services/standalone-docx-plumbing.js +443 -0
  62. package/services/standalone-operation-runner.js +1169 -0
  63. package/services/table-reconciliation.js +344 -0
  64. package/standalone.js +5 -0
@@ -0,0 +1,160 @@
1
+ /**
2
+ * OOXML Reconciliation Pipeline - Markdown Processor
3
+ *
4
+ * Strips markdown syntax and captures format hints for later application.
5
+ */
6
+
7
+ const MARKDOWN_PATTERNS = [
8
+ // HTML Bold: <b>text</b>, <strong>text</strong>
9
+ { regex: /<b>(.+?)<\/b>/i, format: { bold: true } },
10
+ { regex: /<strong>(.+?)<\/strong>/i, format: { bold: true } },
11
+ // HTML Italic: <i>text</i>, <em>text</em>
12
+ { regex: /<i>(.+?)<\/i>/i, format: { italic: true } },
13
+ { regex: /<em>(.+?)<\/em>/i, format: { italic: true } },
14
+ // HTML Underline: <u>text</u>
15
+ { regex: /<u>(.+?)<\/u>/i, format: { underline: true } },
16
+ // HTML Strikethrough: <s>text</s>, <strike>text</strike>, <del>text</del>
17
+ { regex: /<s>(.+?)<\/s>/i, format: { strikethrough: true } },
18
+ { regex: /<strike>(.+?)<\/strike>/i, format: { strikethrough: true } },
19
+ { regex: /<del>(.+?)<\/del>/i, format: { strikethrough: true } },
20
+
21
+ // Escaped HTML Bold: &lt;b&gt;text&lt;/b&gt;, &lt;strong&gt;text&lt;/strong&gt;
22
+ { regex: /&lt;b&gt;(.+?)&lt;\/b&gt;/i, format: { bold: true }, isEscaped: true },
23
+ { regex: /&lt;strong&gt;(.+?)&lt;\/strong&gt;/i, format: { bold: true }, isEscaped: true },
24
+ // Escaped HTML Italic: &lt;i&gt;text&lt;/i&gt;, &lt;em&gt;text&lt;/em&gt;
25
+ { regex: /&lt;i&gt;(.+?)&lt;\/i&gt;/i, format: { italic: true }, isEscaped: true },
26
+ { regex: /&lt;em&gt;(.+?)&lt;\/em&gt;/i, format: { italic: true }, isEscaped: true },
27
+ // Escaped HTML Underline: &lt;u&gt;text&lt;/u&gt;
28
+ { regex: /&lt;u&gt;(.+?)&lt;\/u&gt;/i, format: { underline: true }, isEscaped: true },
29
+ // Escaped HTML Strikethrough: &lt;s&gt;text&lt;/s&gt;
30
+ { regex: /&lt;s&gt;(.+?)&lt;\/s&gt;/i, format: { strikethrough: true }, isEscaped: true },
31
+
32
+ // Bold + Italic: ***text***
33
+ { regex: /\*\*\*(.+?)\*\*\*/, format: { bold: true, italic: true } },
34
+ // Bold + Underline: **++text++**
35
+ { regex: /\*\*\+\+(.+?)\+\+\*\*/, format: { bold: true, underline: true } },
36
+ // Bold: **text** or __text__
37
+ { regex: /\*\*(.+?)\*\*/, format: { bold: true } },
38
+ { regex: /__(.+?)__/, format: { bold: true } },
39
+ // Underline: ++text++
40
+ { regex: /\+\+(.+?)\+\+/, format: { underline: true } },
41
+ // Strikethrough: ~~text~~ or ~text~
42
+ { regex: /~~(.+?)~~/, format: { strikethrough: true } },
43
+ { regex: /~(.+?)~/, format: { strikethrough: true } },
44
+ // Italic: *text* or _text_ (using lookahead only for compatibility)
45
+ { regex: /\*(?!\*)(.+?)\*(?!\*)/, format: { italic: true } },
46
+ { regex: /_(?!_)(.+?)_(?!_)/, format: { italic: true } }
47
+ ];
48
+
49
+ /**
50
+ * Preprocesses markdown text by stripping formatting markers
51
+ * and capturing their positions as format hints.
52
+ */
53
+ export function preprocessMarkdown(text) {
54
+ if (!text) {
55
+ return { cleanText: '', formatHints: [] };
56
+ }
57
+
58
+ const formatHints = [];
59
+ let cleanText = '';
60
+
61
+ // Find all matches for all patterns
62
+ const allMatches = [];
63
+ for (const pattern of MARKDOWN_PATTERNS) {
64
+ let match;
65
+ const source = pattern.regex.source || pattern.regex.toString().replace(/^\/|\/[gimuy]*$/g, '');
66
+ const flags = 'g' + (pattern.regex.ignoreCase ? 'i' : '');
67
+ const regex = new RegExp(source, flags);
68
+
69
+ while ((match = regex.exec(text)) !== null) {
70
+ allMatches.push({
71
+ start: match.index,
72
+ end: match.index + match[0].length,
73
+ fullMatch: match[0],
74
+ innerText: pattern.isEscaped ? decodeHtmlEntities(match[1]) : match[1],
75
+ format: pattern.format
76
+ });
77
+ if (match.index === regex.lastIndex) regex.lastIndex++;
78
+ }
79
+ }
80
+
81
+ // Sort: earliest first, then longest first
82
+ allMatches.sort((a, b) => (a.start - b.start) || (b.end - a.end));
83
+
84
+ // Filter to keep only top-level matches
85
+ const topLevelMatches = [];
86
+ let lastEnd = 0;
87
+ for (const match of allMatches) {
88
+ if (match.start >= lastEnd) {
89
+ topLevelMatches.push(match);
90
+ lastEnd = match.end;
91
+ }
92
+ }
93
+
94
+ // Recursive reconstruction
95
+ let lastIndex = 0;
96
+ for (const match of topLevelMatches) {
97
+ cleanText += text.slice(lastIndex, match.start);
98
+
99
+ const subResult = preprocessMarkdown(match.innerText);
100
+
101
+ const segmentStart = cleanText.length;
102
+ cleanText += subResult.cleanText;
103
+ const segmentEnd = cleanText.length;
104
+
105
+ formatHints.push({
106
+ start: segmentStart,
107
+ end: segmentEnd,
108
+ format: match.format
109
+ });
110
+
111
+ for (const subHint of subResult.formatHints) {
112
+ formatHints.push({
113
+ start: segmentStart + subHint.start,
114
+ end: segmentStart + subHint.end,
115
+ format: subHint.format
116
+ });
117
+ }
118
+
119
+ lastIndex = match.end;
120
+ }
121
+
122
+ cleanText += text.slice(lastIndex);
123
+ return { cleanText, formatHints };
124
+ }
125
+
126
+ /**
127
+ * Checks if any format hints apply to a given offset range.
128
+ */
129
+ export function getApplicableFormatHints(formatHints, startOffset, endOffset) {
130
+ return formatHints.filter(hint =>
131
+ hint.start < endOffset && hint.end > startOffset
132
+ );
133
+ }
134
+
135
+ /**
136
+ * Merges format objects.
137
+ */
138
+ export function mergeFormats(...formats) {
139
+ const result = {};
140
+ for (const format of formats) {
141
+ if (format) {
142
+ Object.assign(result, format);
143
+ }
144
+ }
145
+ return result;
146
+ }
147
+
148
+ /**
149
+ * Decodes HTML entities in text.
150
+ */
151
+ function decodeHtmlEntities(text) {
152
+ if (!text) return '';
153
+ return text
154
+ .replace(/&amp;/g, '&')
155
+ .replace(/&lt;/g, '<')
156
+ .replace(/&gt;/g, '>')
157
+ .replace(/&quot;/g, '"')
158
+ .replace(/&#039;/g, "'");
159
+ }
160
+
@@ -0,0 +1,408 @@
1
+ /**
2
+ * OOXML Reconciliation Pipeline - Patching
3
+ *
4
+ * Splits runs at diff boundaries and applies patch operations.
5
+ */
6
+
7
+ import { DiffOp, RunKind } from '../core/types.js';
8
+ import { log } from '../adapters/logger.js';
9
+ import { matchListMarker, stripListMarker } from './list-markers.js';
10
+ import { serializeXml } from '../adapters/xml-adapter.js';
11
+
12
+ const XMLNS_ATTR_REGEX = /\s+xmlns:[^=]+="[^"]*"/g;
13
+
14
+ /**
15
+ * Splits runs at diff operation boundaries for precise patching.
16
+ *
17
+ * @param {import('../core/types.js').RunEntry[]} runModel - Original run model
18
+ * @param {import('../core/types.js').DiffOperation[]} diffOps - Diff operations
19
+ * @returns {import('../core/types.js').RunEntry[]} Split run model
20
+ */
21
+ export function splitRunsAtDiffBoundaries(runModel, diffOps) {
22
+ const boundaries = buildSortedDiffBoundaries(diffOps);
23
+ const newModel = [];
24
+ let boundaryCursor = 0;
25
+
26
+ for (const run of runModel) {
27
+ if (run.kind !== RunKind.TEXT && run.kind !== RunKind.HYPERLINK) {
28
+ newModel.push(run);
29
+ continue;
30
+ }
31
+
32
+ while (boundaryCursor < boundaries.length && boundaries[boundaryCursor] <= run.startOffset) {
33
+ boundaryCursor++;
34
+ }
35
+
36
+ let cursor = boundaryCursor;
37
+ let currentStart = run.startOffset;
38
+ let hasSplit = false;
39
+
40
+ while (cursor < boundaries.length) {
41
+ const boundary = boundaries[cursor];
42
+ if (boundary >= run.endOffset) break;
43
+
44
+ if (boundary > currentStart) {
45
+ hasSplit = true;
46
+ newModel.push({
47
+ ...run,
48
+ text: run.text.slice(currentStart - run.startOffset, boundary - run.startOffset),
49
+ startOffset: currentStart,
50
+ endOffset: boundary
51
+ });
52
+ currentStart = boundary;
53
+ }
54
+ cursor++;
55
+ }
56
+
57
+ boundaryCursor = cursor;
58
+
59
+ if (!hasSplit) {
60
+ newModel.push(run);
61
+ continue;
62
+ }
63
+
64
+ newModel.push({
65
+ ...run,
66
+ text: run.text.slice(currentStart - run.startOffset),
67
+ startOffset: currentStart,
68
+ endOffset: run.endOffset
69
+ });
70
+ }
71
+
72
+ return newModel;
73
+ }
74
+
75
+ /**
76
+ * Applies diff operations to the split run model.
77
+ *
78
+ * @param {import('../core/types.js').RunEntry[]} splitModel - Pre-split run model
79
+ * @param {import('../core/types.js').DiffOperation[]} diffOps - Diff operations
80
+ * @param {Object} options - Patching options
81
+ * @param {boolean} options.generateRedlines - Whether to generate track changes
82
+ * @param {string} options.author - Author for track changes
83
+ * @param {import('../core/types.js').FormatHint[]} [options.formatHints] - Format hints
84
+ * @returns {import('../core/types.js').RunEntry[]}
85
+ */
86
+ export function applyPatches(splitModel, diffOps, options) {
87
+ const { generateRedlines, author } = options;
88
+ const patchedModel = [];
89
+ const processedInsertions = new Set();
90
+ const diffLookup = buildPatchLookupIndex(diffOps);
91
+ const styleLookup = buildTextRunLookup(splitModel);
92
+ const getCoveringDiffOp = createRangeCursorLookup(diffLookup.nonInsertOps);
93
+ const state = {
94
+ containerStack: [],
95
+ lastParagraphStartIndex: -1,
96
+ currentParagraphPPrXml: '',
97
+ currentParagraphPPrElement: null
98
+ };
99
+
100
+ for (const run of splitModel) {
101
+ if (run.kind === RunKind.CONTAINER_START) {
102
+ state.containerStack.push(run.containerId);
103
+ patchedModel.push({ ...run });
104
+ continue;
105
+ }
106
+
107
+ if (run.kind === RunKind.CONTAINER_END) {
108
+ state.containerStack.pop();
109
+ patchedModel.push({ ...run });
110
+ continue;
111
+ }
112
+
113
+ if (run.kind === RunKind.PARAGRAPH_START) {
114
+ state.currentParagraphPPrXml = typeof run.pPrXml === 'string' ? run.pPrXml : '';
115
+ state.currentParagraphPPrElement = run.pPrElement || null;
116
+ patchedModel.push({ ...run });
117
+ state.lastParagraphStartIndex = patchedModel.length - 1;
118
+ continue;
119
+ }
120
+
121
+ if (run.kind === RunKind.BOOKMARK || run.kind === RunKind.DELETION) {
122
+ patchedModel.push({ ...run });
123
+ continue;
124
+ }
125
+
126
+ const op = getCoveringDiffOp(run.startOffset, run.endOffset);
127
+ const insertOps = diffLookup.insertOpsByStartOffset.get(run.startOffset) || [];
128
+
129
+ for (const insertOp of insertOps) {
130
+ if (processedInsertions.has(insertOp)) continue;
131
+ processedInsertions.add(insertOp);
132
+ processInsertionOperation({
133
+ insertOp,
134
+ splitModel,
135
+ styleLookup,
136
+ patchedModel,
137
+ state,
138
+ options,
139
+ generateRedlines,
140
+ author
141
+ });
142
+ }
143
+
144
+ if (!op || op.type === DiffOp.EQUAL) {
145
+ patchedModel.push({
146
+ ...run,
147
+ containerContext: state.containerStack.length > 0 ? state.containerStack[state.containerStack.length - 1] : null
148
+ });
149
+ continue;
150
+ }
151
+
152
+ if (op.type === DiffOp.DELETE && generateRedlines) {
153
+ patchedModel.push({
154
+ ...run,
155
+ kind: RunKind.DELETION,
156
+ author,
157
+ containerContext: state.containerStack.length > 0 ? state.containerStack[state.containerStack.length - 1] : null
158
+ });
159
+ }
160
+ }
161
+
162
+ const endOffset = splitModel.length > 0
163
+ ? Math.max(...splitModel.map(run => run.endOffset))
164
+ : 0;
165
+
166
+ for (const insertOp of diffLookup.sortedInsertOps) {
167
+ if (insertOp.startOffset < endOffset || processedInsertions.has(insertOp)) continue;
168
+
169
+ const lastRun = splitModel[splitModel.length - 1];
170
+ patchedModel.push({
171
+ kind: generateRedlines ? RunKind.INSERTION : RunKind.TEXT,
172
+ text: insertOp.text,
173
+ rPrXml: lastRun?.rPrXml || '',
174
+ startOffset: insertOp.startOffset,
175
+ endOffset: insertOp.startOffset + insertOp.text.length,
176
+ author: generateRedlines ? author : undefined
177
+ });
178
+ }
179
+
180
+ return patchedModel;
181
+ }
182
+
183
+ function processInsertionOperation(context) {
184
+ const {
185
+ insertOp,
186
+ splitModel,
187
+ styleLookup,
188
+ patchedModel,
189
+ state,
190
+ options,
191
+ generateRedlines,
192
+ author
193
+ } = context;
194
+
195
+ const lines = insertOp.text.split('\n');
196
+ const styleSource = chooseInsertionStyle(styleLookup, insertOp.startOffset, insertOp.text);
197
+
198
+ for (let index = 0; index < lines.length; index++) {
199
+ const parsed = parseInsertionLine(lines[index], options.numberingService, state);
200
+ const lineText = parsed.lineText;
201
+
202
+ if (index > 0) {
203
+ let newPPrXml = resolveCurrentParagraphPPrXml(state);
204
+ if (parsed.isListLine && parsed.numId) {
205
+ newPPrXml = options.numberingService.buildListPPr(parsed.numId, parsed.ilvl);
206
+ }
207
+
208
+ patchedModel.push({
209
+ kind: RunKind.PARAGRAPH_START,
210
+ pPrXml: newPPrXml,
211
+ startOffset: insertOp.startOffset,
212
+ endOffset: insertOp.startOffset,
213
+ text: ''
214
+ });
215
+ state.currentParagraphPPrXml = newPPrXml;
216
+ state.currentParagraphPPrElement = null;
217
+ state.lastParagraphStartIndex = patchedModel.length - 1;
218
+ } else if (parsed.isListLine && parsed.numId && state.lastParagraphStartIndex >= 0) {
219
+ const newPPrXml = options.numberingService.buildListPPr(parsed.numId, parsed.ilvl);
220
+ patchedModel[state.lastParagraphStartIndex].pPrXml = newPPrXml;
221
+ patchedModel[state.lastParagraphStartIndex].pPrElement = null;
222
+ state.currentParagraphPPrXml = newPPrXml;
223
+ state.currentParagraphPPrElement = null;
224
+ log(`[Patching] Converted current paragraph to list item: numId=${parsed.numId}, ilvl=${parsed.ilvl}`);
225
+ }
226
+
227
+ if (lineText.length > 0 || index > 0) {
228
+ patchedModel.push({
229
+ kind: generateRedlines ? RunKind.INSERTION : RunKind.TEXT,
230
+ text: lineText,
231
+ rPrXml: styleSource?.rPrXml || '',
232
+ startOffset: insertOp.startOffset,
233
+ endOffset: insertOp.startOffset + lineText.length,
234
+ author: generateRedlines ? author : undefined,
235
+ containerContext: state.containerStack.length > 0 ? state.containerStack[state.containerStack.length - 1] : null
236
+ });
237
+ }
238
+ }
239
+ }
240
+
241
+ function parseInsertionLine(line, numberingService, state) {
242
+ if (!numberingService) {
243
+ return { lineText: line, isListLine: false, numId: null, ilvl: 0 };
244
+ }
245
+
246
+ const markerMatch = matchListMarker(line, { allowZeroSpaceAfterMarker: true });
247
+ if (!markerMatch) {
248
+ return { lineText: line, isListLine: false, numId: null, ilvl: 0 };
249
+ }
250
+
251
+ const marker = markerMatch[2].trim();
252
+ const lineFormat = numberingService.detectNumberingFormat(marker);
253
+ const indentMatch = line.match(/^(\s*)/);
254
+ const indentSize = indentMatch ? indentMatch[1].length : 0;
255
+ const indentStep = indentSize >= 4 ? 4 : 2;
256
+ const indentLevel = Math.floor(indentSize / indentStep);
257
+
258
+ const currentPPrXml = resolveCurrentParagraphPPrXml(state);
259
+ const lineText = stripListMarker(line, { allowZeroSpaceAfterMarker: true });
260
+ const numIdMatch = currentPPrXml.match(/w:numId w:val="(\d+)"/);
261
+ const ilvlMatch = currentPPrXml.match(/w:ilvl w:val="(\d+)"/);
262
+ const contextNumId = numIdMatch ? numIdMatch[1] : null;
263
+ const contextIlvl = ilvlMatch ? parseInt(ilvlMatch[1], 10) : 0;
264
+
265
+ const numId = numberingService.getOrCreateNumId(
266
+ { type: lineFormat.format },
267
+ { numId: contextNumId, type: 'unknown' }
268
+ );
269
+
270
+ const ilvl = lineFormat.format === 'outline'
271
+ ? Math.min(8, lineFormat.depth)
272
+ : Math.min(8, indentLevel + contextIlvl);
273
+
274
+ return { lineText, isListLine: true, numId, ilvl };
275
+ }
276
+
277
+ function resolveCurrentParagraphPPrXml(state) {
278
+ if (state.currentParagraphPPrXml) {
279
+ return state.currentParagraphPPrXml;
280
+ }
281
+
282
+ if (!state.currentParagraphPPrElement) {
283
+ return '';
284
+ }
285
+
286
+ state.currentParagraphPPrXml = serializeXml(state.currentParagraphPPrElement).replace(XMLNS_ATTR_REGEX, '');
287
+ return state.currentParagraphPPrXml;
288
+ }
289
+
290
+ function chooseInsertionStyle(styleLookup, offset, insertText) {
291
+ const prevRun = styleLookup.findRunBefore(offset);
292
+ const nextRun = styleLookup.findRunAfter(offset);
293
+
294
+ if (!prevRun && !nextRun) return null;
295
+ if (!prevRun) return nextRun;
296
+ if (!nextRun) return prevRun;
297
+
298
+ if (insertText && insertText.endsWith(' ')) return nextRun;
299
+ if (insertText && insertText.startsWith(' ')) return prevRun;
300
+
301
+ return prevRun;
302
+ }
303
+
304
+ function buildTextRunLookup(runModel) {
305
+ const textRuns = runModel.filter(run => run.kind === RunKind.TEXT);
306
+ const starts = textRuns.map(run => run.startOffset);
307
+ const ends = textRuns.map(run => run.endOffset);
308
+
309
+ return {
310
+ findRunBefore(offset) {
311
+ let left = 0;
312
+ let right = ends.length - 1;
313
+ let answer = -1;
314
+
315
+ while (left <= right) {
316
+ const middle = (left + right) >> 1;
317
+ if (ends[middle] <= offset) {
318
+ answer = middle;
319
+ left = middle + 1;
320
+ } else {
321
+ right = middle - 1;
322
+ }
323
+ }
324
+
325
+ return answer >= 0 ? textRuns[answer] : null;
326
+ },
327
+
328
+ findRunAfter(offset) {
329
+ let left = 0;
330
+ let right = starts.length - 1;
331
+ let answer = -1;
332
+
333
+ while (left <= right) {
334
+ const middle = (left + right) >> 1;
335
+ if (starts[middle] >= offset) {
336
+ answer = middle;
337
+ right = middle - 1;
338
+ } else {
339
+ left = middle + 1;
340
+ }
341
+ }
342
+
343
+ return answer >= 0 ? textRuns[answer] : null;
344
+ }
345
+ };
346
+ }
347
+
348
+ function createRangeCursorLookup(operations) {
349
+ let cursor = 0;
350
+ return (startOffset, endOffset) => {
351
+ while (cursor < operations.length && operations[cursor].endOffset <= startOffset) {
352
+ cursor++;
353
+ }
354
+
355
+ const operation = operations[cursor];
356
+ if (!operation) return null;
357
+ if (operation.startOffset <= startOffset && operation.endOffset >= endOffset) {
358
+ return operation;
359
+ }
360
+ return null;
361
+ };
362
+ }
363
+
364
+ function buildSortedDiffBoundaries(diffOps) {
365
+ const unique = new Set();
366
+ for (const op of diffOps) {
367
+ unique.add(op.startOffset);
368
+ unique.add(op.endOffset);
369
+ }
370
+ return Array.from(unique).sort((a, b) => a - b);
371
+ }
372
+
373
+ /**
374
+ * Builds indexed diff lookups for patching hot paths.
375
+ *
376
+ * @param {import('../core/types.js').DiffOperation[]} diffOps - Diff operations
377
+ * @returns {{
378
+ * insertOpsByStartOffset: Map<number, import('../core/types.js').DiffOperation[]>,
379
+ * nonInsertOps: import('../core/types.js').DiffOperation[],
380
+ * sortedInsertOps: import('../core/types.js').DiffOperation[]
381
+ * }}
382
+ */
383
+ function buildPatchLookupIndex(diffOps) {
384
+ const insertOpsByStartOffset = new Map();
385
+ const nonInsertOps = [];
386
+ const sortedInsertOps = [];
387
+
388
+ for (const op of diffOps) {
389
+ if (op.type === DiffOp.INSERT) {
390
+ if (!insertOpsByStartOffset.has(op.startOffset)) {
391
+ insertOpsByStartOffset.set(op.startOffset, []);
392
+ }
393
+ insertOpsByStartOffset.get(op.startOffset).push(op);
394
+ sortedInsertOps.push(op);
395
+ continue;
396
+ }
397
+ nonInsertOps.push(op);
398
+ }
399
+
400
+ nonInsertOps.sort((a, b) => a.startOffset - b.startOffset || a.endOffset - b.endOffset);
401
+ sortedInsertOps.sort((a, b) => a.startOffset - b.startOffset || a.endOffset - b.endOffset);
402
+
403
+ return {
404
+ insertOpsByStartOffset,
405
+ nonInsertOps,
406
+ sortedInsertOps
407
+ };
408
+ }