@ansonlai/docx-redline-js 0.8.3 → 0.9.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 (71) hide show
  1. package/ARCHITECTURE.md +221 -12
  2. package/CHANGELOG.md +803 -586
  3. package/README.md +1018 -901
  4. package/core/current-properties.js +136 -0
  5. package/core/paragraph-text.js +211 -209
  6. package/core/types.js +9 -3
  7. package/core/word-revision-semantics.js +256 -0
  8. package/dist/docx-redline-js.esm.js +4551 -967
  9. package/dist/docx-redline-js.esm.js.map +4 -4
  10. package/dist/docx-redline-js.esm.min.js +112 -106
  11. package/dist/docx-redline-js.esm.min.js.map +4 -4
  12. package/dist/docx-redline.bundle.cjs +4690 -1106
  13. package/dist/docx-redline.bundle.cjs.map +4 -4
  14. package/dist/docx-redline.bundle.js +4690 -1106
  15. package/dist/docx-redline.bundle.js.map +4 -4
  16. package/docs/AGENT_FAST_START.md +2 -0
  17. package/docs/AGENT_KNOWLEDGE_BASE.md +23 -4
  18. package/docs/SKILL_AUTHORING.md +165 -150
  19. package/docs/TESTING.md +1142 -1059
  20. package/docs/schemas/document-operations.schema.json +221 -168
  21. package/document/docx-document.js +1022 -1010
  22. package/engine/oxml-engine.js +567 -560
  23. package/engine/paragraph-mark-revisions.js +292 -0
  24. package/engine/reconstruction-mapper.js +312 -311
  25. package/engine/reconstruction-mode.js +183 -183
  26. package/engine/reconstruction-writer.js +67 -10
  27. package/engine/rpr-helpers.js +42 -0
  28. package/engine/run-builders.js +0 -73
  29. package/engine/surgical-diff-application.js +604 -605
  30. package/engine/table-mode.js +63 -63
  31. package/index.d.ts +51 -1
  32. package/index.js +47 -3
  33. package/node/cli-help.js +1 -0
  34. package/node/cli.js +992 -974
  35. package/orchestration/list-structural-fallback.js +67 -9
  36. package/package.json +1 -1
  37. package/pipeline/diff-engine.js +130 -1
  38. package/pipeline/ingestion-export.js +227 -243
  39. package/pipeline/ingestion-paragraph.js +93 -98
  40. package/pipeline/list-generation.js +415 -419
  41. package/pipeline/pipeline.js +206 -204
  42. package/pipeline/serialization.js +111 -94
  43. package/scripts/export-validation-fixtures.mjs +19 -6
  44. package/scripts/generate-word-revision-semantics-matrix.mjs +134 -0
  45. package/scripts/lib/word-coverage-catalogue.mjs +7 -2
  46. package/scripts/render-multilevel-bullet-visual.ps1 +2 -2
  47. package/scripts/word-com-corpus-visual-suite.ps1 +2 -2
  48. package/scripts/word-com-differential.ps1 +443 -335
  49. package/scripts/word-com-visual-suite.ps1 +2 -2
  50. package/scripts/word-revision-semantics-matrix.ps1 +108 -0
  51. package/services/batch-operation-orchestrator.js +26 -0
  52. package/services/capture-engine.js +228 -226
  53. package/services/document-operation-applier.js +215 -0
  54. package/services/document-operation-contract.js +628 -537
  55. package/services/document-operation-mutations.js +789 -46
  56. package/services/error-recovery.js +17 -0
  57. package/services/list-conversion-diagnostics.js +296 -0
  58. package/services/list-format-change.js +586 -0
  59. package/services/list-format-mutation.js +416 -0
  60. package/services/list-format.js +305 -0
  61. package/services/numbering-allocation.js +642 -0
  62. package/services/numbering-helpers.js +354 -524
  63. package/services/operation-batch-compiler.js +612 -436
  64. package/services/operation-heuristics.js +197 -173
  65. package/services/operation-preflight.js +1 -0
  66. package/services/receipt-collector.js +288 -288
  67. package/services/revision-comment-management.js +634 -594
  68. package/services/revision-fidelity-verifier.js +498 -0
  69. package/services/standalone-docx-plumbing.js +627 -631
  70. package/services/standalone-operation-runner.d.ts +606 -427
  71. package/services/table-reconciliation.js +103 -49
