@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,1169 @@
1
+ /**
2
+ * Standalone document-operation runner for redline/highlight/comment operations.
3
+ *
4
+ * This module centralizes the browser-demo operation bridge so host layers can
5
+ * stay focused on UI + prompt orchestration.
6
+ */
7
+
8
+ import { createParser, createSerializer } from '../adapters/xml-adapter.js';
9
+ import {
10
+ applyRedlineToOxml,
11
+ reconcileMarkdownTableOoxml,
12
+ applyHighlightToOoxml,
13
+ injectCommentsIntoOoxml,
14
+ getParagraphText as getParagraphTextFromOxml,
15
+ isMarkdownTableText,
16
+ findContainingWordElement,
17
+ resolveTargetParagraphWithSnapshot as resolveTargetParagraphWithSnapshotShared,
18
+ buildSingleLineListStructuralFallbackPlan,
19
+ executeSingleLineListStructuralFallback,
20
+ resolveSingleLineListFallbackNumberingAction,
21
+ recordSingleLineListFallbackExplicitSequence,
22
+ clearSingleLineListFallbackExplicitSequence,
23
+ enforceListBindingOnParagraphNodes,
24
+ synthesizeTableMarkdownFromMultilineCellEdit,
25
+ synthesizeExpandedListScopeEdit,
26
+ planListInsertionOnlyEdit,
27
+ getParagraphListInfo,
28
+ stripRedundantLeadingListMarkers,
29
+ stripSingleLineListMarkerPrefix,
30
+ normalizeWhitespaceForTargeting,
31
+ reserveNextNumberingIdPair,
32
+ remapNumberingPayloadForDocument,
33
+ overwriteParagraphNumIds,
34
+ extractFirstParagraphNumId,
35
+ buildExplicitDecimalMultilevelNumberingXml,
36
+ inferTableReplacementParagraphBlock,
37
+ resolveParagraphRangeByRefs,
38
+ extractReplacementNodesFromOoxml,
39
+ normalizeBodySectionOrderStandalone
40
+ } from '../standalone.js';
41
+
42
+ const NS_W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
43
+
44
+ function getParagraphText(paragraph) {
45
+ return getParagraphTextFromOxml(paragraph);
46
+ }
47
+
48
+ function resolveTargetParagraph(xmlDoc, targetText, targetRef, opType, runtimeContext = null, options = {}) {
49
+ const onInfo = typeof options?.onInfo === 'function' ? options.onInfo : () => {};
50
+ const onWarn = typeof options?.onWarn === 'function' ? options.onWarn : () => {};
51
+ return resolveTargetParagraphWithSnapshotShared(xmlDoc, {
52
+ targetText,
53
+ targetRef,
54
+ opType,
55
+ targetRefSnapshot: runtimeContext?.targetRefSnapshot || null,
56
+ onInfo,
57
+ onWarn
58
+ });
59
+ }
60
+
61
+ function extractReplacementNodes(outputOxml) {
62
+ return extractReplacementNodesFromOoxml(outputOxml);
63
+ }
64
+
65
+ function normalizeBodySectionOrder(xmlDoc) {
66
+ normalizeBodySectionOrderStandalone(xmlDoc);
67
+ }
68
+
69
+ function getDirectWordChild(element, localName) {
70
+ if (!element) return null;
71
+ return Array.from(element.childNodes || []).find(
72
+ node => node && node.nodeType === 1 && node.namespaceURI === NS_W && node.localName === localName
73
+ ) || null;
74
+ }
75
+
76
+ function computeTableIndexInDocument(xmlDoc, tableElement) {
77
+ if (!xmlDoc || !tableElement) return null;
78
+ const tables = Array.from(xmlDoc.getElementsByTagNameNS(NS_W, 'tbl'));
79
+ const idx = tables.indexOf(tableElement);
80
+ return idx >= 0 ? idx + 1 : null;
81
+ }
82
+
83
+ function normalizeMultilineTableStructuralPayload(text) {
84
+ return String(text || '')
85
+ .replace(/\r\n/g, '\n')
86
+ .split('\n')
87
+ .map(line => line.trim())
88
+ .filter(Boolean)
89
+ .join('\n');
90
+ }
91
+
92
+ function computeTableStructuralDedupeKey(xmlDoc, containingTable, modifiedText) {
93
+ const tableIndex = computeTableIndexInDocument(xmlDoc, containingTable);
94
+ if (!Number.isInteger(tableIndex) || tableIndex < 1) return null;
95
+ const normalizedPayload = normalizeMultilineTableStructuralPayload(modifiedText);
96
+ if (!normalizedPayload) return null;
97
+ return `table:${tableIndex}|payload:${normalizedPayload}`;
98
+ }
99
+
100
+ function getNextTrackedChangeId(xmlDoc) {
101
+ let maxId = 999;
102
+ const revisionNodes = [
103
+ ...Array.from(xmlDoc.getElementsByTagNameNS(NS_W, 'ins')),
104
+ ...Array.from(xmlDoc.getElementsByTagNameNS(NS_W, 'del'))
105
+ ];
106
+ for (const node of revisionNodes) {
107
+ const raw = node.getAttribute('w:id') || node.getAttribute('id') || '';
108
+ const parsed = Number.parseInt(raw, 10);
109
+ if (Number.isFinite(parsed)) maxId = Math.max(maxId, parsed);
110
+ }
111
+ return maxId + 1;
112
+ }
113
+
114
+ function ensureListProperties(xmlDoc, paragraph, ilvl, numId) {
115
+ let pPr = getDirectWordChild(paragraph, 'pPr');
116
+ if (!pPr) {
117
+ pPr = xmlDoc.createElementNS(NS_W, 'w:pPr');
118
+ paragraph.insertBefore(pPr, paragraph.firstChild);
119
+ }
120
+
121
+ let numPr = getDirectWordChild(pPr, 'numPr');
122
+ if (!numPr) {
123
+ numPr = xmlDoc.createElementNS(NS_W, 'w:numPr');
124
+ pPr.appendChild(numPr);
125
+ }
126
+
127
+ let ilvlEl = getDirectWordChild(numPr, 'ilvl');
128
+ if (!ilvlEl) {
129
+ ilvlEl = xmlDoc.createElementNS(NS_W, 'w:ilvl');
130
+ numPr.appendChild(ilvlEl);
131
+ }
132
+ ilvlEl.setAttribute('w:val', String(Math.max(0, Number.parseInt(ilvl, 10) || 0)));
133
+
134
+ let numIdEl = getDirectWordChild(numPr, 'numId');
135
+ if (!numIdEl) {
136
+ numIdEl = xmlDoc.createElementNS(NS_W, 'w:numId');
137
+ numPr.appendChild(numIdEl);
138
+ }
139
+ numIdEl.setAttribute('w:val', String(numId));
140
+ }
141
+
142
+ function buildInsertedListParagraph(xmlDoc, anchorParagraph, entry, revisionId, author, dateIso, options = {}) {
143
+ const generateRedlines = options.generateRedlines !== false;
144
+ const paragraph = xmlDoc.createElementNS(NS_W, 'w:p');
145
+
146
+ const anchorPPr = getDirectWordChild(anchorParagraph, 'pPr');
147
+ if (anchorPPr) {
148
+ paragraph.appendChild(anchorPPr.cloneNode(true));
149
+ }
150
+ ensureListProperties(xmlDoc, paragraph, entry.ilvl, entry.numId);
151
+
152
+ const run = xmlDoc.createElementNS(NS_W, 'w:r');
153
+ const anchorFirstRun = Array.from(anchorParagraph.getElementsByTagNameNS(NS_W, 'r'))[0] || null;
154
+ const anchorRunPr = anchorFirstRun ? getDirectWordChild(anchorFirstRun, 'rPr') : null;
155
+ if (anchorRunPr) {
156
+ run.appendChild(anchorRunPr.cloneNode(true));
157
+ }
158
+
159
+ const textNode = xmlDoc.createElementNS(NS_W, 'w:t');
160
+ const safeText = String(entry.text || '').trim();
161
+ if (/^\s|\s$/.test(safeText)) textNode.setAttribute('xml:space', 'preserve');
162
+ textNode.textContent = safeText;
163
+ run.appendChild(textNode);
164
+ if (generateRedlines) {
165
+ const ins = xmlDoc.createElementNS(NS_W, 'w:ins');
166
+ ins.setAttribute('w:id', String(revisionId));
167
+ ins.setAttribute('w:author', author || 'Browser Demo AI');
168
+ ins.setAttribute('w:date', dateIso);
169
+ ins.appendChild(run);
170
+ paragraph.appendChild(ins);
171
+ } else {
172
+ paragraph.appendChild(run);
173
+ }
174
+
175
+ return paragraph;
176
+ }
177
+
178
+ function serializeParagraphRangeAsDocument(paragraphs, serializer) {
179
+ const paragraphXml = (paragraphs || [])
180
+ .map(paragraph => serializer.serializeToString(paragraph))
181
+ .join('');
182
+ return `<w:document xmlns:w="${NS_W}"><w:body>${paragraphXml}</w:body></w:document>`;
183
+ }
184
+
185
+ const LIST_LINE_REGEX = /^(\s*)((?:\d+(?:\.\d+)*\.?|\((?:\d+|[a-zA-Z]|[ivxlcIVXLC]+)\)|[a-zA-Z]\.|[ivxlcIVXLC]+\.|[-*+\u2022]))\s+(.*)$/;
186
+ const INLINE_LIST_MARKER_REGEX = /(?:^|\s)(?:\d+(?:\.\d+)*\.?|[A-Za-z]\.|[ivxlcIVXLC]+\.)\s+/g;
187
+
188
+ function parseOutlineLevelFromMarker(marker) {
189
+ const normalized = String(marker || '').trim();
190
+ if (!/^\d+(?:\.\d+)+\.?$/.test(normalized)) return null;
191
+ const parts = normalized.replace(/\.$/, '').split('.');
192
+ return Math.max(0, parts.length - 1);
193
+ }
194
+
195
+ function parseModifiedListLines(modifiedText) {
196
+ const lines = String(modifiedText || '')
197
+ .split(/\r?\n/g)
198
+ .map(line => line.trimEnd())
199
+ .filter(line => line.trim().length > 0);
200
+ if (lines.length < 2) return null;
201
+
202
+ const parsed = [];
203
+ for (const line of lines) {
204
+ const markerMatch = line.match(LIST_LINE_REGEX);
205
+ if (!markerMatch) return null;
206
+ const marker = markerMatch[2];
207
+ const markerType = /^[-*+\u2022]$/.test(marker) ? 'bullet' : 'numbered';
208
+ parsed.push({
209
+ marker,
210
+ markerType,
211
+ level: Math.floor((markerMatch[1] || '').length / 2),
212
+ outlineLevel: markerType === 'numbered' ? parseOutlineLevelFromMarker(marker) : null,
213
+ text: stripRedundantLeadingListMarkers(markerMatch[3])
214
+ });
215
+ }
216
+ return parsed.length >= 2 ? parsed : null;
217
+ }
218
+
219
+ function buildExplicitRangeInsertionEntries(explicitRangeParagraphs, modifiedText) {
220
+ if (!Array.isArray(explicitRangeParagraphs) || explicitRangeParagraphs.length === 0) return null;
221
+ const parsedLines = parseModifiedListLines(modifiedText);
222
+ if (!parsedLines) return null;
223
+
224
+ const originalTexts = explicitRangeParagraphs.map(paragraph =>
225
+ normalizeWhitespaceForTargeting(getParagraphText(paragraph))
226
+ );
227
+ const modifiedTexts = parsedLines.map(item => normalizeWhitespaceForTargeting(item.text));
228
+ if (originalTexts.some(text => !text) || modifiedTexts.some(text => !text)) return null;
229
+
230
+ const listInfos = explicitRangeParagraphs.map(paragraph => getParagraphListInfo(paragraph));
231
+ if (listInfos.some(info => !info || !info.numId)) return null;
232
+ const baselineNumId = String(listInfos[0].numId);
233
+ if (listInfos.some(info => String(info.numId) !== baselineNumId)) return null;
234
+
235
+ const matchedPairs = [];
236
+ let originalIndex = 0;
237
+ for (let modifiedIndex = 0; modifiedIndex < modifiedTexts.length && originalIndex < originalTexts.length; modifiedIndex += 1) {
238
+ if (modifiedTexts[modifiedIndex] === originalTexts[originalIndex]) {
239
+ matchedPairs.push({ originalIndex, modifiedIndex });
240
+ originalIndex += 1;
241
+ }
242
+ }
243
+ if (originalIndex !== originalTexts.length) return null;
244
+
245
+ const matchedModifiedIndexes = new Set(matchedPairs.map(pair => pair.modifiedIndex));
246
+ const insertedIndexes = [];
247
+ for (let idx = 0; idx < parsedLines.length; idx += 1) {
248
+ if (!matchedModifiedIndexes.has(idx)) insertedIndexes.push(idx);
249
+ }
250
+ if (insertedIndexes.length === 0) return null;
251
+
252
+ const baseIndentLevel = parsedLines[0]?.level || 0;
253
+ return insertedIndexes.map(modifiedIndex => {
254
+ const nextMatch = matchedPairs.find(pair => pair.modifiedIndex > modifiedIndex) || null;
255
+ const prevMatch = [...matchedPairs].reverse().find(pair => pair.modifiedIndex < modifiedIndex) || null;
256
+ const referenceMatch = nextMatch || prevMatch;
257
+ if (!referenceMatch) return null;
258
+ const referenceListInfo = listInfos[referenceMatch.originalIndex] || listInfos[0];
259
+ if (!referenceListInfo) return null;
260
+
261
+ const entry = parsedLines[modifiedIndex];
262
+ const relativeLevel = Math.max(0, entry.level - baseIndentLevel);
263
+ const explicitOutlineLevel = Number.isInteger(entry.outlineLevel) ? entry.outlineLevel : null;
264
+ return {
265
+ text: entry.text,
266
+ markerType: entry.markerType,
267
+ ilvl: explicitOutlineLevel != null
268
+ ? explicitOutlineLevel
269
+ : Math.max(0, (referenceListInfo.ilvl || 0) + relativeLevel),
270
+ numId: String(referenceListInfo.numId),
271
+ insertBeforeOriginalIndex: nextMatch ? nextMatch.originalIndex : null
272
+ };
273
+ }).filter(Boolean);
274
+ }
275
+
276
+ function applyExplicitRangeListInsertions({
277
+ xmlDoc,
278
+ explicitRangeParagraphs,
279
+ insertionEntries,
280
+ generateRedlines,
281
+ author
282
+ }) {
283
+ if (!Array.isArray(explicitRangeParagraphs) || explicitRangeParagraphs.length === 0) return false;
284
+ if (!Array.isArray(insertionEntries) || insertionEntries.length === 0) return false;
285
+
286
+ const parent = explicitRangeParagraphs[0].parentNode;
287
+ if (!parent || explicitRangeParagraphs.some(paragraph => paragraph.parentNode !== parent)) return false;
288
+
289
+ const tailInsertionPoint = explicitRangeParagraphs[explicitRangeParagraphs.length - 1].nextSibling;
290
+ const dateIso = generateRedlines ? new Date().toISOString() : null;
291
+ let revisionId = generateRedlines ? getNextTrackedChangeId(xmlDoc) : null;
292
+
293
+ for (const entry of insertionEntries) {
294
+ const referenceParagraph = entry.insertBeforeOriginalIndex != null
295
+ ? explicitRangeParagraphs[entry.insertBeforeOriginalIndex]
296
+ : explicitRangeParagraphs[explicitRangeParagraphs.length - 1];
297
+ if (!referenceParagraph) return false;
298
+
299
+ const listParagraph = buildInsertedListParagraph(
300
+ xmlDoc,
301
+ referenceParagraph,
302
+ {
303
+ ilvl: entry.ilvl,
304
+ markerType: entry.markerType,
305
+ numId: entry.numId,
306
+ text: entry.text
307
+ },
308
+ revisionId,
309
+ author,
310
+ dateIso,
311
+ { generateRedlines }
312
+ );
313
+ if (generateRedlines) revisionId += 1;
314
+
315
+ if (entry.insertBeforeOriginalIndex != null) {
316
+ parent.insertBefore(listParagraph, referenceParagraph);
317
+ } else {
318
+ parent.insertBefore(listParagraph, tailInsertionPoint);
319
+ }
320
+ }
321
+
322
+ normalizeBodySectionOrder(xmlDoc);
323
+ return true;
324
+ }
325
+
326
+ function countWords(text) {
327
+ return String(text || '')
328
+ .trim()
329
+ .split(/\s+/)
330
+ .filter(Boolean)
331
+ .length;
332
+ }
333
+
334
+ function hasMultipleInlineListMarkers(text) {
335
+ const source = String(text || '');
336
+ if (!source) return false;
337
+
338
+ let count = 0;
339
+ const regex = new RegExp(INLINE_LIST_MARKER_REGEX.source, INLINE_LIST_MARKER_REGEX.flags);
340
+ while (regex.exec(source)) {
341
+ count += 1;
342
+ if (count >= 2) return true;
343
+ }
344
+ return false;
345
+ }
346
+
347
+ function deriveSingleParagraphListAdjacencyInsertion(currentParagraphText, modifiedText) {
348
+ const rawCurrent = String(currentParagraphText || '').trim();
349
+ const rawModified = String(modifiedText || '').trim();
350
+ if (!rawCurrent || !rawModified || rawModified === rawCurrent) return null;
351
+ if (rawModified.includes('\n')) return null;
352
+ const normalizedCurrent = normalizeWhitespaceForTargeting(rawCurrent);
353
+
354
+ const minWords = 6;
355
+ const sanitizeCandidate = text => stripRedundantLeadingListMarkers(String(text || '').trim()).trim();
356
+ const buildCandidate = (position, text) => {
357
+ const cleanedText = sanitizeCandidate(text);
358
+ const cleanedNormalized = normalizeWhitespaceForTargeting(cleanedText);
359
+ if (!cleanedText || cleanedText === rawCurrent) return null;
360
+ if (countWords(cleanedText) < minWords) return null;
361
+ if (hasMultipleInlineListMarkers(cleanedText)) return null;
362
+ if (normalizedCurrent && cleanedNormalized.includes(normalizedCurrent)) return null;
363
+ return { position, text: cleanedText };
364
+ };
365
+
366
+ if (rawModified.endsWith(rawCurrent)) {
367
+ const prefix = rawModified.slice(0, rawModified.length - rawCurrent.length);
368
+ const candidate = buildCandidate('before', prefix);
369
+ if (candidate) return candidate;
370
+ }
371
+
372
+ if (rawModified.startsWith(rawCurrent)) {
373
+ const suffix = rawModified.slice(rawCurrent.length);
374
+ const candidate = buildCandidate('after', suffix);
375
+ if (candidate) return candidate;
376
+ }
377
+
378
+ const normalizedModified = normalizeWhitespaceForTargeting(rawModified);
379
+ if (!normalizedCurrent || normalizedCurrent === normalizedModified) return null;
380
+
381
+ if (normalizedModified.endsWith(normalizedCurrent)) {
382
+ const prefix = normalizedModified.slice(0, normalizedModified.length - normalizedCurrent.length);
383
+ const candidate = buildCandidate('before', prefix);
384
+ if (candidate) return candidate;
385
+ }
386
+
387
+ if (normalizedModified.startsWith(normalizedCurrent)) {
388
+ const suffix = normalizedModified.slice(normalizedCurrent.length);
389
+ const candidate = buildCandidate('after', suffix);
390
+ if (candidate) return candidate;
391
+ }
392
+
393
+ return null;
394
+ }
395
+
396
+ function deriveSingleParagraphPlainAdjacencyInsertion(currentParagraphText, modifiedText) {
397
+ const rawCurrent = String(currentParagraphText || '').trim();
398
+ const rawModified = String(modifiedText || '');
399
+ if (!rawCurrent || !rawModified || !rawModified.includes('\n')) return null;
400
+
401
+ const lines = rawModified
402
+ .split(/\r?\n/g)
403
+ .map(line => String(line || '').trim())
404
+ .filter(Boolean);
405
+ if (lines.length < 2) return null;
406
+
407
+ const normalize = value => normalizeWhitespaceForTargeting(String(value || ''));
408
+ const normalizedCurrent = normalize(rawCurrent);
409
+ const normalizedFirst = normalize(lines[0]);
410
+ const normalizedLast = normalize(lines[lines.length - 1]);
411
+
412
+ if (normalizedLast === normalizedCurrent) {
413
+ const paragraphs = lines.slice(0, -1).map(line => String(line || '').trim()).filter(Boolean);
414
+ if (paragraphs.length > 0) {
415
+ return { position: 'before', paragraphs };
416
+ }
417
+ }
418
+
419
+ if (normalizedFirst === normalizedCurrent) {
420
+ const paragraphs = lines.slice(1).map(line => String(line || '').trim()).filter(Boolean);
421
+ if (paragraphs.length > 0) {
422
+ return { position: 'after', paragraphs };
423
+ }
424
+ }
425
+
426
+ return null;
427
+ }
428
+
429
+ function buildFallbackInsertedPlainParagraph(xmlDoc, text, revisionId, author, dateIso, options = {}) {
430
+ const generateRedlines = options.generateRedlines !== false;
431
+ const paragraph = xmlDoc.createElementNS(NS_W, 'w:p');
432
+ const run = xmlDoc.createElementNS(NS_W, 'w:r');
433
+ const textNode = xmlDoc.createElementNS(NS_W, 'w:t');
434
+ const safeText = String(text || '');
435
+ if (/^\s|\s$/.test(safeText)) textNode.setAttribute('xml:space', 'preserve');
436
+ textNode.textContent = safeText;
437
+ run.appendChild(textNode);
438
+
439
+ if (generateRedlines) {
440
+ const ins = xmlDoc.createElementNS(NS_W, 'w:ins');
441
+ ins.setAttribute('w:id', String(revisionId));
442
+ ins.setAttribute('w:author', author || 'Browser Demo AI');
443
+ ins.setAttribute('w:date', dateIso);
444
+ ins.appendChild(run);
445
+ paragraph.appendChild(ins);
446
+ } else {
447
+ paragraph.appendChild(run);
448
+ }
449
+
450
+ return paragraph;
451
+ }
452
+
453
+ function buildEmptyParagraphTemplateFromAnchor(xmlDoc, anchorParagraph) {
454
+ const paragraph = xmlDoc.createElementNS(NS_W, 'w:p');
455
+ const anchorPPr = getDirectWordChild(anchorParagraph, 'pPr');
456
+ if (anchorPPr) paragraph.appendChild(anchorPPr.cloneNode(true));
457
+
458
+ const run = xmlDoc.createElementNS(NS_W, 'w:r');
459
+ const anchorFirstRun = Array.from(anchorParagraph.getElementsByTagNameNS(NS_W, 'r'))[0] || null;
460
+ const anchorRunPr = anchorFirstRun ? getDirectWordChild(anchorFirstRun, 'rPr') : null;
461
+ if (anchorRunPr) run.appendChild(anchorRunPr.cloneNode(true));
462
+
463
+ const textNode = xmlDoc.createElementNS(NS_W, 'w:t');
464
+ textNode.textContent = '';
465
+ run.appendChild(textNode);
466
+ paragraph.appendChild(run);
467
+ return paragraph;
468
+ }
469
+
470
+ function wrapParagraphContentInInsertion(xmlDoc, paragraph, revisionId, author, dateIso) {
471
+ const wrappedParagraph = xmlDoc.createElementNS(NS_W, 'w:p');
472
+ const pPr = getDirectWordChild(paragraph, 'pPr');
473
+ if (pPr) wrappedParagraph.appendChild(pPr.cloneNode(true));
474
+
475
+ const ins = xmlDoc.createElementNS(NS_W, 'w:ins');
476
+ ins.setAttribute('w:id', String(revisionId));
477
+ ins.setAttribute('w:author', author || 'Browser Demo AI');
478
+ ins.setAttribute('w:date', dateIso);
479
+
480
+ for (const child of Array.from(paragraph.childNodes || [])) {
481
+ if (child?.nodeType === 1 && child.namespaceURI === NS_W && child.localName === 'pPr') continue;
482
+ ins.appendChild(child.cloneNode(true));
483
+ }
484
+
485
+ wrappedParagraph.appendChild(ins);
486
+ return wrappedParagraph;
487
+ }
488
+
489
+ async function buildInsertedPlainParagraph(xmlDoc, anchorParagraph, text, revisionId, author, dateIso, options = {}) {
490
+ const generateRedlines = options.generateRedlines !== false;
491
+ const serializer = createSerializer();
492
+ const templateParagraph = buildEmptyParagraphTemplateFromAnchor(xmlDoc, anchorParagraph);
493
+ const templateXml = serializer.serializeToString(templateParagraph);
494
+ const markdownResult = await applyRedlineToOxml(
495
+ templateXml,
496
+ '',
497
+ String(text || ''),
498
+ {
499
+ author,
500
+ generateRedlines: false
501
+ }
502
+ );
503
+
504
+ let sourceParagraph = null;
505
+ if (typeof markdownResult?.oxml === 'string') {
506
+ const extracted = extractReplacementNodes(markdownResult.oxml);
507
+ sourceParagraph = (extracted.replacementNodes || []).find(
508
+ node => node && node.nodeType === 1 && node.namespaceURI === NS_W && node.localName === 'p'
509
+ ) || null;
510
+ }
511
+
512
+ if (!sourceParagraph) {
513
+ return buildFallbackInsertedPlainParagraph(
514
+ xmlDoc,
515
+ text,
516
+ revisionId,
517
+ author,
518
+ dateIso,
519
+ { generateRedlines }
520
+ );
521
+ }
522
+
523
+ if (!generateRedlines) {
524
+ return sourceParagraph;
525
+ }
526
+
527
+ return wrapParagraphContentInInsertion(xmlDoc, sourceParagraph, revisionId, author, dateIso);
528
+ }
529
+
530
+ async function tryExplicitDecimalHeaderListConversion({
531
+ xmlDoc,
532
+ serializer,
533
+ targetParagraph,
534
+ currentParagraphText,
535
+ modifiedText,
536
+ author,
537
+ runtimeContext,
538
+ generateRedlines = true,
539
+ onInfo = () => {}
540
+ }) {
541
+ if (!targetParagraph) return null;
542
+ const scopedParagraphOxml = serializer.serializeToString(targetParagraph);
543
+ const explicitPlan = buildSingleLineListStructuralFallbackPlan({
544
+ oxml: scopedParagraphOxml,
545
+ originalText: currentParagraphText,
546
+ modifiedText,
547
+ allowExistingList: false
548
+ });
549
+ if (
550
+ !explicitPlan ||
551
+ explicitPlan.numberingKey !== 'numbered:decimal:single' ||
552
+ !Number.isInteger(explicitPlan.startAt) ||
553
+ explicitPlan.startAt < 1
554
+ ) {
555
+ return null;
556
+ }
557
+
558
+ const strippedContent = stripSingleLineListMarkerPrefix(explicitPlan.listInput || modifiedText);
559
+ if (!strippedContent) return null;
560
+
561
+ onInfo('[List] Applying explicit numeric header conversion with direct list binding.');
562
+ const redlineResult = await applyRedlineToOxml(
563
+ serializer.serializeToString(targetParagraph),
564
+ currentParagraphText,
565
+ strippedContent,
566
+ {
567
+ author,
568
+ generateRedlines
569
+ }
570
+ );
571
+ if (!redlineResult?.hasChanges || typeof redlineResult?.oxml !== 'string') return null;
572
+
573
+ const extracted = extractReplacementNodes(redlineResult.oxml);
574
+ const replacementNodes = extracted.replacementNodes;
575
+ const numberingAction = resolveSingleLineListFallbackNumberingAction(
576
+ explicitPlan,
577
+ runtimeContext?.listFallbackSequenceState || null
578
+ );
579
+
580
+ const explicitStart = explicitPlan.startAt;
581
+ const numberingState = runtimeContext?.numberingIdState || null;
582
+ let appliedNumId = null;
583
+ let numberingXml = null;
584
+
585
+ if (numberingAction.type === 'explicitReuse' && numberingAction.numId) {
586
+ appliedNumId = String(numberingAction.numId);
587
+ onInfo(`[List] Reusing explicit-start list sequence (${numberingAction.numberingKey} -> numId ${appliedNumId}, next ${explicitStart + 1}).`);
588
+ } else {
589
+ const reservedPair = reserveNextNumberingIdPair(numberingState);
590
+ if (!reservedPair) return null;
591
+
592
+ appliedNumId = String(reservedPair.numId);
593
+ numberingXml = buildExplicitDecimalMultilevelNumberingXml(
594
+ reservedPair.numId,
595
+ reservedPair.abstractNumId,
596
+ explicitStart
597
+ );
598
+
599
+ if (numberingAction.type === 'explicitStartNew') {
600
+ onInfo(`[List] Started explicit-start list sequence (${numberingAction.numberingKey} -> numId ${appliedNumId}).`);
601
+ }
602
+ onInfo(`[List] Using isolated explicit-start numbering (start ${explicitStart}, numId ${appliedNumId}, abstractNumId ${reservedPair.abstractNumId}).`);
603
+ }
604
+
605
+ if (explicitPlan.numberingKey && runtimeContext?.listFallbackSharedNumIdByKey instanceof Map) {
606
+ runtimeContext.listFallbackSharedNumIdByKey.delete(explicitPlan.numberingKey);
607
+ }
608
+
609
+ if (numberingAction.type === 'explicitStartNew' || numberingAction.type === 'explicitReuse') {
610
+ recordSingleLineListFallbackExplicitSequence(
611
+ runtimeContext?.listFallbackSequenceState || null,
612
+ numberingAction.numberingKey || explicitPlan.numberingKey,
613
+ appliedNumId,
614
+ explicitStart
615
+ );
616
+ } else {
617
+ clearSingleLineListFallbackExplicitSequence(
618
+ runtimeContext?.listFallbackSequenceState || null,
619
+ numberingAction.numberingKey || explicitPlan.numberingKey
620
+ );
621
+ }
622
+
623
+ enforceListBindingOnParagraphNodes(replacementNodes, {
624
+ numId: appliedNumId,
625
+ ilvl: 0,
626
+ clearParagraphPropertyChanges: true,
627
+ removeListPropertyNode: true
628
+ });
629
+
630
+ const parent = targetParagraph.parentNode;
631
+ if (!parent) return null;
632
+ for (const node of replacementNodes) parent.insertBefore(xmlDoc.importNode(node, true), targetParagraph);
633
+ parent.removeChild(targetParagraph);
634
+ normalizeBodySectionOrder(xmlDoc);
635
+ return { documentXml: serializer.serializeToString(xmlDoc), hasChanges: true, numberingXml };
636
+ }
637
+
638
+ async function trySingleParagraphListStructuralFallback({
639
+ xmlDoc,
640
+ serializer,
641
+ targetParagraph,
642
+ currentParagraphText,
643
+ modifiedText,
644
+ author,
645
+ runtimeContext,
646
+ generateRedlines = true,
647
+ onInfo = () => {}
648
+ }) {
649
+ if (!targetParagraph) return null;
650
+
651
+ const scopedParagraphOxml = serializer.serializeToString(targetParagraph);
652
+ const fallbackPlan = buildSingleLineListStructuralFallbackPlan({
653
+ oxml: scopedParagraphOxml,
654
+ originalText: currentParagraphText,
655
+ modifiedText,
656
+ allowExistingList: false
657
+ });
658
+ if (!fallbackPlan) return null;
659
+
660
+ onInfo('[List] No textual diff but list marker detected; forcing structural list conversion fallback.');
661
+ const fallbackResult = await executeSingleLineListStructuralFallback(fallbackPlan, {
662
+ author,
663
+ generateRedlines,
664
+ setAbstractStartOverride: false
665
+ });
666
+ if (!fallbackResult?.hasChanges || !fallbackResult?.oxml) {
667
+ onInfo('[List] Structural list fallback produced no valid OOXML payload.');
668
+ return null;
669
+ }
670
+
671
+ const extracted = extractReplacementNodes(fallbackResult.oxml);
672
+ let replacementNodes = extracted.replacementNodes;
673
+ let numberingXml = extracted.numberingXml || fallbackResult?.numberingXml || null;
674
+ const hasExplicitStartAt = Number.isInteger(fallbackPlan?.startAt) && fallbackPlan.startAt > 0;
675
+ const numberingKey = fallbackResult?.listStructuralFallbackKey || fallbackPlan?.numberingKey || null;
676
+ const numberingAction = resolveSingleLineListFallbackNumberingAction(
677
+ fallbackPlan,
678
+ runtimeContext?.listFallbackSequenceState || null
679
+ );
680
+ if (hasExplicitStartAt) {
681
+ const explicitStart = fallbackPlan.startAt;
682
+ let explicitNumIdForBinding = null;
683
+ const numberingState = runtimeContext?.numberingIdState || null;
684
+ if (numberingAction.type === 'explicitReuse' && numberingAction.numId) {
685
+ explicitNumIdForBinding = String(numberingAction.numId);
686
+ numberingXml = null;
687
+ onInfo(`[List] Reusing explicit-start list sequence (${numberingAction.numberingKey} -> numId ${explicitNumIdForBinding}, next ${explicitStart + 1}).`);
688
+ } else if (numberingState) {
689
+ const reservedPair = reserveNextNumberingIdPair(numberingState);
690
+ if (!reservedPair) return null;
691
+ overwriteParagraphNumIds(replacementNodes, reservedPair.numId);
692
+ explicitNumIdForBinding = String(reservedPair.numId);
693
+ numberingXml = buildExplicitDecimalMultilevelNumberingXml(
694
+ reservedPair.numId,
695
+ reservedPair.abstractNumId,
696
+ explicitStart
697
+ );
698
+ if (numberingAction.type === 'explicitStartNew') {
699
+ onInfo(`[List] Started explicit-start list sequence (${numberingAction.numberingKey} -> numId ${reservedPair.numId}).`);
700
+ }
701
+ onInfo(`[List] Using isolated explicit-start numbering (start ${explicitStart}, numId ${reservedPair.numId}, abstractNumId ${reservedPair.abstractNumId}).`);
702
+ } else {
703
+ const generatedNumId = extractFirstParagraphNumId(replacementNodes);
704
+ explicitNumIdForBinding = generatedNumId ? String(generatedNumId) : null;
705
+ onInfo(`[List] Using isolated list numbering with explicit start ${explicitStart}${generatedNumId ? ` (numId ${generatedNumId})` : ''}.`);
706
+ }
707
+
708
+ if (numberingKey && runtimeContext?.listFallbackSharedNumIdByKey instanceof Map) {
709
+ runtimeContext.listFallbackSharedNumIdByKey.delete(numberingKey);
710
+ }
711
+ if (numberingAction.type === 'explicitStartNew' || numberingAction.type === 'explicitReuse') {
712
+ recordSingleLineListFallbackExplicitSequence(
713
+ runtimeContext?.listFallbackSequenceState || null,
714
+ numberingAction.numberingKey || numberingKey,
715
+ explicitNumIdForBinding,
716
+ explicitStart
717
+ );
718
+ } else {
719
+ clearSingleLineListFallbackExplicitSequence(
720
+ runtimeContext?.listFallbackSequenceState || null,
721
+ numberingAction.numberingKey || numberingKey
722
+ );
723
+ }
724
+
725
+ if (explicitNumIdForBinding) {
726
+ enforceListBindingOnParagraphNodes(replacementNodes, {
727
+ numId: explicitNumIdForBinding,
728
+ ilvl: 0,
729
+ clearParagraphPropertyChanges: true,
730
+ removeListPropertyNode: true
731
+ });
732
+ }
733
+ } else {
734
+ if (numberingXml && runtimeContext?.numberingIdState) {
735
+ const normalizedNumbering = remapNumberingPayloadForDocument(numberingXml, replacementNodes, runtimeContext.numberingIdState);
736
+ replacementNodes = normalizedNumbering.replacementNodes;
737
+ numberingXml = normalizedNumbering.numberingXml;
738
+ }
739
+ clearSingleLineListFallbackExplicitSequence(
740
+ runtimeContext?.listFallbackSequenceState || null,
741
+ numberingAction.numberingKey || numberingKey
742
+ );
743
+ }
744
+
745
+ if (!hasExplicitStartAt && runtimeContext?.listFallbackSharedNumIdByKey instanceof Map) {
746
+ const sharedNumId = numberingKey ? runtimeContext.listFallbackSharedNumIdByKey.get(numberingKey) : null;
747
+ if (sharedNumId) {
748
+ overwriteParagraphNumIds(replacementNodes, sharedNumId);
749
+ numberingXml = null;
750
+ onInfo(`[List] Reusing shared list numbering (${numberingKey} -> numId ${sharedNumId}).`);
751
+ } else if (numberingKey) {
752
+ const generatedNumId = extractFirstParagraphNumId(replacementNodes);
753
+ if (generatedNumId) {
754
+ runtimeContext.listFallbackSharedNumIdByKey.set(numberingKey, generatedNumId);
755
+ onInfo(`[List] Captured shared list numbering (${numberingKey} -> numId ${generatedNumId}).`);
756
+ }
757
+ }
758
+ }
759
+
760
+ const parent = targetParagraph.parentNode;
761
+ if (!parent) return null;
762
+ for (const node of replacementNodes) parent.insertBefore(xmlDoc.importNode(node, true), targetParagraph);
763
+ parent.removeChild(targetParagraph);
764
+ normalizeBodySectionOrder(xmlDoc);
765
+ return { documentXml: serializer.serializeToString(xmlDoc), hasChanges: true, numberingXml };
766
+ }
767
+
768
+ async function applyToParagraphByExactText(documentXml, targetText, modifiedText, author, targetRef = null, targetEndRef = null, runtimeContext = null, options = {}) {
769
+ const generateRedlines = options.generateRedlines !== false;
770
+ const onInfo = typeof options?.onInfo === 'function' ? options.onInfo : () => {};
771
+ const onWarn = typeof options?.onWarn === 'function' ? options.onWarn : () => {};
772
+ const parser = createParser();
773
+ const serializer = createSerializer();
774
+ const xmlDoc = parser.parseFromString(documentXml, 'application/xml');
775
+ const resolved = resolveTargetParagraph(xmlDoc, targetText, targetRef, 'redline', runtimeContext, { onInfo, onWarn });
776
+ const targetParagraph = resolved.paragraph;
777
+ const currentParagraphText = getParagraphText(targetParagraph).trim();
778
+ const containingTable = findContainingWordElement(targetParagraph, 'tbl');
779
+ const rawTableStructuralCandidate = !!containingTable
780
+ && !targetEndRef
781
+ && typeof modifiedText === 'string'
782
+ && modifiedText.includes('\n')
783
+ && !isMarkdownTableText(modifiedText);
784
+ const rawTableStructuralDedupeKey = rawTableStructuralCandidate
785
+ ? computeTableStructuralDedupeKey(xmlDoc, containingTable, modifiedText)
786
+ : null;
787
+ const tableStructuralDedupes = runtimeContext?.tableStructuralRedlineKeys instanceof Set
788
+ ? runtimeContext.tableStructuralRedlineKeys
789
+ : null;
790
+ if (rawTableStructuralDedupeKey && tableStructuralDedupes?.has(rawTableStructuralDedupeKey)) {
791
+ onInfo('[Table] Skipping duplicate table-structural redline for the same table/payload in this turn.');
792
+ return {
793
+ documentXml,
794
+ hasChanges: false,
795
+ numberingXml: null,
796
+ warnings: ['Skipped duplicate table-structural redline in the same turn.']
797
+ };
798
+ }
799
+ const synthesizedTableMarkdown = containingTable
800
+ ? synthesizeTableMarkdownFromMultilineCellEdit(targetParagraph, modifiedText, {
801
+ tableElement: containingTable,
802
+ currentParagraphText,
803
+ onInfo,
804
+ onWarn
805
+ })
806
+ : null;
807
+ let effectiveModifiedText = synthesizedTableMarkdown || modifiedText;
808
+ const useTableScope = !!containingTable && isMarkdownTableText(effectiveModifiedText);
809
+ const isTableMarkdownEdit = isMarkdownTableText(effectiveModifiedText);
810
+ const explicitRangeParagraphs = targetEndRef
811
+ ? resolveParagraphRangeByRefs(xmlDoc, targetRef, targetEndRef, {
812
+ opType: 'redline',
813
+ targetRefSnapshot: runtimeContext?.targetRefSnapshot || null,
814
+ onInfo,
815
+ onWarn
816
+ })
817
+ : null;
818
+ const hasExplicitRangeScope = Array.isArray(explicitRangeParagraphs) && explicitRangeParagraphs.length > 0;
819
+ if (!useTableScope && hasExplicitRangeScope) {
820
+ const insertionEntries = buildExplicitRangeInsertionEntries(explicitRangeParagraphs, effectiveModifiedText);
821
+ if (insertionEntries && insertionEntries.length > 0) {
822
+ onInfo(`[List] Applying explicit-range insertion-only heuristic (${insertionEntries.length} new item(s)).`);
823
+ for (const entry of insertionEntries) {
824
+ onInfo(`[List] Explicit-range insertion: ilvl=${entry.ilvl}, markerType=${entry.markerType}, text="${String(entry.text || '').slice(0, 80)}${String(entry.text || '').length > 80 ? '…' : ''}"`);
825
+ }
826
+ const applied = applyExplicitRangeListInsertions({
827
+ xmlDoc,
828
+ explicitRangeParagraphs,
829
+ insertionEntries,
830
+ generateRedlines,
831
+ author
832
+ });
833
+ if (applied) {
834
+ return { documentXml: serializer.serializeToString(xmlDoc), hasChanges: true, numberingXml: null };
835
+ }
836
+ }
837
+ }
838
+ let inferredTableRangeParagraphs = null;
839
+ if (!explicitRangeParagraphs && !useTableScope && isTableMarkdownEdit) {
840
+ inferredTableRangeParagraphs = inferTableReplacementParagraphBlock(targetParagraph, {
841
+ getParagraphText
842
+ });
843
+ if (inferredTableRangeParagraphs?.length > 1) {
844
+ onInfo(`[Table] Heuristic range expansion selected ${inferredTableRangeParagraphs.length} paragraph(s) for replacement.`);
845
+ }
846
+ }
847
+ const targetListInfo = getParagraphListInfo(targetParagraph);
848
+ if (
849
+ targetListInfo &&
850
+ typeof effectiveModifiedText === 'string' &&
851
+ !effectiveModifiedText.includes('\n')
852
+ ) {
853
+ const strippedListPrefix = stripRedundantLeadingListMarkers(effectiveModifiedText);
854
+ if (strippedListPrefix && strippedListPrefix !== effectiveModifiedText.trim()) {
855
+ onInfo('[List] Stripped redundant manual list marker prefix from single-line list item edit.');
856
+ effectiveModifiedText = strippedListPrefix;
857
+ }
858
+ }
859
+ if (useTableScope) {
860
+ onInfo('[Table] Markdown table edit detected in table cell target; applying reconciliation at table scope.');
861
+ }
862
+
863
+ const adjacencyInsertionCandidate = (!useTableScope && !hasExplicitRangeScope && targetListInfo)
864
+ ? deriveSingleParagraphListAdjacencyInsertion(currentParagraphText, effectiveModifiedText)
865
+ : null;
866
+ if (adjacencyInsertionCandidate) {
867
+ onInfo(`[List] Applying single-paragraph list adjacency insertion heuristic (${adjacencyInsertionCandidate.position}).`);
868
+ const parent = targetParagraph.parentNode;
869
+ if (!parent) throw new Error('Target paragraph has no parent for adjacency list insertion');
870
+
871
+ const dateIso = generateRedlines ? new Date().toISOString() : null;
872
+ const revisionId = generateRedlines ? getNextTrackedChangeId(xmlDoc) : null;
873
+ const listParagraph = buildInsertedListParagraph(
874
+ xmlDoc,
875
+ targetParagraph,
876
+ {
877
+ ilvl: targetListInfo.ilvl,
878
+ numId: targetListInfo.numId,
879
+ markerType: 'numbered',
880
+ text: adjacencyInsertionCandidate.text
881
+ },
882
+ revisionId,
883
+ author,
884
+ dateIso,
885
+ { generateRedlines }
886
+ );
887
+
888
+ const insertionPoint = adjacencyInsertionCandidate.position === 'before'
889
+ ? targetParagraph
890
+ : targetParagraph.nextSibling;
891
+ parent.insertBefore(listParagraph, insertionPoint);
892
+ normalizeBodySectionOrder(xmlDoc);
893
+ return { documentXml: serializer.serializeToString(xmlDoc), hasChanges: true, numberingXml: null };
894
+ }
895
+
896
+ const plainAdjacencyInsertionCandidate = (!useTableScope && !hasExplicitRangeScope && !targetListInfo)
897
+ ? deriveSingleParagraphPlainAdjacencyInsertion(currentParagraphText, effectiveModifiedText)
898
+ : null;
899
+ if (plainAdjacencyInsertionCandidate) {
900
+ onInfo(
901
+ `[Text] Applying single-paragraph plain adjacency insertion heuristic `
902
+ + `(${plainAdjacencyInsertionCandidate.position}, count=${plainAdjacencyInsertionCandidate.paragraphs.length}).`
903
+ );
904
+ const parent = targetParagraph.parentNode;
905
+ if (!parent) throw new Error('Target paragraph has no parent for plain adjacency insertion');
906
+
907
+ const dateIso = generateRedlines ? new Date().toISOString() : null;
908
+ let revisionId = generateRedlines ? getNextTrackedChangeId(xmlDoc) : null;
909
+ const insertionPoint = plainAdjacencyInsertionCandidate.position === 'before'
910
+ ? targetParagraph
911
+ : targetParagraph.nextSibling;
912
+
913
+ for (const paragraphText of plainAdjacencyInsertionCandidate.paragraphs) {
914
+ const plainParagraph = await buildInsertedPlainParagraph(
915
+ xmlDoc,
916
+ targetParagraph,
917
+ paragraphText,
918
+ revisionId,
919
+ author,
920
+ dateIso,
921
+ { generateRedlines }
922
+ );
923
+ parent.insertBefore(xmlDoc.importNode(plainParagraph, true), insertionPoint);
924
+ if (generateRedlines) revisionId += 1;
925
+ }
926
+
927
+ normalizeBodySectionOrder(xmlDoc);
928
+ return { documentXml: serializer.serializeToString(xmlDoc), hasChanges: true, numberingXml: null };
929
+ }
930
+
931
+ const insertionOnlyPlan = (!useTableScope && !hasExplicitRangeScope)
932
+ ? planListInsertionOnlyEdit(targetParagraph, effectiveModifiedText, {
933
+ currentParagraphText,
934
+ onInfo,
935
+ onWarn
936
+ })
937
+ : null;
938
+ if (insertionOnlyPlan && insertionOnlyPlan.entries.length > 0) {
939
+ onInfo(`[List] Applying insertion-only list redline heuristic (${insertionOnlyPlan.entries.length} new item(s)).`);
940
+ for (const entry of insertionOnlyPlan.entries) {
941
+ onInfo(`[List] Insertion entry resolved: ilvl=${entry.ilvl}, markerType=${entry.markerType}, text="${String(entry.text || '').slice(0, 80)}${String(entry.text || '').length > 80 ? '…' : ''}"`);
942
+ }
943
+ const parent = targetParagraph.parentNode;
944
+ if (!parent) throw new Error('Target paragraph has no parent for list insertion');
945
+ const insertionPoint = targetParagraph.nextSibling;
946
+ const dateIso = generateRedlines ? new Date().toISOString() : null;
947
+ let revisionId = generateRedlines ? getNextTrackedChangeId(xmlDoc) : null;
948
+ for (const entry of insertionOnlyPlan.entries) {
949
+ const listParagraph = buildInsertedListParagraph(
950
+ xmlDoc,
951
+ targetParagraph,
952
+ { ...entry, numId: insertionOnlyPlan.numId },
953
+ revisionId,
954
+ author,
955
+ dateIso,
956
+ { generateRedlines }
957
+ );
958
+ if (generateRedlines) revisionId += 1;
959
+ parent.insertBefore(listParagraph, insertionPoint);
960
+ }
961
+ normalizeBodySectionOrder(xmlDoc);
962
+ return { documentXml: serializer.serializeToString(xmlDoc), hasChanges: true, numberingXml: null };
963
+ }
964
+
965
+ const listScopeEdit = (!useTableScope && !hasExplicitRangeScope)
966
+ ? synthesizeExpandedListScopeEdit(targetParagraph, effectiveModifiedText, {
967
+ currentParagraphText,
968
+ onInfo,
969
+ onWarn
970
+ })
971
+ : null;
972
+ const useListScope = !!listScopeEdit && Array.isArray(listScopeEdit.paragraphs) && listScopeEdit.paragraphs.length > 0;
973
+ if (useListScope) {
974
+ effectiveModifiedText = listScopeEdit.modifiedText;
975
+ }
976
+
977
+ if (!useTableScope && !useListScope && !hasExplicitRangeScope) {
978
+ const explicitHeaderListConversion = await tryExplicitDecimalHeaderListConversion({
979
+ xmlDoc,
980
+ serializer,
981
+ targetParagraph,
982
+ currentParagraphText,
983
+ modifiedText: effectiveModifiedText,
984
+ author,
985
+ runtimeContext,
986
+ generateRedlines,
987
+ onInfo
988
+ });
989
+ if (explicitHeaderListConversion) return explicitHeaderListConversion;
990
+
991
+ const listFallback = await trySingleParagraphListStructuralFallback({
992
+ xmlDoc,
993
+ serializer,
994
+ targetParagraph,
995
+ currentParagraphText,
996
+ modifiedText: effectiveModifiedText,
997
+ author,
998
+ runtimeContext,
999
+ generateRedlines,
1000
+ onInfo
1001
+ });
1002
+ if (listFallback) return listFallback;
1003
+ }
1004
+
1005
+ const originalTextForApply = useListScope
1006
+ ? listScopeEdit.originalText
1007
+ : (
1008
+ explicitRangeParagraphs
1009
+ ? explicitRangeParagraphs.map(paragraph => getParagraphText(paragraph)).join('\n')
1010
+ : (inferredTableRangeParagraphs
1011
+ ? inferredTableRangeParagraphs.map(paragraph => getParagraphText(paragraph)).join('\n')
1012
+ : (currentParagraphText || targetText))
1013
+ );
1014
+ const scopedXml = useTableScope
1015
+ ? serializer.serializeToString(containingTable)
1016
+ : (
1017
+ useListScope
1018
+ ? serializeParagraphRangeAsDocument(listScopeEdit.paragraphs, serializer)
1019
+ : (
1020
+ explicitRangeParagraphs
1021
+ ? serializeParagraphRangeAsDocument(explicitRangeParagraphs, serializer)
1022
+ : (inferredTableRangeParagraphs
1023
+ ? serializeParagraphRangeAsDocument(inferredTableRangeParagraphs, serializer)
1024
+ : serializer.serializeToString(targetParagraph))
1025
+ )
1026
+ );
1027
+
1028
+ const result = isTableMarkdownEdit
1029
+ ? await reconcileMarkdownTableOoxml(scopedXml, originalTextForApply, effectiveModifiedText, {
1030
+ author,
1031
+ generateRedlines,
1032
+ _isolatedTableCell: useTableScope
1033
+ })
1034
+ : await applyRedlineToOxml(scopedXml, originalTextForApply, effectiveModifiedText, {
1035
+ author,
1036
+ generateRedlines,
1037
+ _isolatedTableCell: useTableScope
1038
+ });
1039
+ if (!result?.hasChanges) return { documentXml, hasChanges: false, numberingXml: null };
1040
+ if (result.useNativeApi && !result.oxml) {
1041
+ const warning = 'Format-only fallback requires native Word API; browser demo skipped this operation.';
1042
+ onWarn(`[WARN] ${warning}`);
1043
+ return { documentXml, hasChanges: false, numberingXml: null, warnings: [warning] };
1044
+ }
1045
+ if (typeof result.oxml !== 'string') {
1046
+ throw new Error('Reconciliation engine did not return OOXML for a changed redline operation');
1047
+ }
1048
+ const extracted = extractReplacementNodes(result.oxml);
1049
+ let replacementNodes = extracted.replacementNodes;
1050
+ let numberingXml = extracted.numberingXml;
1051
+ if (numberingXml && runtimeContext?.numberingIdState) {
1052
+ const normalizedNumbering = remapNumberingPayloadForDocument(numberingXml, replacementNodes, runtimeContext.numberingIdState);
1053
+ replacementNodes = normalizedNumbering.replacementNodes;
1054
+ numberingXml = normalizedNumbering.numberingXml;
1055
+ }
1056
+ const scopeNodes = useTableScope
1057
+ ? [containingTable]
1058
+ : (
1059
+ useListScope
1060
+ ? listScopeEdit.paragraphs
1061
+ : (
1062
+ explicitRangeParagraphs
1063
+ ? explicitRangeParagraphs
1064
+ : (inferredTableRangeParagraphs || [targetParagraph])
1065
+ )
1066
+ );
1067
+ const anchorNode = scopeNodes[0];
1068
+ const parent = anchorNode.parentNode;
1069
+ for (const node of replacementNodes) parent.insertBefore(xmlDoc.importNode(node, true), anchorNode);
1070
+ for (const scopeNode of scopeNodes) {
1071
+ if (scopeNode && scopeNode.parentNode === parent) parent.removeChild(scopeNode);
1072
+ }
1073
+ normalizeBodySectionOrder(xmlDoc);
1074
+ if (rawTableStructuralDedupeKey && tableStructuralDedupes && (useTableScope || containingTable)) {
1075
+ tableStructuralDedupes.add(rawTableStructuralDedupeKey);
1076
+ }
1077
+ return { documentXml: serializer.serializeToString(xmlDoc), hasChanges: true, numberingXml };
1078
+ }
1079
+
1080
+ async function applyHighlightToParagraphByExactText(documentXml, targetText, textToHighlight, color, author, targetRef = null, runtimeContext = null, options = {}) {
1081
+ const generateRedlines = options.generateRedlines !== false;
1082
+ const onInfo = typeof options?.onInfo === 'function' ? options.onInfo : () => {};
1083
+ const onWarn = typeof options?.onWarn === 'function' ? options.onWarn : () => {};
1084
+ const parser = createParser();
1085
+ const serializer = createSerializer();
1086
+ const xmlDoc = parser.parseFromString(documentXml, 'application/xml');
1087
+ const resolved = resolveTargetParagraph(xmlDoc, targetText, targetRef, 'highlight', runtimeContext, { onInfo, onWarn });
1088
+ const targetParagraph = resolved.paragraph;
1089
+ const paragraphXml = serializer.serializeToString(targetParagraph);
1090
+ const highlightedXml = applyHighlightToOoxml(paragraphXml, textToHighlight, color, { generateRedlines, author });
1091
+ if (!highlightedXml || highlightedXml === paragraphXml) return { documentXml, hasChanges: false };
1092
+ const { replacementNodes } = extractReplacementNodes(highlightedXml);
1093
+ const parent = targetParagraph.parentNode;
1094
+ for (const node of replacementNodes) parent.insertBefore(xmlDoc.importNode(node, true), targetParagraph);
1095
+ parent.removeChild(targetParagraph);
1096
+ normalizeBodySectionOrder(xmlDoc);
1097
+ return { documentXml: serializer.serializeToString(xmlDoc), hasChanges: true };
1098
+ }
1099
+
1100
+ async function applyCommentToParagraphByExactText(documentXml, targetText, textToComment, commentContent, author, targetRef = null, runtimeContext = null, options = {}) {
1101
+ const onInfo = typeof options?.onInfo === 'function' ? options.onInfo : () => {};
1102
+ const onWarn = typeof options?.onWarn === 'function' ? options.onWarn : () => {};
1103
+ const parser = createParser();
1104
+ const serializer = createSerializer();
1105
+ const xmlDoc = parser.parseFromString(documentXml, 'application/xml');
1106
+ const resolved = resolveTargetParagraph(xmlDoc, targetText, targetRef, 'comment', runtimeContext, { onInfo, onWarn });
1107
+ const targetParagraph = resolved.paragraph;
1108
+ const paragraphXml = serializer.serializeToString(targetParagraph);
1109
+ const commentResult = injectCommentsIntoOoxml(paragraphXml, [{ paragraphIndex: 1, textToFind: textToComment, commentContent }], { author });
1110
+ if (!commentResult.commentsApplied) return { documentXml, hasChanges: false, commentsXml: null, warnings: commentResult.warnings || [] };
1111
+ const { replacementNodes } = extractReplacementNodes(commentResult.oxml);
1112
+ const parent = targetParagraph.parentNode;
1113
+ for (const node of replacementNodes) parent.insertBefore(xmlDoc.importNode(node, true), targetParagraph);
1114
+ parent.removeChild(targetParagraph);
1115
+ normalizeBodySectionOrder(xmlDoc);
1116
+ return { documentXml: serializer.serializeToString(xmlDoc), hasChanges: true, commentsXml: commentResult.commentsXml || null, warnings: commentResult.warnings || [] };
1117
+ }
1118
+
1119
+ /**
1120
+ * Applies one structured operation (`redline`, `highlight`, or `comment`) to
1121
+ * a full `word/document.xml` payload.
1122
+ *
1123
+ * @param {string} documentXml
1124
+ * @param {Object} op
1125
+ * @param {string} author
1126
+ * @param {Object|null} [runtimeContext=null]
1127
+ * @param {{
1128
+ * generateRedlines?: boolean,
1129
+ * onInfo?: (message: string) => void,
1130
+ * onWarn?: (message: string) => void
1131
+ * }} [options={}]
1132
+ * @returns {Promise<{ documentXml: string, hasChanges: boolean, numberingXml?: string|null, commentsXml?: string|null, warnings?: string[] }>}
1133
+ */
1134
+ export async function applyOperationToDocumentXml(documentXml, op, author, runtimeContext = null, options = {}) {
1135
+ if (op?.type === 'highlight') {
1136
+ return applyHighlightToParagraphByExactText(
1137
+ documentXml,
1138
+ op.target,
1139
+ op.textToHighlight,
1140
+ op.color,
1141
+ author,
1142
+ op.targetRef,
1143
+ runtimeContext,
1144
+ options
1145
+ );
1146
+ }
1147
+ if (op?.type === 'comment') {
1148
+ return applyCommentToParagraphByExactText(
1149
+ documentXml,
1150
+ op.target,
1151
+ op.textToComment,
1152
+ op.commentContent,
1153
+ author,
1154
+ op.targetRef,
1155
+ runtimeContext,
1156
+ options
1157
+ );
1158
+ }
1159
+ return applyToParagraphByExactText(
1160
+ documentXml,
1161
+ op?.target,
1162
+ op?.modified,
1163
+ author,
1164
+ op?.targetRef,
1165
+ op?.targetEndRef,
1166
+ runtimeContext,
1167
+ options
1168
+ );
1169
+ }