@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,520 @@
1
+ /**
2
+ * Surgical reconciliation mode.
3
+ *
4
+ * This mode performs in-place run-level edits and preserves existing structure,
5
+ * making it safe for tables and other complex OOXML containers.
6
+ */
7
+
8
+ import { getApplicableFormatHints } from '../pipeline/markdown-processor.js';
9
+ import { computeWordDiffs } from '../pipeline/diff-engine.js';
10
+ import { appendParagraphBoundary } from '../core/paragraph-offset-policy.js';
11
+ import { NS_W, getNextRevisionId } from '../core/types.js';
12
+ import { createSerializer } from '../adapters/xml-adapter.js';
13
+ import { getDocumentParagraphs } from './format-extraction.js';
14
+ import { buildOverrideRPrXml } from './rpr-helpers.js';
15
+ import { getFirstElementByTag } from '../core/xml-query.js';
16
+ import { getDefaultAuthor } from '../adapters/config.js';
17
+ import {
18
+ createTrackChange,
19
+ createTextRun,
20
+ createFormattedRuns,
21
+ createTextRunWithRPrElement,
22
+ injectFormattingToRPr
23
+ } from './run-builders.js';
24
+
25
+ /**
26
+ * Builds minimal OOXML for a surgical text replacement with track changes.
27
+ *
28
+ * @param {Document} xmlDoc - XML document
29
+ * @param {Element} originalRun - Original run
30
+ * @param {string} textContent - Replacement text
31
+ * @param {string} author - Author name
32
+ * @param {string} dateStr - ISO date
33
+ * @param {Object} formatToRemove - Format flags to force off
34
+ * @returns {string}
35
+ */
36
+ function buildSurgicalReplacementOoxml(xmlDoc, originalRun, textContent, author, dateStr, formatToRemove) {
37
+ const authorName = author || getDefaultAuthor();
38
+ const delId = getNextRevisionId();
39
+ const insId = getNextRevisionId();
40
+
41
+ const serializer = createSerializer();
42
+
43
+ let rPrXml = '';
44
+ const rPr = getFirstElementByTag(originalRun, 'w:rPr');
45
+ if (rPr) {
46
+ rPrXml = serializer.serializeToString(rPr);
47
+ rPrXml = rPrXml.replace(/\s+xmlns:[^=]+="[^"]*"/g, '');
48
+ }
49
+
50
+ const unformattedRPrXml = buildOverrideRPrXml(xmlDoc, originalRun, formatToRemove, serializer);
51
+
52
+ const escapedText = textContent
53
+ .replace(/&/g, '&')
54
+ .replace(/</g, '&lt;')
55
+ .replace(/>/g, '&gt;');
56
+
57
+ const delFragment = `<w:del xmlns:w="${NS_W}" w:id="${delId}" w:author="${authorName}" w:date="${dateStr}">` +
58
+ `<w:r>${rPrXml}<w:delText xml:space="preserve">${escapedText}</w:delText></w:r>` +
59
+ `</w:del>`;
60
+
61
+ const insFragment = `<w:ins xmlns:w="${NS_W}" w:id="${insId}" w:author="${authorName}" w:date="${dateStr}">` +
62
+ `<w:r>${unformattedRPrXml}<w:t xml:space="preserve">${escapedText}</w:t></w:r>` +
63
+ `</w:ins>`;
64
+
65
+ return `${delFragment}${insFragment}`;
66
+ }
67
+
68
+ /**
69
+ * Builds minimal OOXML for an unformatted run (without track wrappers).
70
+ *
71
+ * @param {Document} xmlDoc - XML document
72
+ * @param {Element} originalRun - Original run
73
+ * @param {string} textContent - Replacement text
74
+ * @param {Object} formatToRemove - Format flags to force off
75
+ * @returns {string}
76
+ */
77
+ function buildUnformattedRunOoxml(xmlDoc, originalRun, textContent, formatToRemove) {
78
+ const serializer = createSerializer();
79
+ const unformattedRPrXml = buildOverrideRPrXml(xmlDoc, originalRun, formatToRemove, serializer);
80
+
81
+ const escapedText = textContent
82
+ .replace(/&/g, '&amp;')
83
+ .replace(/</g, '&lt;')
84
+ .replace(/>/g, '&gt;');
85
+
86
+ return `<w:r xmlns:w="${NS_W}">${unformattedRPrXml}<w:t xml:space="preserve">${escapedText}</w:t></w:r>`;
87
+ }
88
+
89
+ /**
90
+ * Applies surgical mode reconciliation.
91
+ *
92
+ * @param {Document} xmlDoc - XML document
93
+ * @param {string} originalText - Original text
94
+ * @param {string} modifiedText - Modified text
95
+ * @param {XMLSerializer} serializer - Serializer instance
96
+ * @param {string} author - Author name
97
+ * @param {Array} formatHints - Format hints
98
+ * @param {boolean} [generateRedlines=true] - Track change toggle
99
+ * @param {Element|null} [targetParagraph=null] - Optional scope paragraph
100
+ * @returns {{ oxml: string, hasChanges: boolean }}
101
+ */
102
+ export function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer, author, formatHints, generateRedlines = true, targetParagraph = null) {
103
+ let fullText = '';
104
+ const textSpans = [];
105
+
106
+ const allParagraphs = targetParagraph
107
+ ? [targetParagraph]
108
+ : getDocumentParagraphs(xmlDoc);
109
+
110
+ allParagraphs.forEach((p, pIndex) => {
111
+ const container = p.parentNode;
112
+
113
+ for (let child = p.firstChild; child; child = child.nextSibling) {
114
+ if (child.nodeName === 'w:r') {
115
+ const runResult = processRunElement(child, p, container, fullText.length, textSpans);
116
+ fullText += runResult.text;
117
+ } else if (child.nodeName === 'w:hyperlink') {
118
+ for (let hc = child.firstChild; hc; hc = hc.nextSibling) {
119
+ if (hc.nodeName === 'w:r') {
120
+ const runResult = processRunElement(hc, p, container, fullText.length, textSpans);
121
+ fullText += runResult.text;
122
+ }
123
+ }
124
+ }
125
+ }
126
+
127
+ fullText = appendParagraphBoundary(fullText, pIndex, allParagraphs.length);
128
+ });
129
+
130
+ const diffs = computeWordDiffs(fullText, modifiedText);
131
+ const spanIndex = buildSpanIndex(textSpans);
132
+
133
+ let originalPos = 0;
134
+ let newPos = 0;
135
+ const processedSpans = new Set();
136
+
137
+ for (const [op, text] of diffs) {
138
+ if (op === 0) {
139
+ const len = text.length;
140
+ const startPos = originalPos;
141
+ const endPos = originalPos + len;
142
+
143
+ forEachOverlappingSpan(spanIndex, startPos, endPos, span => {
144
+ const overlapStartOriginal = Math.max(span.charStart, startPos);
145
+ const overlapEndOriginal = Math.min(span.charEnd, endPos);
146
+ const segmentLen = overlapEndOriginal - overlapStartOriginal;
147
+ const relativeOffset = overlapStartOriginal - startPos;
148
+ const overlapStartNew = newPos + relativeOffset;
149
+ const overlapEndNew = overlapStartNew + segmentLen;
150
+ const applicableHints = getApplicableFormatHints(formatHints, overlapStartNew, overlapEndNew);
151
+ reconcileFormattingForTextSpan(xmlDoc, span, overlapStartOriginal, overlapEndOriginal, applicableHints, author, generateRedlines);
152
+ });
153
+
154
+ originalPos += len;
155
+ newPos += len;
156
+ } else if (op === -1) {
157
+ processDelete(xmlDoc, spanIndex, originalPos, originalPos + text.length, processedSpans, author, generateRedlines);
158
+ originalPos += text.length;
159
+ } else if (op === 1) {
160
+ const textWithoutNewlines = text.replace(/\n/g, ' ');
161
+ if (textWithoutNewlines.trim().length > 0) {
162
+ processInsert(xmlDoc, spanIndex, originalPos, textWithoutNewlines, processedSpans, author, formatHints, newPos, generateRedlines);
163
+ }
164
+ newPos += text.length;
165
+ }
166
+ }
167
+
168
+ return { oxml: serializer.serializeToString(xmlDoc), hasChanges: true };
169
+ }
170
+
171
+ /**
172
+ * Reconciles formatting for a text span segment.
173
+ *
174
+ * @param {Document} xmlDoc - XML document
175
+ * @param {Object} span - Text span
176
+ * @param {number} start - Absolute start
177
+ * @param {number} end - Absolute end
178
+ * @param {Array} applicableHints - Applicable format hints
179
+ * @param {string} author - Author name
180
+ * @param {boolean} generateRedlines - Track change toggle
181
+ */
182
+ function reconcileFormattingForTextSpan(xmlDoc, span, start, end, applicableHints, author, generateRedlines) {
183
+ const desiredFormat = {};
184
+ if (applicableHints.length > 0) {
185
+ applicableHints.forEach(h => Object.assign(desiredFormat, h.format));
186
+ }
187
+
188
+ const rPr = span.rPr;
189
+ const hasElement = (tagName) => {
190
+ if (!rPr) return false;
191
+ for (let node = rPr.firstChild; node; node = node.nextSibling) {
192
+ if (node.nodeName === tagName) {
193
+ return true;
194
+ }
195
+ }
196
+ return false;
197
+ };
198
+
199
+ const existingFormat = {
200
+ bold: hasElement('w:b'),
201
+ italic: hasElement('w:i'),
202
+ underline: hasElement('w:u'),
203
+ strikethrough: hasElement('w:strike')
204
+ };
205
+
206
+ const formatsToCheck = ['bold', 'italic', 'underline', 'strikethrough'];
207
+ const changesNeeded = formatsToCheck.some(f => !!desiredFormat[f] !== existingFormat[f]);
208
+
209
+ if (!changesNeeded) return;
210
+
211
+ const parent = span.runElement.parentNode;
212
+ if (!parent) return;
213
+
214
+ const fullText = span.textElement.textContent || '';
215
+ const runStart = span.charStart;
216
+
217
+ const localStart = start - runStart;
218
+ const localEnd = end - runStart;
219
+
220
+ const beforeText = fullText.substring(0, localStart);
221
+ const affectedText = fullText.substring(localStart, localEnd);
222
+ const afterText = fullText.substring(localEnd);
223
+
224
+ if (beforeText.length > 0) {
225
+ const beforeRun = createTextRun(xmlDoc, beforeText, rPr, false);
226
+ parent.insertBefore(beforeRun, span.runElement);
227
+ }
228
+
229
+ const newRPr = injectFormattingToRPr(xmlDoc, rPr, desiredFormat, author, generateRedlines);
230
+ const newRun = createTextRunWithRPrElement(xmlDoc, affectedText, newRPr, false);
231
+ parent.insertBefore(newRun, span.runElement);
232
+
233
+ if (afterText.length > 0) {
234
+ const afterRun = createTextRun(xmlDoc, afterText, rPr, false);
235
+ parent.insertBefore(afterRun, span.runElement);
236
+ }
237
+
238
+ parent.removeChild(span.runElement);
239
+ }
240
+
241
+ /**
242
+ * Processes a run and appends text span metadata.
243
+ *
244
+ * @param {Element} r - Run element
245
+ * @param {Element} p - Paragraph element
246
+ * @param {Element} container - Parent container
247
+ * @param {number} currentOffset - Absolute offset
248
+ * @param {Array} textSpans - Span collection
249
+ * @returns {{ nextOffset: number, text: string }}
250
+ */
251
+ function processRunElement(r, p, container, currentOffset, textSpans) {
252
+ const rPr = getFirstElementByTag(r, 'w:rPr');
253
+ let localOffset = currentOffset;
254
+ const textParts = [];
255
+
256
+ for (let rc = r.firstChild; rc; rc = rc.nextSibling) {
257
+ if (rc.nodeName === 'w:t') {
258
+ const text = rc.textContent || '';
259
+ if (text.length > 0) {
260
+ textSpans.push({
261
+ charStart: localOffset,
262
+ charEnd: localOffset + text.length,
263
+ textElement: rc,
264
+ runElement: r,
265
+ paragraph: p,
266
+ container,
267
+ rPr
268
+ });
269
+ localOffset += text.length;
270
+ textParts.push(text);
271
+ }
272
+ } else if (rc.nodeName === 'w:br' || rc.nodeName === 'w:cr') {
273
+ textSpans.push({
274
+ charStart: localOffset,
275
+ charEnd: localOffset + 1,
276
+ textElement: rc,
277
+ runElement: r,
278
+ paragraph: p,
279
+ container,
280
+ rPr
281
+ });
282
+ localOffset += 1;
283
+ textParts.push('\n');
284
+ } else if (rc.nodeName === 'w:tab') {
285
+ textSpans.push({
286
+ charStart: localOffset,
287
+ charEnd: localOffset + 1,
288
+ textElement: rc,
289
+ runElement: r,
290
+ paragraph: p,
291
+ container,
292
+ rPr
293
+ });
294
+ localOffset += 1;
295
+ textParts.push('\t');
296
+ } else if (rc.nodeName === 'w:noBreakHyphen') {
297
+ textSpans.push({
298
+ charStart: localOffset,
299
+ charEnd: localOffset + 1,
300
+ textElement: rc,
301
+ runElement: r,
302
+ paragraph: p,
303
+ container,
304
+ rPr
305
+ });
306
+ localOffset += 1;
307
+ textParts.push('\u2011');
308
+ }
309
+ }
310
+ return { nextOffset: localOffset, text: textParts.join('') };
311
+ }
312
+
313
+ /**
314
+ * Applies deletion over affected spans.
315
+ *
316
+ * @param {Document} xmlDoc - XML document
317
+ * @param {{ spans: Array, starts: number[], ends: number[] }} spanIndex - Indexed spans
318
+ * @param {number} startPos - Delete start
319
+ * @param {number} endPos - Delete end
320
+ * @param {Set<Node>} processedSpans - Processed marker set
321
+ * @param {string} author - Author name
322
+ * @param {boolean} generateRedlines - Track change toggle
323
+ */
324
+ function processDelete(xmlDoc, spanIndex, startPos, endPos, processedSpans, author, generateRedlines) {
325
+ forEachOverlappingSpan(spanIndex, startPos, endPos, span => {
326
+ if (processedSpans.has(span.textElement)) return;
327
+
328
+ const deleteStart = Math.max(0, startPos - span.charStart);
329
+ const deleteEnd = Math.min(span.charEnd - span.charStart, endPos - span.charStart);
330
+
331
+ const originalText = span.textElement.textContent || '';
332
+ const beforeText = originalText.substring(0, deleteStart);
333
+ const deletedText = originalText.substring(deleteStart, deleteEnd);
334
+ const afterText = originalText.substring(deleteEnd);
335
+
336
+ if (deletedText.length === 0) return;
337
+
338
+ const parent = span.runElement.parentNode;
339
+ if (!parent) return;
340
+
341
+ if (beforeText.length === 0 && afterText.length === 0) {
342
+ const delRun = createTextRun(xmlDoc, deletedText, span.rPr, true);
343
+ if (generateRedlines) {
344
+ const delWrapper = createTrackChange(xmlDoc, 'del', delRun, author);
345
+ parent.insertBefore(delWrapper, span.runElement);
346
+ }
347
+ parent.removeChild(span.runElement);
348
+ } else {
349
+ const oldRun = span.runElement;
350
+
351
+ if (beforeText.length > 0) {
352
+ const beforeRun = createTextRun(xmlDoc, beforeText, span.rPr, false);
353
+ parent.insertBefore(beforeRun, oldRun);
354
+ }
355
+
356
+ const delRun = createTextRun(xmlDoc, deletedText, span.rPr, true);
357
+ if (generateRedlines) {
358
+ const delWrapper = createTrackChange(xmlDoc, 'del', delRun, author);
359
+ parent.insertBefore(delWrapper, oldRun);
360
+ }
361
+
362
+ if (afterText.length > 0) {
363
+ const afterRun = createTextRun(xmlDoc, afterText, span.rPr, false);
364
+ parent.insertBefore(afterRun, oldRun);
365
+ span.runElement = afterRun;
366
+ span.textElement = getFirstElementByTag(afterRun, 'w:t') || getFirstElementByTag(afterRun, 't');
367
+ }
368
+
369
+ parent.removeChild(oldRun);
370
+ }
371
+
372
+ processedSpans.add(span.textElement);
373
+ });
374
+ }
375
+
376
+ /**
377
+ * Inserts new text at an absolute position.
378
+ *
379
+ * @param {Document} xmlDoc - XML document
380
+ * @param {{ spans: Array, starts: number[], ends: number[] }} spanIndex - Indexed spans
381
+ * @param {number} pos - Insertion position
382
+ * @param {string} text - Text to insert
383
+ * @param {Set<Node>} processedSpans - Processed marker set (unused, kept for signature)
384
+ * @param {string} author - Author name
385
+ * @param {Array} [formatHints=[]] - Format hints
386
+ * @param {number} [insertOffset=0] - Offset in modified text
387
+ * @param {boolean} [generateRedlines=true] - Track change toggle
388
+ */
389
+ function processInsert(xmlDoc, spanIndex, pos, text, processedSpans, author, formatHints = [], insertOffset = 0, generateRedlines = true) {
390
+ void processedSpans;
391
+
392
+ let targetSpan = findContainingSpan(spanIndex, pos);
393
+
394
+ if (!targetSpan && pos > 0) {
395
+ targetSpan = findFirstSpanEndingAt(spanIndex, pos);
396
+ }
397
+
398
+ if (!targetSpan && pos > 0) {
399
+ targetSpan = findLastSpanEndingBeforeOrAt(spanIndex, pos);
400
+ }
401
+
402
+ if (!targetSpan && spanIndex.spans.length > 0) {
403
+ targetSpan = spanIndex.spans[spanIndex.spans.length - 1];
404
+ }
405
+
406
+ if (targetSpan) {
407
+ const applicableHints = getApplicableFormatHints(formatHints, insertOffset, insertOffset + text.length);
408
+ const baseRPr = targetSpan.rPr;
409
+ const parent = targetSpan.runElement.parentNode;
410
+
411
+ if (parent) {
412
+ const referenceNode = (pos === targetSpan.charStart) ? targetSpan.runElement : targetSpan.runElement.nextSibling;
413
+
414
+ if (applicableHints.length === 0) {
415
+ const insRun = createTextRun(xmlDoc, text, baseRPr, false);
416
+ if (generateRedlines) {
417
+ const insWrapper = createTrackChange(xmlDoc, 'ins', insRun, author);
418
+ parent.insertBefore(insWrapper, referenceNode);
419
+ } else {
420
+ parent.insertBefore(insRun, referenceNode);
421
+ }
422
+ } else {
423
+ const runs = createFormattedRuns(xmlDoc, text, baseRPr, applicableHints, insertOffset, author, generateRedlines);
424
+
425
+ if (generateRedlines) {
426
+ const insWrapper = createTrackChange(xmlDoc, 'ins', null, author);
427
+ runs.forEach(run => insWrapper.appendChild(run));
428
+ parent.insertBefore(insWrapper, referenceNode);
429
+ } else {
430
+ runs.forEach(run => parent.insertBefore(run, referenceNode));
431
+ }
432
+ }
433
+ }
434
+ }
435
+ }
436
+
437
+ function buildSpanIndex(textSpans) {
438
+ const spans = textSpans
439
+ .slice()
440
+ .sort((a, b) => a.charStart - b.charStart || a.charEnd - b.charEnd);
441
+
442
+ const starts = spans.map(span => span.charStart);
443
+ const ends = spans.map(span => span.charEnd);
444
+
445
+ return { spans, starts, ends };
446
+ }
447
+
448
+ function upperBound(values, target) {
449
+ let left = 0;
450
+ let right = values.length;
451
+
452
+ while (left < right) {
453
+ const middle = (left + right) >> 1;
454
+ if (values[middle] <= target) {
455
+ left = middle + 1;
456
+ } else {
457
+ right = middle;
458
+ }
459
+ }
460
+
461
+ return left;
462
+ }
463
+
464
+ function lowerBound(values, target) {
465
+ let left = 0;
466
+ let right = values.length;
467
+
468
+ while (left < right) {
469
+ const middle = (left + right) >> 1;
470
+ if (values[middle] < target) {
471
+ left = middle + 1;
472
+ } else {
473
+ right = middle;
474
+ }
475
+ }
476
+
477
+ return left;
478
+ }
479
+
480
+ function forEachOverlappingSpan(spanIndex, startPos, endPos, callback) {
481
+ if (endPos <= startPos || spanIndex.spans.length === 0) {
482
+ return;
483
+ }
484
+
485
+ let index = upperBound(spanIndex.ends, startPos);
486
+ while (index < spanIndex.spans.length) {
487
+ const span = spanIndex.spans[index];
488
+ if (span.charStart >= endPos) {
489
+ break;
490
+ }
491
+ callback(span);
492
+ index++;
493
+ }
494
+ }
495
+
496
+ function findContainingSpan(spanIndex, pos) {
497
+ if (spanIndex.spans.length === 0) return null;
498
+
499
+ const index = upperBound(spanIndex.starts, pos) - 1;
500
+ if (index < 0) return null;
501
+
502
+ const span = spanIndex.spans[index];
503
+ return pos >= span.charStart && pos < span.charEnd ? span : null;
504
+ }
505
+
506
+ function findFirstSpanEndingAt(spanIndex, pos) {
507
+ const index = lowerBound(spanIndex.ends, pos);
508
+ if (index < spanIndex.spans.length && spanIndex.ends[index] === pos) {
509
+ return spanIndex.spans[index];
510
+ }
511
+ return null;
512
+ }
513
+
514
+ function findLastSpanEndingBeforeOrAt(spanIndex, pos) {
515
+ const index = upperBound(spanIndex.ends, pos) - 1;
516
+ if (index >= 0) {
517
+ return spanIndex.spans[index];
518
+ }
519
+ return null;
520
+ }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Table-cell context helpers.
3
+ *
4
+ * Handles detection of table-wrapped paragraph OOXML and extraction of only
5
+ * target paragraph content for safe `insertOoxml` replacement.
6
+ */
7
+
8
+ import { getDocumentParagraphs } from './format-extraction.js';
9
+ import { log } from '../adapters/logger.js';
10
+ import { buildParagraphOnlyPackage } from '../services/package-builder.js';
11
+ import { getElementsByTag } from '../core/xml-query.js';
12
+
13
+ const W14_NS = 'http://schemas.microsoft.com/office/word/2010/wordml';
14
+
15
+ /**
16
+ * Detects whether the current XML is table-wrapped and resolves target paragraph context.
17
+ *
18
+ * @param {Document} xmlDoc - XML document
19
+ * @param {string} originalText - Source paragraph text
20
+ * @param {Object} [options={}] - Detection options
21
+ * @param {string|null} [options.targetParagraphId=null] - Preferred paragraph id (`w14:paraId`)
22
+ * @returns {{
23
+ * hasTableWrapper: boolean,
24
+ * isTableCellParagraph: boolean,
25
+ * targetParagraph?: Element|null,
26
+ * paragraphs: Element[],
27
+ * paragraph: Element|null,
28
+ * tableElement: Element|null
29
+ * }}
30
+ */
31
+ export function detectTableCellContext(xmlDoc, originalText, options = {}) {
32
+ const { targetParagraphId = null } = options;
33
+ const tables = getElementsByTag(xmlDoc, 'w:tbl');
34
+ if (tables.length === 0) {
35
+ return { hasTableWrapper: false, isTableCellParagraph: false, paragraphs: [], paragraph: null, tableElement: null };
36
+ }
37
+
38
+ const allParagraphs = getDocumentParagraphs(xmlDoc);
39
+ const paragraphsInCells = allParagraphs.filter(p => {
40
+ let parent = p.parentNode;
41
+ while (parent) {
42
+ if (parent.nodeName === 'w:tc') return true;
43
+ parent = parent.parentNode;
44
+ }
45
+ return false;
46
+ });
47
+
48
+ log(`[OxmlEngine] Table wrapper detected: ${tables.length} tables, ${paragraphsInCells.length} paragraphs in cells`);
49
+
50
+ let targetParagraph = null;
51
+
52
+ // Most reliable selector when available (avoids ambiguous duplicate text matches in tables).
53
+ if (targetParagraphId) {
54
+ const normalizedTargetId = String(targetParagraphId).toUpperCase();
55
+ targetParagraph = paragraphsInCells.find(p => {
56
+ const paragraphId = getParagraphId(p);
57
+ return paragraphId && paragraphId.toUpperCase() === normalizedTargetId;
58
+ }) || null;
59
+
60
+ if (targetParagraph) {
61
+ log(`[OxmlEngine] Found target paragraph by paraId: "${targetParagraphId}"`);
62
+ } else {
63
+ log(`[OxmlEngine] paraId "${targetParagraphId}" not found in wrapper, falling back to text match`);
64
+ }
65
+ }
66
+
67
+ if (originalText && originalText.trim()) {
68
+ const normalizedTarget = originalText.trim();
69
+ if (!targetParagraph) {
70
+ for (const p of paragraphsInCells) {
71
+ const textNodes = getElementsByTag(p, 'w:t');
72
+ let paragraphText = '';
73
+ for (const t of textNodes) {
74
+ paragraphText += t.textContent || '';
75
+ }
76
+
77
+ if (paragraphText.trim() === normalizedTarget) {
78
+ targetParagraph = p;
79
+ log(`[OxmlEngine] Found target paragraph by text match: "${normalizedTarget.substring(0, 30)}..."`);
80
+ break;
81
+ }
82
+ }
83
+ }
84
+ }
85
+
86
+ return {
87
+ hasTableWrapper: true,
88
+ isTableCellParagraph: paragraphsInCells.length > 0,
89
+ targetParagraph,
90
+ paragraphs: paragraphsInCells,
91
+ paragraph: targetParagraph || paragraphsInCells[0] || null,
92
+ tableElement: tables[0]
93
+ };
94
+ }
95
+
96
+ /**
97
+ * Serializes one or more paragraphs without surrounding table wrappers.
98
+ *
99
+ * @param {Document} xmlDoc - XML document (unused, kept for signature compatibility)
100
+ * @param {Element|Element[]} paragraphs - Paragraph or paragraph array
101
+ * @param {XMLSerializer} serializer - Serializer instance
102
+ * @returns {string}
103
+ */
104
+ export function serializeParagraphOnly(xmlDoc, paragraphs, serializer) {
105
+ const paragraphArray = Array.isArray(paragraphs) ? paragraphs : [paragraphs];
106
+
107
+ let combinedXml = '';
108
+ for (const p of paragraphArray) {
109
+ if (!p) continue;
110
+ let pXml = serializer.serializeToString(p);
111
+ pXml = pXml.replace(/\s+xmlns:w="[^"]*"/g, '');
112
+ pXml = pXml.replace(/\s+xmlns:r="[^"]*"/g, '');
113
+ pXml = pXml.replace(/\s+xmlns:wp="[^"]*"/g, '');
114
+ combinedXml += pXml;
115
+ }
116
+
117
+ log(`[OxmlEngine] Stripping table wrapper, serializing ${paragraphArray.length} paragraphs`);
118
+ log(`[OxmlEngine] Paragraph XML preview: ${combinedXml.substring(0, 200)}...`);
119
+
120
+ return wrapParagraphInPackage(combinedXml);
121
+ }
122
+
123
+ /**
124
+ * Wraps paragraph XML in a complete OOXML package.
125
+ *
126
+ * @param {string} paragraphXml - Paragraph-only XML
127
+ * @returns {string}
128
+ */
129
+ export function wrapParagraphInPackage(paragraphXml) {
130
+ return buildParagraphOnlyPackage(paragraphXml);
131
+ }
132
+
133
+ /**
134
+ * Reads the best available paragraph identity from OOXML attributes.
135
+ *
136
+ * @param {Element} paragraph - Paragraph element
137
+ * @returns {string|null}
138
+ */
139
+ function getParagraphId(paragraph) {
140
+ if (!paragraph) return null;
141
+
142
+ const namespacedId = typeof paragraph.getAttributeNS === 'function'
143
+ ? paragraph.getAttributeNS(W14_NS, 'paraId')
144
+ : null;
145
+ if (namespacedId) return namespacedId;
146
+
147
+ return paragraph.getAttribute('w14:paraId')
148
+ || paragraph.getAttribute('w:paraId')
149
+ || paragraph.getAttribute('paraId')
150
+ || null;
151
+ }