@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,501 @@
1
+ /**
2
+ * Shared paragraph-targeting helpers for standalone/add-in consumers.
3
+ *
4
+ * This module centralizes target parsing and matching used by callers that
5
+ * apply per-paragraph operations (for example chat redlines/comments/highlights).
6
+ */
7
+
8
+ function toArray(nodeList) {
9
+ return Array.from(nodeList || []);
10
+ }
11
+
12
+ export const WORD_MAIN_NS = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
13
+
14
+ function getElementsByLocalName(node, localName) {
15
+ if (!node) return [];
16
+
17
+ if (typeof node.getElementsByTagNameNS === 'function') {
18
+ const namespaced = toArray(node.getElementsByTagNameNS('*', localName));
19
+ if (namespaced.length > 0) return namespaced;
20
+ }
21
+
22
+ if (typeof node.getElementsByTagName !== 'function') return [];
23
+
24
+ const prefixed = toArray(node.getElementsByTagName(`w:${localName}`));
25
+ if (prefixed.length > 0) return prefixed;
26
+
27
+ return toArray(node.getElementsByTagName(localName));
28
+ }
29
+
30
+ function toParagraphText(paragraph) {
31
+ const textNodes = getElementsByLocalName(paragraph, 't');
32
+ return textNodes.map(node => node.textContent || '').join('');
33
+ }
34
+
35
+ /**
36
+ * Reads visible text from a paragraph by concatenating `w:t` nodes.
37
+ *
38
+ * @param {Element|null|undefined} paragraph - OOXML paragraph node
39
+ * @returns {string}
40
+ */
41
+ export function getParagraphText(paragraph) {
42
+ if (!paragraph) return '';
43
+ return toParagraphText(paragraph);
44
+ }
45
+
46
+ /**
47
+ * Returns body paragraphs for a document, or all paragraphs as fallback.
48
+ *
49
+ * @param {Document|Element|null|undefined} xmlDoc - OOXML document root
50
+ * @returns {Element[]}
51
+ */
52
+ export function getDocumentParagraphNodes(xmlDoc) {
53
+ if (!xmlDoc) return [];
54
+ const bodies = getElementsByLocalName(xmlDoc, 'body');
55
+ const searchRoot = bodies.length > 0 ? bodies[0] : xmlDoc;
56
+ return getElementsByLocalName(searchRoot, 'p');
57
+ }
58
+
59
+ /**
60
+ * Normalizes whitespace for paragraph-comparison matching.
61
+ *
62
+ * @param {string} text - Input text
63
+ * @returns {string}
64
+ */
65
+ export function normalizeWhitespaceForTargeting(text) {
66
+ return String(text || '').replace(/\s+/g, ' ').trim();
67
+ }
68
+
69
+ /**
70
+ * Detects markdown table syntax used for table reconciliation.
71
+ *
72
+ * @param {string} text - Candidate markdown text
73
+ * @returns {boolean}
74
+ */
75
+ export function isMarkdownTableText(text) {
76
+ const trimmed = String(text || '').trim();
77
+ return /^\|.+\|/.test(trimmed) && trimmed.includes('\n');
78
+ }
79
+
80
+ /**
81
+ * Parses paragraph references such as `P12`, `[P12]`, `12`, or `P12.3`.
82
+ *
83
+ * @param {string|number|null|undefined} rawValue - Reference input
84
+ * @returns {number|null}
85
+ */
86
+ export function parseParagraphReference(rawValue) {
87
+ if (rawValue == null) return null;
88
+ if (typeof rawValue === 'number' && Number.isInteger(rawValue) && rawValue > 0) return rawValue;
89
+
90
+ const text = String(rawValue).trim();
91
+ if (!text) return null;
92
+
93
+ const prefixed = text.match(/^\[?P(\d+)(?:\.\d+)?\]?$/i);
94
+ if (prefixed) return Number.parseInt(prefixed[1], 10);
95
+
96
+ const numeric = text.match(/^(\d+)$/);
97
+ if (numeric) return Number.parseInt(numeric[1], 10);
98
+
99
+ return null;
100
+ }
101
+
102
+ /**
103
+ * Removes leading paragraph labels (for example `[P12]`) from text fields.
104
+ *
105
+ * @param {string|null|undefined} text - Input text
106
+ * @returns {string}
107
+ */
108
+ export function stripLeadingParagraphMarker(text) {
109
+ if (text == null) return '';
110
+ return String(text).replace(/^\s*\[P\d+(?:\.\d+)?\]\s*/i, '').trim();
111
+ }
112
+
113
+ /**
114
+ * Splits a leading paragraph label from text.
115
+ *
116
+ * @param {string|null|undefined} text - Input text
117
+ * @returns {{ text: string, targetRef: number|null }}
118
+ */
119
+ export function splitLeadingParagraphMarker(text) {
120
+ const raw = String(text || '');
121
+ const marker = raw.match(/^\s*\[P(\d+)(?:\.\d+)?\]\s*/i);
122
+ if (!marker) return { text: raw.trim(), targetRef: null };
123
+
124
+ return {
125
+ text: raw.replace(/^\s*\[P\d+(?:\.\d+)?\]\s*/i, '').trim(),
126
+ targetRef: Number.parseInt(marker[1], 10)
127
+ };
128
+ }
129
+
130
+ /**
131
+ * Resolves a paragraph by 1-based paragraph index.
132
+ *
133
+ * @param {Document|Element|null|undefined} xmlDoc - OOXML document
134
+ * @param {number|null|undefined} targetRef - 1-based paragraph number
135
+ * @returns {Element|null}
136
+ */
137
+ export function findParagraphByReference(xmlDoc, targetRef) {
138
+ if (!Number.isInteger(targetRef) || targetRef < 1) return null;
139
+ const paragraphs = getDocumentParagraphNodes(xmlDoc);
140
+ return paragraphs[targetRef - 1] || null;
141
+ }
142
+
143
+ /**
144
+ * Finds the closest ancestor matching Word namespace + localName.
145
+ *
146
+ * @param {Node|null|undefined} node - Start node
147
+ * @param {string} localName - WordprocessingML local element name (for example `tbl`, `tc`)
148
+ * @param {string} [namespaceUri] - Namespace URI to match
149
+ * @returns {Element|null}
150
+ */
151
+ export function findContainingWordElement(node, localName, namespaceUri = WORD_MAIN_NS) {
152
+ let current = node;
153
+ while (current) {
154
+ if (
155
+ current.nodeType === 1 &&
156
+ current.namespaceURI === namespaceUri &&
157
+ current.localName === localName
158
+ ) {
159
+ return current;
160
+ }
161
+ current = current.parentNode;
162
+ }
163
+ return null;
164
+ }
165
+
166
+ /**
167
+ * Finds paragraph by exact/normalized text equality.
168
+ *
169
+ * @param {Document|Element|null|undefined} xmlDoc - OOXML document
170
+ * @param {string} targetText - Target paragraph text
171
+ * @returns {Element|null}
172
+ */
173
+ export function findParagraphByStrictText(xmlDoc, targetText) {
174
+ const paragraphs = getDocumentParagraphNodes(xmlDoc);
175
+ const normalizedTarget = String(targetText || '').trim();
176
+ if (!normalizedTarget) return null;
177
+
178
+ const exact = paragraphs.find(p => getParagraphText(p).trim() === normalizedTarget);
179
+ if (exact) return exact;
180
+
181
+ const normTarget = normalizeWhitespaceForTargeting(normalizedTarget);
182
+ return paragraphs.find(p => normalizeWhitespaceForTargeting(getParagraphText(p)) === normTarget) || null;
183
+ }
184
+
185
+ /**
186
+ * Finds paragraph by strict match, then fuzzy fallback heuristics.
187
+ *
188
+ * @param {Document|Element|null|undefined} xmlDoc - OOXML document
189
+ * @param {string} targetText - Target paragraph text
190
+ * @param {{ onInfo?: (msg:string)=>void }} [options] - Optional logger callbacks
191
+ * @returns {Element|null}
192
+ */
193
+ export function findParagraphByBestTextMatch(xmlDoc, targetText, options = {}) {
194
+ const onInfo = typeof options.onInfo === 'function' ? options.onInfo : () => {};
195
+ const paragraphs = getDocumentParagraphNodes(xmlDoc);
196
+ const normalizedTarget = String(targetText || '').trim();
197
+ if (!normalizedTarget) return null;
198
+
199
+ const strictMatch = findParagraphByStrictText(xmlDoc, normalizedTarget);
200
+ if (strictMatch) return strictMatch;
201
+
202
+ const normTarget = normalizeWhitespaceForTargeting(normalizedTarget);
203
+
204
+ const startsWithMatch = paragraphs.find(p => {
205
+ const paragraphText = normalizeWhitespaceForTargeting(getParagraphText(p));
206
+ return paragraphText.length > 10 && normTarget.startsWith(paragraphText);
207
+ });
208
+ if (startsWithMatch) {
209
+ onInfo(`[Fuzzy] Prefix match (target starts with paragraph): "${getParagraphText(startsWithMatch).trim().slice(0, 60)}..."`);
210
+ return startsWithMatch;
211
+ }
212
+
213
+ const containsMatch = paragraphs.find(p => {
214
+ const paragraphText = normalizeWhitespaceForTargeting(getParagraphText(p));
215
+ return paragraphText.length > 15 && normTarget.includes(paragraphText);
216
+ });
217
+ if (containsMatch) {
218
+ onInfo(`[Fuzzy] Contains match: "${getParagraphText(containsMatch).trim().slice(0, 60)}..."`);
219
+ return containsMatch;
220
+ }
221
+
222
+ let bestScore = 0;
223
+ let bestParagraph = null;
224
+ const targetWords = new Set(normTarget.toLowerCase().split(/\s+/).filter(word => word.length > 2));
225
+ for (const paragraph of paragraphs) {
226
+ const paragraphText = getParagraphText(paragraph).trim();
227
+ if (!paragraphText) continue;
228
+
229
+ const paragraphWords = normalizeWhitespaceForTargeting(paragraphText)
230
+ .toLowerCase()
231
+ .split(/\s+/)
232
+ .filter(word => word.length > 2);
233
+ const overlap = paragraphWords.filter(word => targetWords.has(word)).length;
234
+ const score = overlap / Math.max(targetWords.size, 1);
235
+ if (score > bestScore && score > 0.5) {
236
+ bestScore = score;
237
+ bestParagraph = paragraph;
238
+ }
239
+ }
240
+
241
+ if (bestParagraph) {
242
+ onInfo(`[Fuzzy] Best word-overlap match (${(bestScore * 100).toFixed(0)}%): "${getParagraphText(bestParagraph).trim().slice(0, 60)}..."`);
243
+ return bestParagraph;
244
+ }
245
+
246
+ return null;
247
+ }
248
+
249
+ /**
250
+ * Resolves a target paragraph from `targetRef` + `targetText`.
251
+ *
252
+ * Resolution order:
253
+ * 1) `targetRef` when provided and valid
254
+ * 2) strict text match
255
+ * 3) fuzzy text match
256
+ *
257
+ * @param {Document|Element|null|undefined} xmlDoc - OOXML document
258
+ * @param {{
259
+ * targetText?: string,
260
+ * targetRef?: string|number|null,
261
+ * opType?: string,
262
+ * onInfo?: (msg:string)=>void,
263
+ * onWarn?: (msg:string)=>void
264
+ * }} options - Resolution options
265
+ * @returns {{ paragraph: Element, resolvedBy: 'ref'|'strict_text'|'fuzzy_text'|'strict_text_after_ref_drift'|'fuzzy_text_after_ref_drift' }}
266
+ */
267
+ export function resolveTargetParagraph(xmlDoc, options = {}) {
268
+ const onInfo = typeof options.onInfo === 'function' ? options.onInfo : () => {};
269
+ const onWarn = typeof options.onWarn === 'function' ? options.onWarn : () => {};
270
+ const opType = options.opType || 'operation';
271
+ const cleanTargetText = String(options.targetText || '').trim();
272
+ const parsedRef = parseParagraphReference(options.targetRef);
273
+
274
+ if (parsedRef) {
275
+ const byRef = findParagraphByReference(xmlDoc, parsedRef);
276
+ if (byRef) {
277
+ if (cleanTargetText) {
278
+ const strictMatch = findParagraphByStrictText(xmlDoc, cleanTargetText);
279
+ const byRefText = getParagraphText(byRef).trim();
280
+ const byRefNorm = normalizeWhitespaceForTargeting(byRefText);
281
+ const targetNorm = normalizeWhitespaceForTargeting(cleanTargetText);
282
+ const hasDrift = byRefNorm !== targetNorm;
283
+
284
+ if (hasDrift && strictMatch && strictMatch !== byRef) {
285
+ onInfo(`[Target] [P${parsedRef}] drifted for ${opType}; using strict text rematch.`);
286
+ return { paragraph: strictMatch, resolvedBy: 'strict_text_after_ref_drift' };
287
+ }
288
+
289
+ if (hasDrift) {
290
+ const fuzzyMatch = findParagraphByBestTextMatch(xmlDoc, cleanTargetText, { onInfo });
291
+ if (fuzzyMatch && fuzzyMatch !== byRef) {
292
+ onInfo(`[Target] [P${parsedRef}] drifted for ${opType}; using fuzzy text rematch.`);
293
+ return { paragraph: fuzzyMatch, resolvedBy: 'fuzzy_text_after_ref_drift' };
294
+ }
295
+ onInfo(`[Target] Using [P${parsedRef}] fallback for ${opType}; target text drifted.`);
296
+ } else if (strictMatch && strictMatch !== byRef) {
297
+ onInfo(`[Target] [P${parsedRef}] disambiguated duplicate target text for ${opType}.`);
298
+ }
299
+ } else {
300
+ onInfo(`[Target] Using [P${parsedRef}] fallback for ${opType}.`);
301
+ }
302
+ return { paragraph: byRef, resolvedBy: 'ref' };
303
+ }
304
+
305
+ onWarn(`[WARN] Target reference [P${parsedRef}] not found; falling back to text matching for ${opType}.`);
306
+ }
307
+
308
+ if (cleanTargetText) {
309
+ const strictMatch = findParagraphByStrictText(xmlDoc, cleanTargetText);
310
+ if (strictMatch) return { paragraph: strictMatch, resolvedBy: 'strict_text' };
311
+
312
+ const fuzzyMatch = findParagraphByBestTextMatch(xmlDoc, cleanTargetText, { onInfo });
313
+ if (fuzzyMatch) return { paragraph: fuzzyMatch, resolvedBy: 'fuzzy_text' };
314
+ }
315
+
316
+ if (cleanTargetText) throw new Error(`Target paragraph not found: "${cleanTargetText}"`);
317
+ if (parsedRef) throw new Error(`Target paragraph reference not found: [P${parsedRef}]`);
318
+ throw new Error('Operation target missing: provide "target" text or "targetRef" ([P#]).');
319
+ }
320
+
321
+ function isParagraphInTable(paragraph) {
322
+ return !!findContainingWordElement(paragraph, 'tbl');
323
+ }
324
+
325
+ function findStrictTargetCandidates(xmlDoc, targetText) {
326
+ const normalizedTarget = normalizeWhitespaceForTargeting(targetText);
327
+ if (!normalizedTarget) return [];
328
+
329
+ const paragraphs = getDocumentParagraphNodes(xmlDoc);
330
+ const candidates = [];
331
+ for (let i = 0; i < paragraphs.length; i++) {
332
+ const paragraph = paragraphs[i];
333
+ const paragraphText = getParagraphText(paragraph).trim();
334
+ if (!paragraphText) continue;
335
+ if (normalizeWhitespaceForTargeting(paragraphText) !== normalizedTarget) continue;
336
+ candidates.push({
337
+ paragraph,
338
+ index: i + 1,
339
+ inTable: isParagraphInTable(paragraph)
340
+ });
341
+ }
342
+ return candidates;
343
+ }
344
+
345
+ function selectBestTargetCandidate(candidates, parsedRef, expectedInTable = null) {
346
+ if (!Array.isArray(candidates) || candidates.length === 0) return null;
347
+
348
+ let scoped = candidates.slice();
349
+ if (typeof expectedInTable === 'boolean') {
350
+ const sameContext = scoped.filter(candidate => candidate.inTable === expectedInTable);
351
+ if (sameContext.length > 0) scoped = sameContext;
352
+ }
353
+
354
+ if (Number.isInteger(parsedRef) && parsedRef > 0) {
355
+ scoped.sort((a, b) => Math.abs(a.index - parsedRef) - Math.abs(b.index - parsedRef));
356
+ }
357
+
358
+ return scoped[0] || null;
359
+ }
360
+
361
+ /**
362
+ * Builds a turn-start paragraph snapshot keyed by 1-based paragraph index.
363
+ *
364
+ * Intended for callers that apply multiple operations sequentially and need to
365
+ * detect `targetRef` drift after earlier structural edits.
366
+ *
367
+ * @param {Document|Element|null|undefined} xmlDoc - OOXML document root
368
+ * @returns {Map<number, { text: string, normalizedText: string, inTable: boolean }>}
369
+ */
370
+ export function buildTargetReferenceSnapshot(xmlDoc) {
371
+ const paragraphs = getDocumentParagraphNodes(xmlDoc);
372
+ const snapshot = new Map();
373
+ for (let i = 0; i < paragraphs.length; i++) {
374
+ const paragraph = paragraphs[i];
375
+ const text = getParagraphText(paragraph).trim();
376
+ snapshot.set(i + 1, {
377
+ text,
378
+ normalizedText: normalizeWhitespaceForTargeting(text),
379
+ inTable: isParagraphInTable(paragraph)
380
+ });
381
+ }
382
+ return snapshot;
383
+ }
384
+
385
+ /**
386
+ * Resolves a paragraph using the standard resolver, then corrects stale
387
+ * `targetRef` mappings via strict rematch when a turn-start snapshot is provided.
388
+ *
389
+ * @param {Document|Element|null|undefined} xmlDoc - OOXML document
390
+ * @param {{
391
+ * targetText?: string,
392
+ * targetRef?: string|number|null,
393
+ * opType?: string,
394
+ * targetRefSnapshot?: Map<number, { text?: string, inTable?: boolean }>|null,
395
+ * onInfo?: (msg:string)=>void,
396
+ * onWarn?: (msg:string)=>void
397
+ * }} options - Resolution options
398
+ * @returns {{ paragraph: Element, resolvedBy: 'ref'|'strict_text'|'fuzzy_text'|'strict_text_after_ref_drift' }}
399
+ */
400
+ export function resolveTargetParagraphWithSnapshot(xmlDoc, options = {}) {
401
+ const onInfo = typeof options.onInfo === 'function' ? options.onInfo : () => {};
402
+ const resolved = resolveTargetParagraph(xmlDoc, options);
403
+
404
+ const parsedRef = parseParagraphReference(options.targetRef);
405
+ if (!parsedRef || resolved?.resolvedBy !== 'ref') return resolved;
406
+
407
+ const snapshotEntry = options.targetRefSnapshot instanceof Map
408
+ ? (options.targetRefSnapshot.get(parsedRef) || null)
409
+ : null;
410
+ if (!snapshotEntry) return resolved;
411
+
412
+ const cleanTargetText = String(options.targetText || '').trim();
413
+ const expectedText = cleanTargetText || snapshotEntry.text || '';
414
+ const expectedNorm = normalizeWhitespaceForTargeting(expectedText);
415
+ if (!expectedNorm) return resolved;
416
+
417
+ const resolvedNorm = normalizeWhitespaceForTargeting(getParagraphText(resolved.paragraph));
418
+ if (resolvedNorm === expectedNorm) return resolved;
419
+
420
+ const candidateTexts = [];
421
+ if (cleanTargetText) candidateTexts.push(cleanTargetText);
422
+ if (snapshotEntry.text) {
423
+ const snapshotNorm = normalizeWhitespaceForTargeting(snapshotEntry.text);
424
+ if (snapshotNorm && !candidateTexts.some(text => normalizeWhitespaceForTargeting(text) === snapshotNorm)) {
425
+ candidateTexts.push(snapshotEntry.text);
426
+ }
427
+ }
428
+
429
+ let bestCandidate = null;
430
+ for (const candidateText of candidateTexts) {
431
+ const candidates = findStrictTargetCandidates(xmlDoc, candidateText);
432
+ const selected = selectBestTargetCandidate(candidates, parsedRef, snapshotEntry.inTable);
433
+ if (!selected) continue;
434
+ if (!bestCandidate) bestCandidate = selected;
435
+ if (selected.paragraph !== resolved.paragraph) {
436
+ bestCandidate = selected;
437
+ break;
438
+ }
439
+ }
440
+
441
+ if (bestCandidate && bestCandidate.paragraph !== resolved.paragraph) {
442
+ const opType = options.opType || 'operation';
443
+ onInfo(`[Target] [P${parsedRef}] appears stale after prior edits; using strict text rematch for ${opType}.`);
444
+ return { paragraph: bestCandidate.paragraph, resolvedBy: 'strict_text_after_ref_drift' };
445
+ }
446
+
447
+ return resolved;
448
+ }
449
+
450
+ /**
451
+ * Resolves a contiguous paragraph range using paragraph references.
452
+ *
453
+ * @param {Document} xmlDoc - XML document
454
+ * @param {string|number|null} startRef - Start paragraph reference (e.g. P12)
455
+ * @param {string|number|null} endRef - End paragraph reference (e.g. P15)
456
+ * @param {Object} [options={}] - Resolution options
457
+ * @param {string} [options.opType='redline'] - Operation type hint
458
+ * @param {Array|null} [options.targetRefSnapshot=null] - Optional target snapshot
459
+ * @param {(message: string) => void} [options.onInfo] - Optional info logger
460
+ * @param {(message: string) => void} [options.onWarn] - Optional warn logger
461
+ * @returns {Element[]|null}
462
+ */
463
+ export function resolveParagraphRangeByRefs(xmlDoc, startRef, endRef, options = {}) {
464
+ if (!xmlDoc || !startRef || !endRef) return null;
465
+
466
+ const opType = options?.opType || 'redline';
467
+ const targetRefSnapshot = options?.targetRefSnapshot || null;
468
+ const onInfo = typeof options?.onInfo === 'function' ? options.onInfo : () => { };
469
+ const onWarn = typeof options?.onWarn === 'function' ? options.onWarn : () => { };
470
+
471
+ const start = resolveTargetParagraphWithSnapshot(xmlDoc, {
472
+ targetRef: startRef,
473
+ opType,
474
+ targetRefSnapshot,
475
+ onInfo,
476
+ onWarn
477
+ })?.paragraph;
478
+ if (!start) return null;
479
+
480
+ const end = resolveTargetParagraphWithSnapshot(xmlDoc, {
481
+ targetRef: endRef,
482
+ opType,
483
+ targetRefSnapshot,
484
+ onInfo,
485
+ onWarn
486
+ })?.paragraph;
487
+ if (!end) return null;
488
+
489
+ const allParagraphs = Array.from(xmlDoc.getElementsByTagNameNS('*', 'p'));
490
+ const startIdx = allParagraphs.indexOf(start);
491
+ const endIdx = allParagraphs.indexOf(end);
492
+ if (startIdx < 0 || endIdx < startIdx) return null;
493
+
494
+ const range = allParagraphs.slice(startIdx, endIdx + 1);
495
+ if (range.length === 0) return null;
496
+
497
+ const parent = range[0]?.parentNode || null;
498
+ if (!parent) return null;
499
+ if (!range.every(node => node && node.parentNode === parent)) return null;
500
+ return range;
501
+ }