@@ -1,605 +1,604 @@
1
- import { getApplicableFormatHints } from '../pipeline/markdown-processor.js';
2
- import {
3
- createTrackChange,
4
- createTextRun,
5
- createFormattedRuns,
6
- createTextRunWithRPrElement,
7
- injectFormattingToRPr
8
- } from './run-builders.js';
9
- import { createRevisionMetadata } from '../core/types.js';
10
- import {
11
- createRunFromPieces,
12
- getRunContentPieces,
13
- getRunTextLength,
14
- insertRunPiecesBefore,
15
- sliceRunPieces,
16
- splitTrackChangeCarrier
17
- } from './surgical-run-splitting.js';
18
- import {
19
- findContainingSpan,
20
- findFirstSpanEndingAt,
21
- findLastSpanEndingBeforeOrAt,
22
- forEachOverlappingSpan,
23
- describeInsertionBoundary
24
- } from './surgical-spans.js';
25
- import { extractFormatFromRPr } from './rpr-helpers.js';
26
- import { isWordElement } from '../core/word-xml.js';
27
- import { NS_W } from '../core/types.js';
28
-
29
- export function reconcileFormattingForTextSpan(xmlDoc, span, start, end, applicableHints, author, generateRedlines) {
30
- // Plain modified text carries no negative formatting instruction. Preserve
31
- // unchanged source formatting unless Markdown explicitly targets this span.
32
- if (applicableHints.length === 0) return false;
33
-
34
- const rPr = span.rPr;
35
- const existingFormat = extractFormatFromRPr(rPr);
36
- const desiredFormat = { ...existingFormat };
37
- applicableHints.forEach(h => Object.assign(desiredFormat, h.format));
38
-
39
- const formatsToCheck = ['bold', 'italic', 'underline', 'strikethrough'];
40
- const changesNeeded = formatsToCheck.some(f => !!desiredFormat[f] !== existingFormat[f]);
41
-
42
- if (!changesNeeded) return false;
43
-
44
- const parent = span.runElement.parentNode;
45
- if (!parent) return false;
46
-
47
- const fullText = span.textElement.textContent || '';
48
- const runStart = span.charStart;
49
-
50
- const localStart = start - runStart;
51
- const localEnd = end - runStart;
52
-
53
- const beforeText = fullText.substring(0, localStart);
54
- const affectedText = fullText.substring(localStart, localEnd);
55
- const afterText = fullText.substring(localEnd);
56
-
57
- if (beforeText.length > 0) {
58
- const beforeRun = createTextRun(xmlDoc, beforeText, rPr, false);
59
- parent.insertBefore(beforeRun, span.runElement);
60
- }
61
-
62
- const newRPr = injectFormattingToRPr(xmlDoc, rPr, desiredFormat, author, generateRedlines);
63
- const newRun = createTextRunWithRPrElement(xmlDoc, affectedText, newRPr, false);
64
- parent.insertBefore(newRun, span.runElement);
65
-
66
- if (afterText.length > 0) {
67
- const afterRun = createTextRun(xmlDoc, afterText, rPr, false);
68
- parent.insertBefore(afterRun, span.runElement);
69
- }
70
-
71
- parent.removeChild(span.runElement);
72
- return true;
73
- }
74
-
75
- export function processDelete(xmlDoc, spanIndex, startPos, endPos, author, generateRedlines, revisionMetadata = null) {
76
- const spans = [];
77
- forEachOverlappingSpan(spanIndex, startPos, endPos, span => {
78
- spans.push(span);
79
- });
80
-
81
- if (spans.length === 0) return false;
82
-
83
- const spansByRun = new Map();
84
- spans.forEach(span => {
85
- if (!span.runElement?.parentNode) return;
86
- if (!spansByRun.has(span.runElement)) spansByRun.set(span.runElement, []);
87
- spansByRun.get(span.runElement).push(span);
88
- });
89
-
90
- const records = [];
91
- spansByRun.forEach((runSpans, runElement) => {
92
- const parent = runElement.parentNode;
93
- if (!parent) return;
94
-
95
- const pieces = getRunContentPieces(runElement);
96
- if (pieces.length === 0) return;
97
-
98
- let deleteStart = Infinity;
99
- let deleteEnd = -Infinity;
100
- runSpans.forEach(span => {
101
- const piece = pieces.find(candidate => candidate.node === span.textElement);
102
- if (!piece) return;
103
-
104
- const spanDeleteStart = Math.max(0, startPos - span.charStart);
105
- const spanDeleteEnd = Math.min(span.charEnd - span.charStart, endPos - span.charStart);
106
- if (spanDeleteEnd <= spanDeleteStart) return;
107
-
108
- deleteStart = Math.min(deleteStart, piece.start + spanDeleteStart);
109
- deleteEnd = Math.max(deleteEnd, piece.start + spanDeleteEnd);
110
- });
111
-
112
- if (!Number.isFinite(deleteStart) || deleteEnd <= deleteStart) return;
113
-
114
- records.push({
115
- runElement,
116
- parent,
117
- rPr: runSpans[0].rPr,
118
- beforePieces: sliceRunPieces(xmlDoc, pieces, 0, deleteStart, false),
119
- deletedPieces: sliceRunPieces(xmlDoc, pieces, deleteStart, deleteEnd, true),
120
- afterPieces: sliceRunPieces(xmlDoc, pieces, deleteEnd, getRunTextLength(pieces), false),
121
- globalStart: Math.max(startPos, Math.min(...runSpans.map(span => span.charStart))),
122
- globalEnd: Math.min(endPos, Math.max(...runSpans.map(span => span.charEnd))),
123
- carrierGlobalStart: isWordElement(parent, 'ins') ? getCarrierGlobalStart(spanIndex, parent) : null
124
- });
125
- });
126
-
127
- const groups = [];
128
- for (const record of records) {
129
- const previousGroup = groups[groups.length - 1];
130
- const previousRecord = previousGroup?.[previousGroup.length - 1];
131
- if (
132
- previousRecord
133
- && previousRecord.parent === record.parent
134
- && nextElementSibling(previousRecord.runElement) === record.runElement
135
- ) {
136
- previousGroup.push(record);
137
- } else {
138
- groups.push([record]);
139
- }
140
- }
141
-
142
- let changed = false;
143
- let usedDelMetadata = false;
144
- for (const group of groups) {
145
- const firstRecord = group[0];
146
- let delWrapper = null;
147
- if (generateRedlines && group.some(record => record.deletedPieces.length > 0)) {
148
- const metadata = revisionMetadata
149
- ? (usedDelMetadata ? { ...revisionMetadata, id: createRevisionMetadata(author, xmlDoc, 'del').id } : revisionMetadata)
150
- : null;
151
- usedDelMetadata = true;
152
- delWrapper = createTrackChange(xmlDoc, 'del', null, author, metadata);
153
- }
154
-
155
- for (const record of group) {
156
- const { parent, runElement } = record;
157
- insertRunPiecesBefore(xmlDoc, parent, runElement, record.beforePieces, record.rPr);
158
- if (delWrapper && record === firstRecord) {
159
- parent.insertBefore(delWrapper, runElement);
160
- }
161
- if (delWrapper && record.deletedPieces.length > 0) {
162
- delWrapper.appendChild(createRunFromPieces(xmlDoc, record.deletedPieces, record.rPr));
163
- }
164
- const afterRun = insertRunPiecesBefore(xmlDoc, parent, runElement, record.afterPieces, record.rPr);
165
- if (
166
- record.globalEnd === endPos
167
- && !isWordElement(parent, 'ins')
168
- ) {
169
- if (!spanIndex.replacementInsertionAnchors) spanIndex.replacementInsertionAnchors = new Map();
170
- spanIndex.replacementInsertionAnchors.set(endPos, {
171
- parent,
172
- referenceNode: afterRun || runElement.nextSibling,
173
- rPr: record.rPr
174
- });
175
- }
176
- parent.removeChild(runElement);
177
- changed = true;
178
- }
179
-
180
- const carrier = isWordElement(firstRecord.parent, 'ins') ? firstRecord.parent : null;
181
- const groupEnd = Math.max(...group.map(record => record.globalEnd));
182
- if (carrier && groupEnd === endPos) {
183
- const carrierStart = firstRecord.carrierGlobalStart;
184
- const deletedBeforeEnd = group
185
- .filter(record => record.globalStart < endPos)
186
- .reduce((sum, record) => sum + record.deletedPieces.reduce((n, piece) => n + (piece.textContent || '').length, 0), 0);
187
- const currentOffset = Math.max(0, endPos - carrierStart - deletedBeforeEnd);
188
- if (!spanIndex.revisionInsertionAnchors) spanIndex.revisionInsertionAnchors = new Map();
189
- spanIndex.revisionInsertionAnchors.set(endPos, {
190
- carrier,
191
- splitOffset: currentOffset,
192
- rPr: firstRecord.rPr
193
- });
194
- }
195
- }
196
-
197
- return changed;
198
- }
199
-
200
- export function processInsert(xmlDoc, spanIndex, pos, text, author, formatHints = [], insertOffset = 0, generateRedlines = true, fallbackParagraph = null, revisionMetadata = null, affinity = null, existingRevisions = 'merge-same-author') {
201
- const mutationAnchor = spanIndex.revisionInsertionAnchors?.get(pos) || null;
202
- if (
203
- mutationAnchor
204
- && existingRevisions === 'slice-cross-author'
205
- && isConnected(mutationAnchor.carrier)
206
- && isForeignInsertion(mutationAnchor.carrier, author)
207
- ) {
208
- spanIndex.revisionInsertionAnchors.delete(pos);
209
- return spliceInsertionAtCarrierOffset(
210
- xmlDoc,
211
- mutationAnchor.carrier,
212
- mutationAnchor.splitOffset,
213
- text,
214
- mutationAnchor.rPr,
215
- author,
216
- formatHints,
217
- insertOffset,
218
- generateRedlines,
219
- revisionMetadata
220
- );
221
- }
222
-
223
- const replacementAnchor = spanIndex.replacementInsertionAnchors?.get(pos) || null;
224
- if (
225
- replacementAnchor
226
- && !affinity
227
- && isConnected(replacementAnchor.parent)
228
- && (!replacementAnchor.referenceNode || replacementAnchor.referenceNode.parentNode === replacementAnchor.parent)
229
- ) {
230
- spanIndex.replacementInsertionAnchors.delete(pos);
231
- insertTextRuns(
232
- xmlDoc,
233
- replacementAnchor.parent,
234
- replacementAnchor.referenceNode,
235
- text,
236
- replacementAnchor.rPr,
237
- author,
238
- formatHints,
239
- insertOffset,
240
- generateRedlines,
241
- revisionMetadata
242
- );
243
- return true;
244
- }
245
-
246
- if (!affinity) {
247
- let targetSpan = findContainingSpan(spanIndex, pos);
248
-
249
- if (!targetSpan && pos > 0) {
250
- targetSpan = findFirstSpanEndingAt(spanIndex, pos);
251
- }
252
-
253
- if (!targetSpan && pos > 0) {
254
- targetSpan = findLastSpanEndingBeforeOrAt(spanIndex, pos);
255
- }
256
-
257
- if (!targetSpan && spanIndex.spans.length > 0) {
258
- targetSpan = spanIndex.spans[spanIndex.spans.length - 1];
259
- }
260
-
261
- if (!targetSpan) {
262
- if (!fallbackParagraph) return false;
263
- insertTextRuns(xmlDoc, fallbackParagraph, null, text, null, author, formatHints, insertOffset, generateRedlines, revisionMetadata);
264
- return true;
265
- }
266
-
267
- const parent = targetSpan.runElement.parentNode;
268
- if (!parent) {
269
- if (!fallbackParagraph) return false;
270
- insertTextRuns(xmlDoc, fallbackParagraph, null, text, targetSpan.rPr, author, formatHints, insertOffset, generateRedlines, revisionMetadata);
271
- return true;
272
- }
273
-
274
- const generateNestedRevision = !(
275
- generateRedlines
276
- && existingRevisions === 'slice-cross-author'
277
- && isSameAuthorInsertion(parent, author)
278
- );
279
-
280
- if (
281
- generateRedlines
282
- && existingRevisions === 'slice-cross-author'
283
- && isForeignInsertion(parent, author)
284
- ) {
285
- return spliceInsertionAtCarrierOffset(
286
- xmlDoc,
287
- parent,
288
- getCarrierSplitOffset(spanIndex, parent, pos),
289
- text,
290
- targetSpan.rPr,
291
- author,
292
- formatHints,
293
- insertOffset,
294
- generateRedlines,
295
- revisionMetadata
296
- );
297
- }
298
-
299
- const pieces = getRunContentPieces(targetSpan.runElement);
300
- const targetPiece = pieces.find(piece => piece.node === targetSpan.textElement);
301
- const localInsertPos = targetPiece
302
- ? targetPiece.start + Math.max(0, Math.min(pos - targetSpan.charStart, targetSpan.charEnd - targetSpan.charStart))
303
- : (pos <= targetSpan.charStart ? 0 : getRunTextLength(pieces));
304
-
305
- if (localInsertPos > 0 && localInsertPos < getRunTextLength(pieces)) {
306
- const beforePieces = sliceRunPieces(xmlDoc, pieces, 0, localInsertPos, false);
307
- const afterPieces = sliceRunPieces(xmlDoc, pieces, localInsertPos, getRunTextLength(pieces), false);
308
-
309
- insertRunPiecesBefore(xmlDoc, parent, targetSpan.runElement, beforePieces, targetSpan.rPr);
310
- insertTextRuns(xmlDoc, parent, targetSpan.runElement, text, targetSpan.rPr, author, formatHints, insertOffset, generateNestedRevision, revisionMetadata);
311
- insertRunPiecesBefore(xmlDoc, parent, targetSpan.runElement, afterPieces, targetSpan.rPr);
312
- parent.removeChild(targetSpan.runElement);
313
- return true;
314
- }
315
-
316
- const referenceNode = pos <= targetSpan.charStart ? targetSpan.runElement : targetSpan.runElement.nextSibling;
317
- insertTextRuns(xmlDoc, parent, referenceNode, text, targetSpan.rPr, author, formatHints, insertOffset, generateNestedRevision, revisionMetadata);
318
- return true;
319
- }
320
-
321
- const boundary = describeInsertionBoundary(spanIndex, pos, fallbackParagraph);
322
-
323
- // Validate hyperlink affinity
324
- const isLeftInHyperlink = boundary.leftSpan && isWordElement(boundary.leftSpan.runElement?.parentNode, 'hyperlink');
325
- const isRightInHyperlink = boundary.rightSpan && isWordElement(boundary.rightSpan.runElement?.parentNode, 'hyperlink');
326
- const isContainingInHyperlink = boundary.containingSpan && isWordElement(boundary.containingSpan.runElement?.parentNode, 'hyperlink');
327
-
328
- if (affinity.hyperlink === 'outside') {
329
- if (boundary.isInterior && isContainingInHyperlink) {
330
- return {
331
- error: {
332
- code: 'UNSUPPORTED_INSERTION_AFFINITY',
333
- message: 'Cannot place insertion outside hyperlink from strictly interior position.'
334
- }
335
- };
336
- }
337
- } else if (affinity.hyperlink === 'inside') {
338
- if (!isLeftInHyperlink && !isRightInHyperlink && !isContainingInHyperlink) {
339
- return {
340
- error: {
341
- code: 'UNSUPPORTED_INSERTION_AFFINITY',
342
- message: 'Cannot place insertion inside hyperlink when no hyperlink is present at boundary.'
343
- }
344
- };
345
- }
346
- }
347
-
348
- // Determine formatting (baseRPr)
349
- let baseRPr = null;
350
- if (affinity.formatting === 'none') {
351
- baseRPr = null;
352
- } else if (affinity.formatting === 'right') {
353
- baseRPr = boundary.rightSpan?.rPr || null;
354
- } else if (affinity.formatting === 'left') {
355
- baseRPr = boundary.leftSpan?.rPr || null;
356
- } else {
357
- baseRPr = (boundary.containingSpan || boundary.leftSpan || boundary.rightSpan)?.rPr || null;
358
- }
359
-
360
- // Check interior of a run
361
- if (boundary.isInterior) {
362
- const targetSpan = boundary.containingSpan;
363
- const parent = targetSpan.runElement.parentNode || fallbackParagraph;
364
- if (!parent) return false;
365
-
366
- const pieces = getRunContentPieces(targetSpan.runElement);
367
- const targetPiece = pieces.find(piece => piece.node === targetSpan.textElement);
368
- const localInsertPos = targetPiece
369
- ? targetPiece.start + Math.max(0, Math.min(pos - targetSpan.charStart, targetSpan.charEnd - targetSpan.charStart))
370
- : (pos <= targetSpan.charStart ? 0 : getRunTextLength(pieces));
371
-
372
- if (localInsertPos > 0 && localInsertPos < getRunTextLength(pieces)) {
373
- const beforePieces = sliceRunPieces(xmlDoc, pieces, 0, localInsertPos, false);
374
- const afterPieces = sliceRunPieces(xmlDoc, pieces, localInsertPos, getRunTextLength(pieces), false);
375
-
376
- insertRunPiecesBefore(xmlDoc, parent, targetSpan.runElement, beforePieces, targetSpan.rPr);
377
- insertTextRuns(xmlDoc, parent, targetSpan.runElement, text, baseRPr, author, formatHints, insertOffset, generateRedlines, revisionMetadata);
378
- insertRunPiecesBefore(xmlDoc, parent, targetSpan.runElement, afterPieces, targetSpan.rPr);
379
- parent.removeChild(targetSpan.runElement);
380
- return true;
381
- }
382
- }
383
-
384
- // Boundary between runs or at start/end of paragraph
385
- let parent = null;
386
- let referenceNode = null;
387
-
388
- if (affinity.hyperlink === 'outside') {
389
- if (isRightInHyperlink) {
390
- const hyperlinkNode = boundary.rightSpan.runElement.parentNode;
391
- parent = hyperlinkNode.parentNode || fallbackParagraph;
392
- referenceNode = hyperlinkNode;
393
- } else if (isLeftInHyperlink) {
394
- const hyperlinkNode = boundary.leftSpan.runElement.parentNode;
395
- parent = hyperlinkNode.parentNode || fallbackParagraph;
396
- referenceNode = hyperlinkNode.nextSibling;
397
- }
398
- } else if (affinity.hyperlink === 'inside') {
399
- if (isRightInHyperlink) {
400
- parent = boundary.rightSpan.runElement.parentNode;
401
- referenceNode = boundary.rightSpan.runElement;
402
- } else if (isLeftInHyperlink) {
403
- parent = boundary.leftSpan.runElement.parentNode;
404
- referenceNode = boundary.leftSpan.runElement.nextSibling;
405
- }
406
- }
407
-
408
- if (!parent) {
409
- if (boundary.rightSpan) {
410
- parent = boundary.rightSpan.runElement.parentNode || fallbackParagraph;
411
- referenceNode = boundary.rightSpan.runElement;
412
- } else if (boundary.leftSpan) {
413
- parent = boundary.leftSpan.runElement.parentNode || fallbackParagraph;
414
- referenceNode = boundary.leftSpan.runElement.nextSibling;
415
- } else {
416
- parent = fallbackParagraph;
417
- referenceNode = null;
418
- }
419
- }
420
-
421
- // Check bookmark range affinity
422
- if (affinity.bookmark && parent) {
423
- if (affinity.bookmark === 'outside') {
424
- if (referenceNode && isWordElement(referenceNode.previousSibling, 'bookmarkStart')) {
425
- referenceNode = referenceNode.previousSibling;
426
- }
427
- if (boundary.leftSpan && isWordElement(boundary.leftSpan.runElement.nextSibling, 'bookmarkEnd')) {
428
- referenceNode = boundary.leftSpan.runElement.nextSibling.nextSibling;
429
- }
430
- } else if (affinity.bookmark === 'inside') {
431
- if (referenceNode && isWordElement(referenceNode, 'bookmarkStart')) {
432
- referenceNode = referenceNode.nextSibling;
433
- }
434
- if (boundary.leftSpan && isWordElement(boundary.leftSpan.runElement.nextSibling, 'bookmarkEnd')) {
435
- referenceNode = boundary.leftSpan.runElement.nextSibling;
436
- }
437
- }
438
- }
439
-
440
- // Check comment range affinity
441
- if (affinity.comment && parent) {
442
- if (affinity.comment === 'outside') {
443
- if (referenceNode && isWordElement(referenceNode.previousSibling, 'commentRangeStart')) {
444
- referenceNode = referenceNode.previousSibling;
445
- }
446
- if (boundary.leftSpan && isWordElement(boundary.leftSpan.runElement.nextSibling, 'commentRangeEnd')) {
447
- let afterComment = boundary.leftSpan.runElement.nextSibling.nextSibling;
448
- if (afterComment && (isWordElement(afterComment, 'commentReference') || isWordElement(afterComment, 'r'))) {
449
- const hasCRef = Array.from(afterComment.childNodes || []).some(n => isWordElement(n, 'commentReference'));
450
- if (hasCRef) afterComment = afterComment.nextSibling;
451
- }
452
- referenceNode = afterComment;
453
- }
454
- } else if (affinity.comment === 'inside') {
455
- if (referenceNode && isWordElement(referenceNode, 'commentRangeStart')) {
456
- referenceNode = referenceNode.nextSibling;
457
- }
458
- if (boundary.leftSpan && isWordElement(boundary.leftSpan.runElement.nextSibling, 'commentRangeEnd')) {
459
- referenceNode = boundary.leftSpan.runElement.nextSibling;
460
- }
461
- }
462
- }
463
-
464
- // Check revision affinity (coalesce_same_author)
465
- if (generateRedlines && affinity.revision === 'coalesce_same_author') {
466
- let insElem = null;
467
- let insRef = null;
468
-
469
- if (boundary.leftSpan && isWordElement(boundary.leftSpan.runElement.parentNode, 'ins')) {
470
- const candidate = boundary.leftSpan.runElement.parentNode;
471
- const candAuthor = candidate.getAttribute('w:author') || candidate.getAttributeNS(NS_W, 'author');
472
- if (candAuthor === author) {
473
- insElem = candidate;
474
- insRef = boundary.leftSpan.runElement.nextSibling;
475
- }
476
- } else if (boundary.rightSpan && isWordElement(boundary.rightSpan.runElement.parentNode, 'ins')) {
477
- const candidate = boundary.rightSpan.runElement.parentNode;
478
- const candAuthor = candidate.getAttribute('w:author') || candidate.getAttributeNS(NS_W, 'author');
479
- if (candAuthor === author) {
480
- insElem = candidate;
481
- insRef = boundary.rightSpan.runElement;
482
- }
483
- }
484
-
485
- if (insElem) {
486
- const insRun = createTextRun(xmlDoc, text, baseRPr, false);
487
- insElem.insertBefore(insRun, insRef);
488
- return true;
489
- }
490
- }
491
-
492
- if (
493
- generateRedlines
494
- && existingRevisions === 'slice-cross-author'
495
- && isForeignInsertion(parent, author)
496
- ) {
497
- return spliceInsertionAtCarrierOffset(
498
- xmlDoc,
499
- parent,
500
- getCarrierSplitOffset(spanIndex, parent, pos),
501
- text,
502
- baseRPr,
503
- author,
504
- formatHints,
505
- insertOffset,
506
- generateRedlines,
507
- revisionMetadata
508
- );
509
- }
510
-
511
- insertTextRuns(xmlDoc, parent, referenceNode, text, baseRPr, author, formatHints, insertOffset, generateRedlines, revisionMetadata);
512
- return true;
513
- }
514
-
515
- function spliceInsertionAtCarrierOffset(xmlDoc, carrier, splitOffset, text, baseRPr, author, formatHints, insertOffset, generateRedlines, revisionMetadata) {
516
- const parent = carrier.parentNode;
517
- if (!parent) return false;
518
-
519
- const { leftCarrier, rightCarrier } = splitTrackChangeCarrier(xmlDoc, carrier, splitOffset);
520
- if (leftCarrier) parent.insertBefore(leftCarrier, carrier);
521
- insertTextRuns(
522
- xmlDoc,
523
- parent,
524
- carrier,
525
- text,
526
- withoutRunPropertyChanges(baseRPr),
527
- author,
528
- formatHints,
529
- insertOffset,
530
- generateRedlines,
531
- revisionMetadata
532
- );
533
- if (rightCarrier) parent.insertBefore(rightCarrier, carrier);
534
- parent.removeChild(carrier);
535
- return true;
536
- }
537
-
538
- function withoutRunPropertyChanges(rPr) {
539
- if (!rPr) return null;
540
- const clone = rPr.cloneNode(true);
541
- const changes = Array.from(clone.getElementsByTagName?.('*') || [])
542
- .filter(node => isWordElement(node, 'rPrChange'));
543
- changes.forEach(node => node.parentNode?.removeChild(node));
544
- return clone;
545
- }
546
-
547
- function getCarrierSplitOffset(spanIndex, carrier, pos) {
548
- const carrierStart = getCarrierGlobalStart(spanIndex, carrier);
549
- const carrierLength = spanIndex.spans
550
- .filter(span => span.runElement?.parentNode === carrier)
551
- .reduce((length, span) => length + (span.charEnd - span.charStart), 0);
552
- return Math.max(0, Math.min(pos - carrierStart, carrierLength));
553
- }
554
-
555
- function getCarrierGlobalStart(spanIndex, carrier) {
556
- const carrierSpans = spanIndex.spans.filter(span => span.runElement?.parentNode === carrier);
557
- return carrierSpans.length > 0 ? Math.min(...carrierSpans.map(span => span.charStart)) : 0;
558
- }
559
-
560
- function isForeignInsertion(node, author) {
561
- if (!isWordElement(node, 'ins')) return false;
562
- const carrierAuthor = node.getAttribute('w:author') || node.getAttributeNS?.(NS_W, 'author') || '';
563
- return carrierAuthor.trim().toLowerCase() !== String(author || '').trim().toLowerCase();
564
- }
565
-
566
- function isSameAuthorInsertion(node, author) {
567
- if (!isWordElement(node, 'ins')) return false;
568
- const carrierAuthor = node.getAttribute('w:author') || node.getAttributeNS?.(NS_W, 'author') || '';
569
- return carrierAuthor.trim().toLowerCase() === String(author || '').trim().toLowerCase();
570
- }
571
-
572
- function nextElementSibling(node) {
573
- let sibling = node?.nextSibling || null;
574
- while (sibling && sibling.nodeType !== 1) sibling = sibling.nextSibling;
575
- return sibling;
576
- }
577
-
578
- function isConnected(node) {
579
- return !!node?.parentNode;
580
- }
581
-
582
- function insertTextRuns(xmlDoc, parent, referenceNode, text, baseRPr, author, formatHints, insertOffset, generateRedlines, revisionMetadata = null) {
583
- const applicableHints = getApplicableFormatHints(formatHints, insertOffset, insertOffset + text.length);
584
-
585
- if (applicableHints.length === 0) {
586
- const insRun = createTextRun(xmlDoc, text, baseRPr, false);
587
- if (generateRedlines) {
588
- const insWrapper = createTrackChange(xmlDoc, 'ins', insRun, author, revisionMetadata);
589
- parent.insertBefore(insWrapper, referenceNode);
590
- } else {
591
- parent.insertBefore(insRun, referenceNode);
592
- }
593
- return;
594
- }
595
-
596
- const runs = createFormattedRuns(xmlDoc, text, baseRPr, applicableHints, insertOffset, author, generateRedlines);
597
-
598
- if (generateRedlines) {
599
- const insWrapper = createTrackChange(xmlDoc, 'ins', null, author, revisionMetadata);
600
- runs.forEach(run => insWrapper.appendChild(run));
601
- parent.insertBefore(insWrapper, referenceNode);
602
- } else {
603
- runs.forEach(run => parent.insertBefore(run, referenceNode));
604
- }
605
- }
1
+ import { getApplicableFormatHints } from '../pipeline/markdown-processor.js';
2
+ import {
3
+ createTrackChange,
4
+ createTextRun,
5
+ createFormattedRuns,
6
+ createTextRunWithRPrElement,
7
+ injectFormattingToRPr
8
+ } from './run-builders.js';
9
+ import { createRevisionMetadata } from '../core/types.js';
10
+ import {
11
+ createRunFromPieces,
12
+ getRunContentPieces,
13
+ getRunTextLength,
14
+ insertRunPiecesBefore,
15
+ sliceRunPieces,
16
+ splitTrackChangeCarrier
17
+ } from './surgical-run-splitting.js';
18
+ import {
19
+ findContainingSpan,
20
+ findFirstSpanEndingAt,
21
+ findLastSpanEndingBeforeOrAt,
22
+ forEachOverlappingSpan,
23
+ describeInsertionBoundary
24
+ } from './surgical-spans.js';
25
+ import { extractFormatFromRPr } from './rpr-helpers.js';
26
+ import { isWordElement } from '../core/word-xml.js';
27
+ import { NS_W } from '../core/types.js';
28
+
29
+ export function reconcileFormattingForTextSpan(xmlDoc, span, start, end, applicableHints, author, generateRedlines) {
30
+ // Plain modified text carries no negative formatting instruction. Preserve
31
+ // unchanged source formatting unless Markdown explicitly targets this span.
32
+ if (applicableHints.length === 0) return false;
33
+
34
+ const rPr = span.rPr;
35
+ const existingFormat = extractFormatFromRPr(rPr);
36
+ const desiredFormat = { ...existingFormat };
37
+ applicableHints.forEach(h => Object.assign(desiredFormat, h.format));
38
+
39
+ const formatsToCheck = ['bold', 'italic', 'underline', 'strikethrough'];
40
+ const changesNeeded = formatsToCheck.some(f => !!desiredFormat[f] !== existingFormat[f]);
41
+
42
+ if (!changesNeeded) return false;
43
+
44
+ const parent = span.runElement.parentNode;
45
+ if (!parent) return false;
46
+
47
+ const fullText = span.textElement.textContent || '';
48
+ const runStart = span.charStart;
49
+
50
+ const localStart = start - runStart;
51
+ const localEnd = end - runStart;
52
+
53
+ const beforeText = fullText.substring(0, localStart);
54
+ const affectedText = fullText.substring(localStart, localEnd);
55
+ const afterText = fullText.substring(localEnd);
56
+
57
+ if (beforeText.length > 0) {
58
+ const beforeRun = createTextRun(xmlDoc, beforeText, rPr, false);
59
+ parent.insertBefore(beforeRun, span.runElement);
60
+ }
61
+
62
+ const newRPr = injectFormattingToRPr(xmlDoc, rPr, desiredFormat, author, generateRedlines);
63
+ const newRun = createTextRunWithRPrElement(xmlDoc, affectedText, newRPr, false);
64
+ parent.insertBefore(newRun, span.runElement);
65
+
66
+ if (afterText.length > 0) {
67
+ const afterRun = createTextRun(xmlDoc, afterText, rPr, false);
68
+ parent.insertBefore(afterRun, span.runElement);
69
+ }
70
+
71
+ parent.removeChild(span.runElement);
72
+ return true;
73
+ }
74
+
75
+ export function processDelete(xmlDoc, spanIndex, startPos, endPos, author, generateRedlines, revisionMetadata = null) {
76
+ const spans = [];
77
+ forEachOverlappingSpan(spanIndex, startPos, endPos, span => {
78
+ spans.push(span);
79
+ });
80
+
81
+ if (spans.length === 0) return false;
82
+
83
+ const spansByRun = new Map();
84
+ spans.forEach(span => {
85
+ if (!span.runElement?.parentNode) return;
86
+ if (!spansByRun.has(span.runElement)) spansByRun.set(span.runElement, []);
87
+ spansByRun.get(span.runElement).push(span);
88
+ });
89
+
90
+ const records = [];
91
+ spansByRun.forEach((runSpans, runElement) => {
92
+ const parent = runElement.parentNode;
93
+ if (!parent) return;
94
+
95
+ const pieces = getRunContentPieces(runElement);
96
+ if (pieces.length === 0) return;
97
+
98
+ let deleteStart = Infinity;
99
+ let deleteEnd = -Infinity;
100
+ runSpans.forEach(span => {
101
+ const piece = pieces.find(candidate => candidate.node === span.textElement);
102
+ if (!piece) return;
103
+
104
+ const spanDeleteStart = Math.max(0, startPos - span.charStart);
105
+ const spanDeleteEnd = Math.min(span.charEnd - span.charStart, endPos - span.charStart);
106
+ if (spanDeleteEnd <= spanDeleteStart) return;
107
+
108
+ deleteStart = Math.min(deleteStart, piece.start + spanDeleteStart);
109
+ deleteEnd = Math.max(deleteEnd, piece.start + spanDeleteEnd);
110
+ });
111
+
112
+ if (!Number.isFinite(deleteStart) || deleteEnd <= deleteStart) return;
113
+
114
+ records.push({
115
+ runElement,
116
+ parent,
117
+ rPr: runSpans[0].rPr,
118
+ beforePieces: sliceRunPieces(xmlDoc, pieces, 0, deleteStart, false),
119
+ deletedPieces: sliceRunPieces(xmlDoc, pieces, deleteStart, deleteEnd, true),
120
+ afterPieces: sliceRunPieces(xmlDoc, pieces, deleteEnd, getRunTextLength(pieces), false),
121
+ globalStart: Math.max(startPos, Math.min(...runSpans.map(span => span.charStart))),
122
+ globalEnd: Math.min(endPos, Math.max(...runSpans.map(span => span.charEnd))),
123
+ carrierGlobalStart: isWordElement(parent, 'ins') ? getCarrierGlobalStart(spanIndex, parent) : null
124
+ });
125
+ });
126
+
127
+ const groups = [];
128
+ for (const record of records) {
129
+ const previousGroup = groups[groups.length - 1];
130
+ const previousRecord = previousGroup?.[previousGroup.length - 1];
131
+ if (
132
+ previousRecord
133
+ && previousRecord.parent === record.parent
134
+ && nextElementSibling(previousRecord.runElement) === record.runElement
135
+ ) {
136
+ previousGroup.push(record);
137
+ } else {
138
+ groups.push([record]);
139
+ }
140
+ }
141
+
142
+ let changed = false;
143
+ let usedDelMetadata = false;
144
+ for (const group of groups) {
145
+ const firstRecord = group[0];
146
+ let delWrapper = null;
147
+ if (generateRedlines && group.some(record => record.deletedPieces.length > 0)) {
148
+ const metadata = revisionMetadata
149
+ ? (usedDelMetadata ? { ...revisionMetadata, id: createRevisionMetadata(author, xmlDoc, 'del').id } : revisionMetadata)
150
+ : null;
151
+ usedDelMetadata = true;
152
+ delWrapper = createTrackChange(xmlDoc, 'del', null, author, metadata);
153
+ }
154
+
155
+ for (const record of group) {
156
+ const { parent, runElement } = record;
157
+ insertRunPiecesBefore(xmlDoc, parent, runElement, record.beforePieces, record.rPr);
158
+ if (delWrapper && record === firstRecord) {
159
+ parent.insertBefore(delWrapper, runElement);
160
+ }
161
+ if (delWrapper && record.deletedPieces.length > 0) {
162
+ delWrapper.appendChild(createRunFromPieces(xmlDoc, record.deletedPieces, record.rPr));
163
+ }
164
+ const afterRun = insertRunPiecesBefore(xmlDoc, parent, runElement, record.afterPieces, record.rPr);
165
+ if (
166
+ record.globalEnd === endPos
167
+ && !isWordElement(parent, 'ins')
168
+ ) {
169
+ if (!spanIndex.replacementInsertionAnchors) spanIndex.replacementInsertionAnchors = new Map();
170
+ spanIndex.replacementInsertionAnchors.set(endPos, {
171
+ parent,
172
+ referenceNode: afterRun || runElement.nextSibling,
173
+ rPr: record.rPr
174
+ });
175
+ }
176
+ parent.removeChild(runElement);
177
+ changed = true;
178
+ }
179
+
180
+ const carrier = isWordElement(firstRecord.parent, 'ins') ? firstRecord.parent : null;
181
+ const groupEnd = Math.max(...group.map(record => record.globalEnd));
182
+ if (carrier && groupEnd === endPos) {
183
+ const carrierStart = firstRecord.carrierGlobalStart;
184
+ const deletedBeforeEnd = group
185
+ .filter(record => record.globalStart < endPos)
186
+ .reduce((sum, record) => sum + record.deletedPieces.reduce((n, piece) => n + (piece.textContent || '').length, 0), 0);
187
+ const currentOffset = Math.max(0, endPos - carrierStart - deletedBeforeEnd);
188
+ if (!spanIndex.revisionInsertionAnchors) spanIndex.revisionInsertionAnchors = new Map();
189
+ spanIndex.revisionInsertionAnchors.set(endPos, {
190
+ carrier,
191
+ splitOffset: currentOffset,
192
+ rPr: firstRecord.rPr
193
+ });
194
+ }
195
+ }
196
+
197
+ return changed;
198
+ }
199
+
200
+ export function processInsert(xmlDoc, spanIndex, pos, text, author, formatHints = [], insertOffset = 0, generateRedlines = true, fallbackParagraph = null, revisionMetadata = null, affinity = null, existingRevisions = 'merge-same-author') {
201
+ const mutationAnchor = spanIndex.revisionInsertionAnchors?.get(pos) || null;
202
+ if (
203
+ mutationAnchor
204
+ && existingRevisions === 'slice-cross-author'
205
+ && isConnected(mutationAnchor.carrier)
206
+ && isForeignInsertion(mutationAnchor.carrier, author)
207
+ ) {
208
+ spanIndex.revisionInsertionAnchors.delete(pos);
209
+ return spliceInsertionAtCarrierOffset(
210
+ xmlDoc,
211
+ mutationAnchor.carrier,
212
+ mutationAnchor.splitOffset,
213
+ text,
214
+ mutationAnchor.rPr,
215
+ author,
216
+ formatHints,
217
+ insertOffset,
218
+ generateRedlines,
219
+ revisionMetadata
220
+ );
221
+ }
222
+
223
+ const replacementAnchor = spanIndex.replacementInsertionAnchors?.get(pos) || null;
224
+ if (
225
+ replacementAnchor
226
+ && !affinity
227
+ && isConnected(replacementAnchor.parent)
228
+ && (!replacementAnchor.referenceNode || replacementAnchor.referenceNode.parentNode === replacementAnchor.parent)
229
+ ) {
230
+ spanIndex.replacementInsertionAnchors.delete(pos);
231
+ insertTextRuns(
232
+ xmlDoc,
233
+ replacementAnchor.parent,
234
+ replacementAnchor.referenceNode,
235
+ text,
236
+ replacementAnchor.rPr,
237
+ author,
238
+ formatHints,
239
+ insertOffset,
240
+ generateRedlines,
241
+ revisionMetadata
242
+ );
243
+ return true;
244
+ }
245
+
246
+ if (!affinity) {
247
+ let targetSpan = findContainingSpan(spanIndex, pos);
248
+
249
+ if (!targetSpan && pos > 0) {
250
+ targetSpan = findFirstSpanEndingAt(spanIndex, pos);
251
+ }
252
+
253
+ if (!targetSpan && pos > 0) {
254
+ targetSpan = findLastSpanEndingBeforeOrAt(spanIndex, pos);
255
+ }
256
+
257
+ if (!targetSpan && spanIndex.spans.length > 0) {
258
+ targetSpan = spanIndex.spans[spanIndex.spans.length - 1];
259
+ }
260
+
261
+ if (!targetSpan) {
262
+ if (!fallbackParagraph) return false;
263
+ insertTextRuns(xmlDoc, fallbackParagraph, null, text, null, author, formatHints, insertOffset, generateRedlines, revisionMetadata);
264
+ return true;
265
+ }
266
+
267
+ const parent = targetSpan.runElement.parentNode;
268
+ if (!parent) {
269
+ if (!fallbackParagraph) return false;
270
+ insertTextRuns(xmlDoc, fallbackParagraph, null, text, targetSpan.rPr, author, formatHints, insertOffset, generateRedlines, revisionMetadata);
271
+ return true;
272
+ }
273
+
274
+ const generateNestedRevision = generateRedlines && !(
275
+ existingRevisions === 'slice-cross-author'
276
+ && isSameAuthorInsertion(parent, author)
277
+ );
278
+
279
+ if (
280
+ generateRedlines
281
+ && existingRevisions === 'slice-cross-author'
282
+ && isForeignInsertion(parent, author)
283
+ ) {
284
+ return spliceInsertionAtCarrierOffset(
285
+ xmlDoc,
286
+ parent,
287
+ getCarrierSplitOffset(spanIndex, parent, pos),
288
+ text,
289
+ targetSpan.rPr,
290
+ author,
291
+ formatHints,
292
+ insertOffset,
293
+ generateRedlines,
294
+ revisionMetadata
295
+ );
296
+ }
297
+
298
+ const pieces = getRunContentPieces(targetSpan.runElement);
299
+ const targetPiece = pieces.find(piece => piece.node === targetSpan.textElement);
300
+ const localInsertPos = targetPiece
301
+ ? targetPiece.start + Math.max(0, Math.min(pos - targetSpan.charStart, targetSpan.charEnd - targetSpan.charStart))
302
+ : (pos <= targetSpan.charStart ? 0 : getRunTextLength(pieces));
303
+
304
+ if (localInsertPos > 0 && localInsertPos < getRunTextLength(pieces)) {
305
+ const beforePieces = sliceRunPieces(xmlDoc, pieces, 0, localInsertPos, false);
306
+ const afterPieces = sliceRunPieces(xmlDoc, pieces, localInsertPos, getRunTextLength(pieces), false);
307
+
308
+ insertRunPiecesBefore(xmlDoc, parent, targetSpan.runElement, beforePieces, targetSpan.rPr);
309
+ insertTextRuns(xmlDoc, parent, targetSpan.runElement, text, targetSpan.rPr, author, formatHints, insertOffset, generateNestedRevision, revisionMetadata);
310
+ insertRunPiecesBefore(xmlDoc, parent, targetSpan.runElement, afterPieces, targetSpan.rPr);
311
+ parent.removeChild(targetSpan.runElement);
312
+ return true;
313
+ }
314
+
315
+ const referenceNode = pos <= targetSpan.charStart ? targetSpan.runElement : targetSpan.runElement.nextSibling;
316
+ insertTextRuns(xmlDoc, parent, referenceNode, text, targetSpan.rPr, author, formatHints, insertOffset, generateNestedRevision, revisionMetadata);
317
+ return true;
318
+ }
319
+
320
+ const boundary = describeInsertionBoundary(spanIndex, pos, fallbackParagraph);
321
+
322
+ // Validate hyperlink affinity
323
+ const isLeftInHyperlink = boundary.leftSpan && isWordElement(boundary.leftSpan.runElement?.parentNode, 'hyperlink');
324
+ const isRightInHyperlink = boundary.rightSpan && isWordElement(boundary.rightSpan.runElement?.parentNode, 'hyperlink');
325
+ const isContainingInHyperlink = boundary.containingSpan && isWordElement(boundary.containingSpan.runElement?.parentNode, 'hyperlink');
326
+
327
+ if (affinity.hyperlink === 'outside') {
328
+ if (boundary.isInterior && isContainingInHyperlink) {
329
+ return {
330
+ error: {
331
+ code: 'UNSUPPORTED_INSERTION_AFFINITY',
332
+ message: 'Cannot place insertion outside hyperlink from strictly interior position.'
333
+ }
334
+ };
335
+ }
336
+ } else if (affinity.hyperlink === 'inside') {
337
+ if (!isLeftInHyperlink && !isRightInHyperlink && !isContainingInHyperlink) {
338
+ return {
339
+ error: {
340
+ code: 'UNSUPPORTED_INSERTION_AFFINITY',
341
+ message: 'Cannot place insertion inside hyperlink when no hyperlink is present at boundary.'
342
+ }
343
+ };
344
+ }
345
+ }
346
+
347
+ // Determine formatting (baseRPr)
348
+ let baseRPr = null;
349
+ if (affinity.formatting === 'none') {
350
+ baseRPr = null;
351
+ } else if (affinity.formatting === 'right') {
352
+ baseRPr = boundary.rightSpan?.rPr || null;
353
+ } else if (affinity.formatting === 'left') {
354
+ baseRPr = boundary.leftSpan?.rPr || null;
355
+ } else {
356
+ baseRPr = (boundary.containingSpan || boundary.leftSpan || boundary.rightSpan)?.rPr || null;
357
+ }
358
+
359
+ // Check interior of a run
360
+ if (boundary.isInterior) {
361
+ const targetSpan = boundary.containingSpan;
362
+ const parent = targetSpan.runElement.parentNode || fallbackParagraph;
363
+ if (!parent) return false;
364
+
365
+ const pieces = getRunContentPieces(targetSpan.runElement);
366
+ const targetPiece = pieces.find(piece => piece.node === targetSpan.textElement);
367
+ const localInsertPos = targetPiece
368
+ ? targetPiece.start + Math.max(0, Math.min(pos - targetSpan.charStart, targetSpan.charEnd - targetSpan.charStart))
369
+ : (pos <= targetSpan.charStart ? 0 : getRunTextLength(pieces));
370
+
371
+ if (localInsertPos > 0 && localInsertPos < getRunTextLength(pieces)) {
372
+ const beforePieces = sliceRunPieces(xmlDoc, pieces, 0, localInsertPos, false);
373
+ const afterPieces = sliceRunPieces(xmlDoc, pieces, localInsertPos, getRunTextLength(pieces), false);
374
+
375
+ insertRunPiecesBefore(xmlDoc, parent, targetSpan.runElement, beforePieces, targetSpan.rPr);
376
+ insertTextRuns(xmlDoc, parent, targetSpan.runElement, text, baseRPr, author, formatHints, insertOffset, generateRedlines, revisionMetadata);
377
+ insertRunPiecesBefore(xmlDoc, parent, targetSpan.runElement, afterPieces, targetSpan.rPr);
378
+ parent.removeChild(targetSpan.runElement);
379
+ return true;
380
+ }
381
+ }
382
+
383
+ // Boundary between runs or at start/end of paragraph
384
+ let parent = null;
385
+ let referenceNode = null;
386
+
387
+ if (affinity.hyperlink === 'outside') {
388
+ if (isRightInHyperlink) {
389
+ const hyperlinkNode = boundary.rightSpan.runElement.parentNode;
390
+ parent = hyperlinkNode.parentNode || fallbackParagraph;
391
+ referenceNode = hyperlinkNode;
392
+ } else if (isLeftInHyperlink) {
393
+ const hyperlinkNode = boundary.leftSpan.runElement.parentNode;
394
+ parent = hyperlinkNode.parentNode || fallbackParagraph;
395
+ referenceNode = hyperlinkNode.nextSibling;
396
+ }
397
+ } else if (affinity.hyperlink === 'inside') {
398
+ if (isRightInHyperlink) {
399
+ parent = boundary.rightSpan.runElement.parentNode;
400
+ referenceNode = boundary.rightSpan.runElement;
401
+ } else if (isLeftInHyperlink) {
402
+ parent = boundary.leftSpan.runElement.parentNode;
403
+ referenceNode = boundary.leftSpan.runElement.nextSibling;
404
+ }
405
+ }
406
+
407
+ if (!parent) {
408
+ if (boundary.rightSpan) {
409
+ parent = boundary.rightSpan.runElement.parentNode || fallbackParagraph;
410
+ referenceNode = boundary.rightSpan.runElement;
411
+ } else if (boundary.leftSpan) {
412
+ parent = boundary.leftSpan.runElement.parentNode || fallbackParagraph;
413
+ referenceNode = boundary.leftSpan.runElement.nextSibling;
414
+ } else {
415
+ parent = fallbackParagraph;
416
+ referenceNode = null;
417
+ }
418
+ }
419
+
420
+ // Check bookmark range affinity
421
+ if (affinity.bookmark && parent) {
422
+ if (affinity.bookmark === 'outside') {
423
+ if (referenceNode && isWordElement(referenceNode.previousSibling, 'bookmarkStart')) {
424
+ referenceNode = referenceNode.previousSibling;
425
+ }
426
+ if (boundary.leftSpan && isWordElement(boundary.leftSpan.runElement.nextSibling, 'bookmarkEnd')) {
427
+ referenceNode = boundary.leftSpan.runElement.nextSibling.nextSibling;
428
+ }
429
+ } else if (affinity.bookmark === 'inside') {
430
+ if (referenceNode && isWordElement(referenceNode, 'bookmarkStart')) {
431
+ referenceNode = referenceNode.nextSibling;
432
+ }
433
+ if (boundary.leftSpan && isWordElement(boundary.leftSpan.runElement.nextSibling, 'bookmarkEnd')) {
434
+ referenceNode = boundary.leftSpan.runElement.nextSibling;
435
+ }
436
+ }
437
+ }
438
+
439
+ // Check comment range affinity
440
+ if (affinity.comment && parent) {
441
+ if (affinity.comment === 'outside') {
442
+ if (referenceNode && isWordElement(referenceNode.previousSibling, 'commentRangeStart')) {
443
+ referenceNode = referenceNode.previousSibling;
444
+ }
445
+ if (boundary.leftSpan && isWordElement(boundary.leftSpan.runElement.nextSibling, 'commentRangeEnd')) {
446
+ let afterComment = boundary.leftSpan.runElement.nextSibling.nextSibling;
447
+ if (afterComment && (isWordElement(afterComment, 'commentReference') || isWordElement(afterComment, 'r'))) {
448
+ const hasCRef = Array.from(afterComment.childNodes || []).some(n => isWordElement(n, 'commentReference'));
449
+ if (hasCRef) afterComment = afterComment.nextSibling;
450
+ }
451
+ referenceNode = afterComment;
452
+ }
453
+ } else if (affinity.comment === 'inside') {
454
+ if (referenceNode && isWordElement(referenceNode, 'commentRangeStart')) {
455
+ referenceNode = referenceNode.nextSibling;
456
+ }
457
+ if (boundary.leftSpan && isWordElement(boundary.leftSpan.runElement.nextSibling, 'commentRangeEnd')) {
458
+ referenceNode = boundary.leftSpan.runElement.nextSibling;
459
+ }
460
+ }
461
+ }
462
+
463
+ // Check revision affinity (coalesce_same_author)
464
+ if (generateRedlines && affinity.revision === 'coalesce_same_author') {
465
+ let insElem = null;
466
+ let insRef = null;
467
+
468
+ if (boundary.leftSpan && isWordElement(boundary.leftSpan.runElement.parentNode, 'ins')) {
469
+ const candidate = boundary.leftSpan.runElement.parentNode;
470
+ const candAuthor = candidate.getAttribute('w:author') || candidate.getAttributeNS(NS_W, 'author');
471
+ if (candAuthor === author) {
472
+ insElem = candidate;
473
+ insRef = boundary.leftSpan.runElement.nextSibling;
474
+ }
475
+ } else if (boundary.rightSpan && isWordElement(boundary.rightSpan.runElement.parentNode, 'ins')) {
476
+ const candidate = boundary.rightSpan.runElement.parentNode;
477
+ const candAuthor = candidate.getAttribute('w:author') || candidate.getAttributeNS(NS_W, 'author');
478
+ if (candAuthor === author) {
479
+ insElem = candidate;
480
+ insRef = boundary.rightSpan.runElement;
481
+ }
482
+ }
483
+
484
+ if (insElem) {
485
+ const insRun = createTextRun(xmlDoc, text, baseRPr, false);
486
+ insElem.insertBefore(insRun, insRef);
487
+ return true;
488
+ }
489
+ }
490
+
491
+ if (
492
+ generateRedlines
493
+ && existingRevisions === 'slice-cross-author'
494
+ && isForeignInsertion(parent, author)
495
+ ) {
496
+ return spliceInsertionAtCarrierOffset(
497
+ xmlDoc,
498
+ parent,
499
+ getCarrierSplitOffset(spanIndex, parent, pos),
500
+ text,
501
+ baseRPr,
502
+ author,
503
+ formatHints,
504
+ insertOffset,
505
+ generateRedlines,
506
+ revisionMetadata
507
+ );
508
+ }
509
+
510
+ insertTextRuns(xmlDoc, parent, referenceNode, text, baseRPr, author, formatHints, insertOffset, generateRedlines, revisionMetadata);
511
+ return true;
512
+ }
513
+
514
+ function spliceInsertionAtCarrierOffset(xmlDoc, carrier, splitOffset, text, baseRPr, author, formatHints, insertOffset, generateRedlines, revisionMetadata) {
515
+ const parent = carrier.parentNode;
516
+ if (!parent) return false;
517
+
518
+ const { leftCarrier, rightCarrier } = splitTrackChangeCarrier(xmlDoc, carrier, splitOffset);
519
+ if (leftCarrier) parent.insertBefore(leftCarrier, carrier);
520
+ insertTextRuns(
521
+ xmlDoc,
522
+ parent,
523
+ carrier,
524
+ text,
525
+ withoutRunPropertyChanges(baseRPr),
526
+ author,
527
+ formatHints,
528
+ insertOffset,
529
+ generateRedlines,
530
+ revisionMetadata
531
+ );
532
+ if (rightCarrier) parent.insertBefore(rightCarrier, carrier);
533
+ parent.removeChild(carrier);
534
+ return true;
535
+ }
536
+
537
+ function withoutRunPropertyChanges(rPr) {
538
+ if (!rPr) return null;
539
+ const clone = rPr.cloneNode(true);
540
+ const changes = Array.from(clone.getElementsByTagName?.('*') || [])
541
+ .filter(node => isWordElement(node, 'rPrChange'));
542
+ changes.forEach(node => node.parentNode?.removeChild(node));
543
+ return clone;
544
+ }
545
+
546
+ function getCarrierSplitOffset(spanIndex, carrier, pos) {
547
+ const carrierStart = getCarrierGlobalStart(spanIndex, carrier);
548
+ const carrierLength = spanIndex.spans
549
+ .filter(span => span.runElement?.parentNode === carrier)
550
+ .reduce((length, span) => length + (span.charEnd - span.charStart), 0);
551
+ return Math.max(0, Math.min(pos - carrierStart, carrierLength));
552
+ }
553
+
554
+ function getCarrierGlobalStart(spanIndex, carrier) {
555
+ const carrierSpans = spanIndex.spans.filter(span => span.runElement?.parentNode === carrier);
556
+ return carrierSpans.length > 0 ? Math.min(...carrierSpans.map(span => span.charStart)) : 0;
557
+ }
558
+
559
+ function isForeignInsertion(node, author) {
560
+ if (!isWordElement(node, 'ins')) return false;
561
+ const carrierAuthor = node.getAttribute('w:author') || node.getAttributeNS?.(NS_W, 'author') || '';
562
+ return carrierAuthor.trim().toLowerCase() !== String(author || '').trim().toLowerCase();
563
+ }
564
+
565
+ function isSameAuthorInsertion(node, author) {
566
+ if (!isWordElement(node, 'ins')) return false;
567
+ const carrierAuthor = node.getAttribute('w:author') || node.getAttributeNS?.(NS_W, 'author') || '';
568
+ return carrierAuthor.trim().toLowerCase() === String(author || '').trim().toLowerCase();
569
+ }
570
+
571
+ function nextElementSibling(node) {
572
+ let sibling = node?.nextSibling || null;
573
+ while (sibling && sibling.nodeType !== 1) sibling = sibling.nextSibling;
574
+ return sibling;
575
+ }
576
+
577
+ function isConnected(node) {
578
+ return !!node?.parentNode;
579
+ }
580
+
581
+ function insertTextRuns(xmlDoc, parent, referenceNode, text, baseRPr, author, formatHints, insertOffset, generateRedlines, revisionMetadata = null) {
582
+ const applicableHints = getApplicableFormatHints(formatHints, insertOffset, insertOffset + text.length);
583
+
584
+ if (applicableHints.length === 0) {
585
+ const insRun = createTextRun(xmlDoc, text, baseRPr, false);
586
+ if (generateRedlines) {
587
+ const insWrapper = createTrackChange(xmlDoc, 'ins', insRun, author, revisionMetadata);
588
+ parent.insertBefore(insWrapper, referenceNode);
589
+ } else {
590
+ parent.insertBefore(insRun, referenceNode);
591
+ }
592
+ return;
593
+ }
594
+
595
+ const runs = createFormattedRuns(xmlDoc, text, baseRPr, applicableHints, insertOffset, author, generateRedlines);
596
+
597
+ if (generateRedlines) {
598
+ const insWrapper = createTrackChange(xmlDoc, 'ins', null, author, revisionMetadata);
599
+ runs.forEach(run => insWrapper.appendChild(run));
600
+ parent.insertBefore(insWrapper, referenceNode);
601
+ } else {
602
+ runs.forEach(run => parent.insertBefore(run, referenceNode));
603
+ }
604
+ }