@ansonlai/docx-redline-js 0.4.0 → 0.5.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 (100) hide show
  1. package/AGENTS.md +589 -287
  2. package/ARCHITECTURE.md +215 -9
  3. package/CHANGELOG.md +319 -0
  4. package/README.md +604 -360
  5. package/adapters/config.js +45 -43
  6. package/bin/docx-redline.js +3 -0
  7. package/core/list-targeting.js +101 -110
  8. package/core/paragraph-targeting.js +501 -61
  9. package/core/paragraph-text.js +209 -0
  10. package/core/revision-cloning.js +38 -0
  11. package/core/types.js +64 -10
  12. package/core/word-xml.js +43 -15
  13. package/dist/docx-redline-js.esm.js +2849 -466
  14. package/dist/docx-redline-js.esm.js.map +4 -4
  15. package/dist/docx-redline-js.esm.min.js +87 -76
  16. package/dist/docx-redline-js.esm.min.js.map +4 -4
  17. package/docs/TESTING.md +342 -23
  18. package/docs/plans/2026-09-05-structural-revisions-and-fidelity-oracles.md +1669 -0
  19. package/docs/plans/completed/2026-09-01-performance-and-complexity-reduction.md +669 -0
  20. package/docs/plans/completed/2026-09-03-agent-friendly-document-workflows.md +427 -0
  21. package/docs/plans/completed/2026-09-04-comment-anchor-and-cli-reliability.md +519 -0
  22. package/docs/plans/completed/PERFORMANCE-CONSOLIDATION.md +69 -0
  23. package/docs/plans/completed/structural-revision-capability-matrix.md +115 -0
  24. package/docs/schemas/document-operations.schema.json +109 -0
  25. package/docs/test-comparison-dashboard.html +4250 -7
  26. package/engine/formatting-removal.js +11 -2
  27. package/engine/oxml-engine.js +491 -336
  28. package/engine/reconstruction-mode.js +15 -14
  29. package/engine/reconstruction-writer.js +247 -142
  30. package/engine/route-selection.js +35 -0
  31. package/engine/rpr-helpers.js +334 -35
  32. package/engine/run-builders.js +239 -196
  33. package/engine/surgical-diff-application.js +222 -37
  34. package/engine/surgical-mode.js +134 -6
  35. package/engine/surgical-spans.js +52 -1
  36. package/engine/table-cell-context.js +3 -6
  37. package/engine/table-mode.js +1 -1
  38. package/index.d.ts +234 -6
  39. package/index.js +24 -1
  40. package/node/cli.js +317 -0
  41. package/node/docx-document.js +302 -0
  42. package/node/index.d.ts +31 -0
  43. package/node/index.js +2 -0
  44. package/node/zip-archive.js +52 -0
  45. package/orchestration/list-markdown.js +10 -16
  46. package/orchestration/list-parsing.js +7 -12
  47. package/orchestration/list-structural-fallback.js +21 -10
  48. package/package.json +24 -3
  49. package/pipeline/content-analysis.js +12 -17
  50. package/pipeline/ingestion-export.js +3 -31
  51. package/pipeline/ingestion-paragraph.js +10 -5
  52. package/pipeline/list-generation.js +150 -55
  53. package/pipeline/list-markers.js +70 -3
  54. package/pipeline/serialization.js +4 -2
  55. package/pipeline/structured-content.js +160 -0
  56. package/scripts/apply_changes.mjs +27 -0
  57. package/scripts/benchmark-operation-session.mjs +137 -0
  58. package/scripts/benchmark-targeting-browser.html +74 -0
  59. package/scripts/benchmark-targeting-hot-paths.mjs +67 -0
  60. package/scripts/benchmark-test-runner.mjs +59 -0
  61. package/scripts/build-test-dashboard.mjs +23 -0
  62. package/scripts/export-lane1-fixtures.mjs +380 -0
  63. package/scripts/export-reredline-stress-fixtures.mjs +317 -0
  64. package/scripts/export-validation-fixtures.mjs +1 -1
  65. package/scripts/extract_text.mjs +7 -0
  66. package/scripts/generate-paragraph-boundary-fixtures.ps1 +215 -0
  67. package/scripts/generate-test-dashboard.mjs +362 -11
  68. package/scripts/lib/word-coverage-catalogue.mjs +6 -2
  69. package/scripts/profile-route-selection.mjs +19 -0
  70. package/scripts/render-agenda-multilevel.mjs +0 -5
  71. package/scripts/render-multilevel-cases.mjs +0 -1
  72. package/scripts/run-tests.mjs +107 -35
  73. package/scripts/word-com-corpus-suite.ps1 +3 -0
  74. package/scripts/word-com-differential.ps1 +64 -4
  75. package/scripts/word-com-suite.ps1 +3 -0
  76. package/services/batch-operation-orchestrator.js +494 -0
  77. package/services/capture-engine.js +226 -0
  78. package/services/comment-builders.js +23 -6
  79. package/services/comment-engine.js +108 -47
  80. package/services/comment-locator.js +187 -82
  81. package/services/comment-replies.js +95 -0
  82. package/services/document-inspection.js +258 -0
  83. package/services/document-operation-applier.js +372 -0
  84. package/services/document-operation-contract.js +323 -0
  85. package/services/document-operation-mutations.js +1733 -0
  86. package/services/document-operation-session.js +258 -0
  87. package/services/numbering-service.js +14 -5
  88. package/services/operation-heuristics.js +173 -0
  89. package/services/operation-preflight.js +366 -0
  90. package/services/receipt-collector.js +288 -0
  91. package/services/revision-comment-management.js +37 -5
  92. package/services/revision-token.js +290 -0
  93. package/services/standalone-docx-plumbing.js +123 -8
  94. package/services/standalone-operation-runner.d.ts +296 -0
  95. package/services/standalone-operation-runner.js +10 -1455
  96. package/services/table-reconciliation.js +15 -6
  97. package/docs/VALIDATION.md +0 -183
  98. package/docs/WORD-MANUAL-REVIEW.md +0 -138
  99. package/docs/plans/2026-09-01-performance-and-complexity-reduction.md +0 -210
  100. /package/docs/plans/{2026-08-30-reliability-testing-improvements.md → completed/2026-08-30-reliability-testing-improvements.md} +0 -0
@@ -15,6 +15,13 @@ function createTargetNotFoundError(message) {
15
15
  return error;
16
16
  }
17
17
 
18
+ function createTargetError(code, message, candidates = null) {
19
+ const error = new Error(message);
20
+ error.code = code;
21
+ if (Array.isArray(candidates)) error.candidates = candidates;
22
+ return error;
23
+ }
24
+
18
25
  export const WORD_MAIN_NS = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
19
26
 
20
27
  function getElementsByLocalName(node, localName) {
@@ -33,26 +40,8 @@ function getElementsByLocalName(node, localName) {
33
40
  return toArray(node.getElementsByTagName(localName));
34
41
  }
35
42
 
36
- function toParagraphText(paragraph) {
37
- let text = '';
38
-
39
- const visit = node => {
40
- for (const child of toArray(node?.childNodes)) {
41
- if (child?.nodeType !== 1) continue;
42
- const localName = String(child.localName || child.nodeName || '').replace(/^.*:/, '');
43
- if (localName === 't') {
44
- text += child.textContent || '';
45
- } else if (localName === 'tab') {
46
- text += '\t';
47
- } else {
48
- visit(child);
49
- }
50
- }
51
- };
52
-
53
- visit(paragraph);
54
- return text;
55
- }
43
+ import { extractCanonicalParagraphText } from './paragraph-text.js';
44
+ import { isWordElement } from './word-xml.js';
56
45
 
57
46
  /**
58
47
  * Reads visible text from a paragraph by concatenating `w:t` nodes and mapping
@@ -63,7 +52,7 @@ function toParagraphText(paragraph) {
63
52
  */
64
53
  export function getParagraphText(paragraph) {
65
54
  if (!paragraph) return '';
66
- return toParagraphText(paragraph);
55
+ return extractCanonicalParagraphText(paragraph);
67
56
  }
68
57
 
69
58
  /**
@@ -79,6 +68,94 @@ export function getDocumentParagraphNodes(xmlDoc) {
79
68
  return getElementsByLocalName(searchRoot, 'p');
80
69
  }
81
70
 
71
+ export function getParagraphId(paragraph) {
72
+ if (!paragraph) return null;
73
+ const attribute = toArray(paragraph.attributes).find(candidate =>
74
+ String(candidate?.localName || candidate?.name || '').replace(/^.*:/, '') === 'paraId'
75
+ );
76
+ return attribute?.value || null;
77
+ }
78
+
79
+ export function createParagraphFingerprint(paragraph, metadata = {}) {
80
+ if (!paragraph) return null;
81
+ const revisionView = metadata.revisionView === 'rejected' ? 'rejected' : 'accepted';
82
+ const text = typeof metadata.text === 'string'
83
+ ? metadata.text
84
+ : extractCanonicalParagraphText(paragraph, { revisionView });
85
+ const documentIndex = Number.isInteger(metadata.index)
86
+ ? metadata.index
87
+ : getDocumentParagraphNodes(paragraph.ownerDocument || paragraph).indexOf(paragraph) + 1;
88
+ const paragraphId = metadata.paragraphId === undefined ? getParagraphId(paragraph) : metadata.paragraphId;
89
+ const inTable = typeof metadata.inTable === 'boolean' ? metadata.inTable : isParagraphInTable(paragraph);
90
+ const viewPart = revisionView === 'rejected' ? 'rejected\u001f' : '';
91
+ const identity = `${paragraphId || ''}\u001f${documentIndex}\u001f${inTable ? 'table' : 'body'}\u001f${viewPart}${text}`;
92
+ let hash = 0x811c9dc5;
93
+ for (let i = 0; i < identity.length; i++) {
94
+ hash ^= identity.charCodeAt(i);
95
+ hash = Math.imul(hash, 0x01000193) >>> 0;
96
+ }
97
+ return `fnv1a32:${hash.toString(16).padStart(8, '0')}`;
98
+ }
99
+
100
+ /**
101
+ * Builds immutable paragraph metadata and grouped lookup maps for one document
102
+ * state. The index is intentionally session-scoped because it retains DOM
103
+ * nodes and must be discarded after a mutation.
104
+ *
105
+ * @param {Document|Element|null|undefined} xmlDoc - OOXML document root
106
+ * @param {Object} [options={}] - Options
107
+ * @param {'accepted'|'rejected'} [options.revisionView='accepted'] - Revision view
108
+ * @returns {{revisionView: 'accepted'|'rejected', entries: ReadonlyArray<Object>, byParagraph: Map<Element,Object>, byId: Map<string,Object>, byNormalizedText: Map<string,ReadonlyArray<Object>>}}
109
+ */
110
+ export function buildParagraphMetadataIndex(xmlDoc, options = {}) {
111
+ const revisionView = options.revisionView === 'rejected' ? 'rejected' : 'accepted';
112
+ const paragraphs = getDocumentParagraphNodes(xmlDoc);
113
+ const entries = [];
114
+ const byParagraph = new Map();
115
+ const byId = new Map();
116
+ const grouped = new Map();
117
+
118
+ for (let offset = 0; offset < paragraphs.length; offset++) {
119
+ const paragraph = paragraphs[offset];
120
+ const text = extractCanonicalParagraphText(paragraph, { revisionView });
121
+ const paragraphId = getParagraphId(paragraph);
122
+ const inTable = isParagraphInTable(paragraph);
123
+ const normalizedText = normalizeWhitespaceForTargeting(text);
124
+ const entry = Object.freeze({
125
+ paragraph,
126
+ index: offset + 1,
127
+ paragraphId,
128
+ text,
129
+ normalizedText,
130
+ revisionView,
131
+ fingerprint: createParagraphFingerprint(paragraph, {
132
+ text,
133
+ index: offset + 1,
134
+ paragraphId,
135
+ inTable,
136
+ revisionView
137
+ }),
138
+ inTable
139
+ });
140
+ entries.push(entry);
141
+ byParagraph.set(paragraph, entry);
142
+ if (paragraphId && !byId.has(paragraphId)) byId.set(paragraphId, entry);
143
+ if (normalizedText) {
144
+ if (!grouped.has(normalizedText)) grouped.set(normalizedText, []);
145
+ grouped.get(normalizedText).push(entry);
146
+ }
147
+ }
148
+
149
+ for (const [key, values] of grouped) grouped.set(key, Object.freeze(values));
150
+ return Object.freeze({
151
+ revisionView,
152
+ entries: Object.freeze(entries),
153
+ byParagraph,
154
+ byId,
155
+ byNormalizedText: grouped
156
+ });
157
+ }
158
+
82
159
  /**
83
160
  * Normalizes whitespace for paragraph-comparison matching.
84
161
  *
@@ -157,10 +234,12 @@ export function splitLeadingParagraphMarker(text) {
157
234
  * @param {number|null|undefined} targetRef - 1-based paragraph number
158
235
  * @returns {Element|null}
159
236
  */
160
- export function findParagraphByReference(xmlDoc, targetRef) {
237
+ export function findParagraphByReference(xmlDoc, targetRef, paragraphMetadataIndex = null) {
161
238
  if (!Number.isInteger(targetRef) || targetRef < 1) return null;
162
- const paragraphs = getDocumentParagraphNodes(xmlDoc);
163
- return paragraphs[targetRef - 1] || null;
239
+ if (paragraphMetadataIndex?.entries) {
240
+ return paragraphMetadataIndex.entries[targetRef - 1]?.paragraph || null;
241
+ }
242
+ return getDocumentParagraphNodes(xmlDoc)[targetRef - 1] || null;
164
243
  }
165
244
 
166
245
  /**
@@ -193,15 +272,22 @@ export function findContainingWordElement(node, localName, namespaceUri = WORD_M
193
272
  * @param {string} targetText - Target paragraph text
194
273
  * @returns {Element|null}
195
274
  */
196
- export function findParagraphByStrictText(xmlDoc, targetText) {
197
- const paragraphs = getDocumentParagraphNodes(xmlDoc);
275
+ export function findParagraphByStrictText(xmlDoc, targetText, options = {}) {
276
+ const metadataIndex = options.paragraphMetadataIndex || null;
277
+ const entries = metadataIndex?.entries || null;
278
+ const paragraphs = entries ? null : getDocumentParagraphNodes(xmlDoc);
198
279
  const normalizedTarget = String(targetText || '').trim();
199
280
  if (!normalizedTarget) return null;
200
281
 
201
- const exact = paragraphs.find(p => getParagraphText(p).trim() === normalizedTarget);
282
+ const exact = entries
283
+ ? entries.find(entry => entry.text.trim() === normalizedTarget)?.paragraph
284
+ : paragraphs.find(p => getParagraphText(p).trim() === normalizedTarget);
202
285
  if (exact) return exact;
203
286
 
204
287
  const normTarget = normalizeWhitespaceForTargeting(normalizedTarget);
288
+ if (metadataIndex?.byNormalizedText) {
289
+ return metadataIndex.byNormalizedText.get(normTarget)?.[0]?.paragraph || null;
290
+ }
205
291
  return paragraphs.find(p => normalizeWhitespaceForTargeting(getParagraphText(p)) === normTarget) || null;
206
292
  }
207
293
 
@@ -215,28 +301,34 @@ export function findParagraphByStrictText(xmlDoc, targetText) {
215
301
  */
216
302
  export function findParagraphByBestTextMatch(xmlDoc, targetText, options = {}) {
217
303
  const onInfo = typeof options.onInfo === 'function' ? options.onInfo : () => {};
218
- const paragraphs = getDocumentParagraphNodes(xmlDoc);
304
+ const metadataIndex = options.paragraphMetadataIndex || null;
305
+ const entries = metadataIndex?.entries || null;
306
+ const paragraphs = entries ? null : getDocumentParagraphNodes(xmlDoc);
219
307
  const normalizedTarget = String(targetText || '').trim();
220
308
  if (!normalizedTarget) return null;
221
309
 
222
- const strictMatch = findParagraphByStrictText(xmlDoc, normalizedTarget);
310
+ const strictMatch = findParagraphByStrictText(xmlDoc, normalizedTarget, { paragraphMetadataIndex: metadataIndex });
223
311
  if (strictMatch) return strictMatch;
224
312
 
225
313
  const normTarget = normalizeWhitespaceForTargeting(normalizedTarget);
226
314
 
227
- const startsWithMatch = paragraphs.find(p => {
228
- const paragraphText = normalizeWhitespaceForTargeting(getParagraphText(p));
229
- return paragraphText.length > 10 && normTarget.startsWith(paragraphText);
230
- });
315
+ const startsWithMatch = entries
316
+ ? (entries.find(entry => entry.normalizedText.length > 10 && normTarget.startsWith(entry.normalizedText))?.paragraph || null)
317
+ : paragraphs.find(p => {
318
+ const paragraphText = normalizeWhitespaceForTargeting(getParagraphText(p));
319
+ return paragraphText.length > 10 && normTarget.startsWith(paragraphText);
320
+ });
231
321
  if (startsWithMatch) {
232
322
  onInfo(`[Fuzzy] Prefix match (target starts with paragraph): "${getParagraphText(startsWithMatch).trim().slice(0, 60)}..."`);
233
323
  return startsWithMatch;
234
324
  }
235
325
 
236
- const containsMatch = paragraphs.find(p => {
237
- const paragraphText = normalizeWhitespaceForTargeting(getParagraphText(p));
238
- return paragraphText.length > 15 && normTarget.includes(paragraphText);
239
- });
326
+ const containsMatch = entries
327
+ ? (entries.find(entry => entry.normalizedText.length > 15 && normTarget.includes(entry.normalizedText))?.paragraph || null)
328
+ : paragraphs.find(p => {
329
+ const paragraphText = normalizeWhitespaceForTargeting(getParagraphText(p));
330
+ return paragraphText.length > 15 && normTarget.includes(paragraphText);
331
+ });
240
332
  if (containsMatch) {
241
333
  onInfo(`[Fuzzy] Contains match: "${getParagraphText(containsMatch).trim().slice(0, 60)}..."`);
242
334
  return containsMatch;
@@ -245,8 +337,10 @@ export function findParagraphByBestTextMatch(xmlDoc, targetText, options = {}) {
245
337
  let bestScore = 0;
246
338
  let bestParagraph = null;
247
339
  const targetWords = new Set(normTarget.toLowerCase().split(/\s+/).filter(word => word.length > 2));
248
- for (const paragraph of paragraphs) {
249
- const paragraphText = getParagraphText(paragraph).trim();
340
+ const candidateCount = entries?.length ?? paragraphs.length;
341
+ for (let index = 0; index < candidateCount; index++) {
342
+ const paragraph = entries?.[index]?.paragraph ?? paragraphs[index];
343
+ const paragraphText = (entries?.[index]?.text ?? getParagraphText(paragraph)).trim();
250
344
  if (!paragraphText) continue;
251
345
 
252
346
  const paragraphWords = normalizeWhitespaceForTargeting(paragraphText)
@@ -291,15 +385,173 @@ export function resolveTargetParagraph(xmlDoc, options = {}) {
291
385
  const onInfo = typeof options.onInfo === 'function' ? options.onInfo : () => {};
292
386
  const onWarn = typeof options.onWarn === 'function' ? options.onWarn : () => {};
293
387
  const opType = options.opType || 'operation';
294
- const cleanTargetText = String(options.targetText || '').trim();
295
- const parsedRef = parseParagraphReference(options.targetRef);
388
+ const descriptor = options.targetDescriptor && typeof options.targetDescriptor === 'object'
389
+ ? options.targetDescriptor
390
+ : null;
391
+ const revisionView = descriptor?.revisionView === 'rejected' ? 'rejected' : 'accepted';
392
+ const cleanTargetText = String(descriptor?.exactText ?? descriptor?.text ?? options.targetText ?? '').trim();
393
+ const parsedRef = parseParagraphReference(descriptor?.index ?? descriptor?.paragraphIndex ?? options.targetRef);
394
+ const strictAmbiguity = options.strictAmbiguity === true;
395
+ let paragraphMetadataIndex = options.paragraphMetadataIndex || null;
396
+ if (paragraphMetadataIndex && paragraphMetadataIndex.revisionView !== revisionView) {
397
+ paragraphMetadataIndex = options.metadataIndices?.[revisionView]
398
+ || buildParagraphMetadataIndex(xmlDoc, { revisionView });
399
+ } else if (!paragraphMetadataIndex) {
400
+ paragraphMetadataIndex = buildParagraphMetadataIndex(xmlDoc, { revisionView });
401
+ }
402
+
403
+ if (descriptor?.paragraphId) {
404
+ const byId = findParagraphById(xmlDoc, descriptor.paragraphId, paragraphMetadataIndex);
405
+ if (!byId) {
406
+ throw createTargetError(
407
+ 'TARGET_NOT_FOUND',
408
+ `Target paragraphId not found: "${descriptor.paragraphId}".`
409
+ );
410
+ }
411
+ const cachedEntry = paragraphMetadataIndex?.byParagraph?.get(byId) || null;
412
+ const actualIndex = cachedEntry?.index ?? getDocumentParagraphNodes(xmlDoc).indexOf(byId) + 1;
413
+ const actualFingerprint = cachedEntry?.fingerprint
414
+ || createParagraphFingerprint(byId, { revisionView });
415
+ const actualInTable = cachedEntry?.inTable ?? isParagraphInTable(byId);
416
+ const actualText = cachedEntry?.normalizedText
417
+ || normalizeWhitespaceForTargeting(extractCanonicalParagraphText(byId, { revisionView }));
418
+
419
+ if (parsedRef != null && parsedRef !== actualIndex) {
420
+ throw createTargetError(
421
+ 'TARGET_INDEX_MISMATCH',
422
+ `Target paragraphId "${descriptor.paragraphId}" (index ${actualIndex}) does not match requested index ${parsedRef}.`,
423
+ cachedEntry ? [serializeTargetCandidate(cachedEntry)] : null
424
+ );
425
+ }
426
+ if (descriptor.fingerprint && descriptor.fingerprint !== actualFingerprint) {
427
+ throw createTargetError(
428
+ 'TARGET_FINGERPRINT_MISMATCH',
429
+ `Target paragraphId "${descriptor.paragraphId}" no longer matches its source fingerprint.`,
430
+ cachedEntry ? [serializeTargetCandidate(cachedEntry)] : null
431
+ );
432
+ }
433
+ if (typeof descriptor.inTable === 'boolean' && descriptor.inTable !== actualInTable) {
434
+ throw createTargetError(
435
+ 'TARGET_CONTEXT_MISMATCH',
436
+ `Target paragraphId "${descriptor.paragraphId}" does not match the requested table context.`,
437
+ cachedEntry ? [serializeTargetCandidate(cachedEntry)] : null
438
+ );
439
+ }
440
+ if (cleanTargetText && actualText !== normalizeWhitespaceForTargeting(cleanTargetText)) {
441
+ throw createTargetError(
442
+ 'TARGET_TEXT_MISMATCH',
443
+ `Target paragraphId "${descriptor.paragraphId}" no longer matches the supplied text.`,
444
+ cachedEntry ? [serializeTargetCandidate(cachedEntry)] : null
445
+ );
446
+ }
447
+ if (descriptor.occurrence != null) {
448
+ const textToFind = cleanTargetText || actualText;
449
+ const textCandidates = findStrictTargetCandidates(xmlDoc, textToFind, paragraphMetadataIndex);
450
+ const actualOccurrence = textCandidates.findIndex(c => c.paragraph === byId) + 1;
451
+ if (actualOccurrence === 0 || actualOccurrence !== descriptor.occurrence) {
452
+ throw createTargetError(
453
+ 'TARGET_OCCURRENCE_MISMATCH',
454
+ `Target paragraphId "${descriptor.paragraphId}" matches occurrence ${actualOccurrence}, not requested occurrence ${descriptor.occurrence}.`,
455
+ textCandidates.map(serializeTargetCandidate)
456
+ );
457
+ }
458
+ }
459
+ return { paragraph: byId, resolvedBy: 'paragraph_id' };
460
+ }
461
+
462
+ let candidates = [];
463
+ if (cleanTargetText) {
464
+ const unfilteredCandidates = findStrictTargetCandidates(xmlDoc, cleanTargetText, paragraphMetadataIndex);
465
+ candidates = filterTargetCandidates(unfilteredCandidates, descriptor);
466
+
467
+ if (descriptor?.fingerprint && unfilteredCandidates.length > 0 && candidates.length === 0) {
468
+ throw createTargetError(
469
+ 'TARGET_FINGERPRINT_MISMATCH',
470
+ 'Target text matched, but no paragraph matched the supplied source fingerprint.',
471
+ unfilteredCandidates.map(serializeTargetCandidate)
472
+ );
473
+ }
474
+
475
+ if (
476
+ typeof descriptor?.inTable === 'boolean'
477
+ && unfilteredCandidates.length > 0
478
+ && candidates.length === 0
479
+ ) {
480
+ throw createTargetError(
481
+ 'TARGET_CONTEXT_MISMATCH',
482
+ 'Target text matched, but no paragraph matched the requested table context.',
483
+ unfilteredCandidates.map(serializeTargetCandidate)
484
+ );
485
+ }
486
+
487
+
488
+ if (descriptor?.occurrence) {
489
+ const occurrenceMatch = candidates[descriptor.occurrence - 1] || null;
490
+ if (!occurrenceMatch) {
491
+ throw createTargetError(
492
+ 'TARGET_NOT_FOUND',
493
+ `Target occurrence ${descriptor.occurrence} was not found.`,
494
+ candidates.map(serializeTargetCandidate)
495
+ );
496
+ }
497
+ return { paragraph: occurrenceMatch.paragraph, resolvedBy: 'occurrence' };
498
+ }
499
+
500
+ if (strictAmbiguity) {
501
+ if (parsedRef) {
502
+ const byReference = candidates.find(candidate => candidate.index === parsedRef) || null;
503
+ if (byReference) return { paragraph: byReference.paragraph, resolvedBy: 'ref' };
504
+ if (descriptor?.fingerprint && candidates.length > 0) {
505
+ throw createTargetError(
506
+ 'TARGET_FINGERPRINT_MISMATCH',
507
+ `Target fingerprint does not match paragraph reference [P${parsedRef}].`,
508
+ candidates.map(serializeTargetCandidate)
509
+ );
510
+ }
511
+ if (candidates.length === 1) {
512
+ return { paragraph: candidates[0].paragraph, resolvedBy: 'strict_text_after_ref_drift' };
513
+ }
514
+ }
515
+ if (candidates.length > 1) {
516
+ throw createTargetError(
517
+ 'AMBIGUOUS_TARGET',
518
+ `Target text matched ${candidates.length} paragraphs; provide paragraphId, index, occurrence, or fingerprint.`,
519
+ candidates.map(serializeTargetCandidate)
520
+ );
521
+ }
522
+ if (candidates.length === 0) {
523
+ throw createTargetNotFoundError(`Target paragraph not found: "${cleanTargetText}"`);
524
+ }
525
+ }
526
+
527
+ if (!parsedRef && candidates.length === 1) {
528
+ const candidate = candidates[0];
529
+ return {
530
+ paragraph: candidate.paragraph,
531
+ resolvedBy: descriptor?.fingerprint ? 'fingerprint' : 'strict_text'
532
+ };
533
+ }
534
+ }
296
535
 
297
536
  if (parsedRef) {
298
- const byRef = findParagraphByReference(xmlDoc, parsedRef);
537
+ const byRef = findParagraphByReference(xmlDoc, parsedRef, paragraphMetadataIndex);
299
538
  if (byRef) {
539
+ const cached = paragraphMetadataIndex?.byParagraph?.get(byRef) || null;
540
+ if (descriptor?.fingerprint && descriptor.fingerprint !== cached?.fingerprint) {
541
+ throw createTargetError(
542
+ 'TARGET_FINGERPRINT_MISMATCH',
543
+ `Target fingerprint does not match paragraph reference [P${parsedRef}].`
544
+ );
545
+ }
546
+ if (typeof descriptor?.inTable === 'boolean' && descriptor.inTable !== cached?.inTable) {
547
+ throw createTargetError(
548
+ 'TARGET_CONTEXT_MISMATCH',
549
+ `Target paragraph reference [P${parsedRef}] does not match requested table context.`
550
+ );
551
+ }
300
552
  if (cleanTargetText) {
301
- const strictMatch = findParagraphByStrictText(xmlDoc, cleanTargetText);
302
- const byRefText = getParagraphText(byRef).trim();
553
+ const strictMatch = findParagraphByStrictText(xmlDoc, cleanTargetText, { paragraphMetadataIndex });
554
+ const byRefText = (cached?.text || extractCanonicalParagraphText(byRef, { revisionView })).trim();
303
555
  const byRefNorm = normalizeWhitespaceForTargeting(byRefText);
304
556
  const targetNorm = normalizeWhitespaceForTargeting(cleanTargetText);
305
557
  const hasDrift = byRefNorm !== targetNorm;
@@ -310,7 +562,7 @@ export function resolveTargetParagraph(xmlDoc, options = {}) {
310
562
  }
311
563
 
312
564
  if (hasDrift) {
313
- const fuzzyMatch = findParagraphByBestTextMatch(xmlDoc, cleanTargetText, { onInfo });
565
+ const fuzzyMatch = findParagraphByBestTextMatch(xmlDoc, cleanTargetText, { onInfo, paragraphMetadataIndex });
314
566
  if (fuzzyMatch && fuzzyMatch !== byRef) {
315
567
  onInfo(`[Target] [P${parsedRef}] drifted for ${opType}; using fuzzy text rematch.`);
316
568
  return { paragraph: fuzzyMatch, resolvedBy: 'fuzzy_text_after_ref_drift' };
@@ -328,11 +580,23 @@ export function resolveTargetParagraph(xmlDoc, options = {}) {
328
580
  onWarn(`[WARN] Target reference [P${parsedRef}] not found; falling back to text matching for ${opType}.`);
329
581
  }
330
582
 
331
- if (cleanTargetText) {
332
- const strictMatch = findParagraphByStrictText(xmlDoc, cleanTargetText);
333
- if (strictMatch) return { paragraph: strictMatch, resolvedBy: 'strict_text' };
583
+ if (cleanTargetText && !strictAmbiguity) {
584
+ const strictMatch = findParagraphByStrictText(xmlDoc, cleanTargetText, { paragraphMetadataIndex });
585
+ if (strictMatch) {
586
+ const candidateCount = candidates.length;
587
+ if (candidateCount > 1) {
588
+ const warningMsg = `AMBIGUOUS_TARGET_HEURISTIC_USED: Target text matched ${candidateCount} paragraphs; permissive resolution chose candidate 1. Migrate to strict targeting (e.g. strictTargets: true with paragraphId, index, occurrence, or fingerprint) before v1.0.0.`;
589
+ onWarn(warningMsg);
590
+ return {
591
+ paragraph: strictMatch,
592
+ resolvedBy: 'strict_text',
593
+ warnings: [warningMsg]
594
+ };
595
+ }
596
+ return { paragraph: strictMatch, resolvedBy: 'strict_text' };
597
+ }
334
598
 
335
- const fuzzyMatch = findParagraphByBestTextMatch(xmlDoc, cleanTargetText, { onInfo });
599
+ const fuzzyMatch = findParagraphByBestTextMatch(xmlDoc, cleanTargetText, { onInfo, paragraphMetadataIndex });
336
600
  if (fuzzyMatch) return { paragraph: fuzzyMatch, resolvedBy: 'fuzzy_text' };
337
601
  }
338
602
 
@@ -345,26 +609,76 @@ function isParagraphInTable(paragraph) {
345
609
  return !!findContainingWordElement(paragraph, 'tbl');
346
610
  }
347
611
 
348
- function findStrictTargetCandidates(xmlDoc, targetText) {
612
+ export function findStrictTargetCandidates(xmlDoc, targetText, optionsOrIndex = null) {
349
613
  const normalizedTarget = normalizeWhitespaceForTargeting(targetText);
350
614
  if (!normalizedTarget) return [];
351
615
 
616
+ const metadataIndex = optionsOrIndex?.byNormalizedText
617
+ ? optionsOrIndex
618
+ : (optionsOrIndex?.paragraphMetadataIndex || null);
619
+ const revisionView = optionsOrIndex?.revisionView
620
+ || metadataIndex?.revisionView
621
+ || 'accepted';
622
+
623
+ if (metadataIndex?.byNormalizedText && metadataIndex.revisionView === revisionView) {
624
+ return Array.from(metadataIndex.byNormalizedText.get(normalizedTarget) || []);
625
+ }
626
+
352
627
  const paragraphs = getDocumentParagraphNodes(xmlDoc);
353
628
  const candidates = [];
354
629
  for (let i = 0; i < paragraphs.length; i++) {
355
630
  const paragraph = paragraphs[i];
356
- const paragraphText = getParagraphText(paragraph).trim();
631
+ const paragraphText = extractCanonicalParagraphText(paragraph, { revisionView }).trim();
357
632
  if (!paragraphText) continue;
358
633
  if (normalizeWhitespaceForTargeting(paragraphText) !== normalizedTarget) continue;
359
634
  candidates.push({
360
635
  paragraph,
361
636
  index: i + 1,
362
- inTable: isParagraphInTable(paragraph)
637
+ inTable: isParagraphInTable(paragraph),
638
+ paragraphId: getParagraphId(paragraph),
639
+ fingerprint: createParagraphFingerprint(paragraph, {
640
+ text: paragraphText,
641
+ index: i + 1,
642
+ revisionView
643
+ }),
644
+ text: paragraphText,
645
+ revisionView
363
646
  });
364
647
  }
365
648
  return candidates;
366
649
  }
367
650
 
651
+ function serializeTargetCandidate(candidate) {
652
+ return {
653
+ index: candidate.index,
654
+ paragraphId: candidate.paragraphId || null,
655
+ text: candidate.text,
656
+ inTable: candidate.inTable,
657
+ fingerprint: candidate.fingerprint,
658
+ revisionView: candidate.revisionView || 'accepted'
659
+ };
660
+ }
661
+
662
+ function filterTargetCandidates(candidates, descriptor) {
663
+ let scoped = candidates.slice();
664
+ if (descriptor?.paragraphId) {
665
+ scoped = scoped.filter(candidate => candidate.paragraphId === descriptor.paragraphId);
666
+ }
667
+ if (typeof descriptor?.inTable === 'boolean') {
668
+ scoped = scoped.filter(candidate => candidate.inTable === descriptor.inTable);
669
+ }
670
+ if (descriptor?.fingerprint) {
671
+ scoped = scoped.filter(candidate => candidate.fingerprint === descriptor.fingerprint);
672
+ }
673
+ return scoped;
674
+ }
675
+
676
+ function findParagraphById(xmlDoc, paragraphId, paragraphMetadataIndex = null) {
677
+ if (!paragraphId) return null;
678
+ if (paragraphMetadataIndex?.byId) return paragraphMetadataIndex.byId.get(paragraphId)?.paragraph || null;
679
+ return getDocumentParagraphNodes(xmlDoc).find(paragraph => getParagraphId(paragraph) === paragraphId) || null;
680
+ }
681
+
368
682
  function selectBestTargetCandidate(candidates, parsedRef, expectedInTable = null) {
369
683
  if (!Array.isArray(candidates) || candidates.length === 0) return null;
370
684
 
@@ -390,16 +704,18 @@ function selectBestTargetCandidate(candidates, parsedRef, expectedInTable = null
390
704
  * @param {Document|Element|null|undefined} xmlDoc - OOXML document root
391
705
  * @returns {Map<number, { text: string, normalizedText: string, inTable: boolean }>}
392
706
  */
393
- export function buildTargetReferenceSnapshot(xmlDoc) {
394
- const paragraphs = getDocumentParagraphNodes(xmlDoc);
707
+ export function buildTargetReferenceSnapshot(xmlDoc, paragraphMetadataIndex = null) {
708
+ const entries = paragraphMetadataIndex?.entries || null;
709
+ const paragraphs = entries ? null : getDocumentParagraphNodes(xmlDoc);
395
710
  const snapshot = new Map();
396
- for (let i = 0; i < paragraphs.length; i++) {
397
- const paragraph = paragraphs[i];
398
- const text = getParagraphText(paragraph).trim();
711
+ const paragraphCount = entries?.length ?? paragraphs.length;
712
+ for (let i = 0; i < paragraphCount; i++) {
713
+ const paragraph = entries?.[i]?.paragraph ?? paragraphs[i];
714
+ const text = (entries?.[i]?.text ?? getParagraphText(paragraph)).trim();
399
715
  snapshot.set(i + 1, {
400
716
  text,
401
- normalizedText: normalizeWhitespaceForTargeting(text),
402
- inTable: isParagraphInTable(paragraph)
717
+ normalizedText: entries?.[i]?.normalizedText ?? normalizeWhitespaceForTargeting(text),
718
+ inTable: entries?.[i]?.inTable ?? isParagraphInTable(paragraph)
403
719
  });
404
720
  }
405
721
  return snapshot;
@@ -437,7 +753,8 @@ export function resolveTargetParagraphWithSnapshot(xmlDoc, options = {}) {
437
753
  const expectedNorm = normalizeWhitespaceForTargeting(expectedText);
438
754
  if (!expectedNorm) return resolved;
439
755
 
440
- const resolvedNorm = normalizeWhitespaceForTargeting(getParagraphText(resolved.paragraph));
756
+ const resolvedNorm = options.paragraphMetadataIndex?.byParagraph?.get(resolved.paragraph)?.normalizedText
757
+ || normalizeWhitespaceForTargeting(getParagraphText(resolved.paragraph));
441
758
  if (resolvedNorm === expectedNorm) return resolved;
442
759
 
443
760
  const candidateTexts = [];
@@ -451,7 +768,7 @@ export function resolveTargetParagraphWithSnapshot(xmlDoc, options = {}) {
451
768
 
452
769
  let bestCandidate = null;
453
770
  for (const candidateText of candidateTexts) {
454
- const candidates = findStrictTargetCandidates(xmlDoc, candidateText);
771
+ const candidates = findStrictTargetCandidates(xmlDoc, candidateText, options.paragraphMetadataIndex || null);
455
772
  const selected = selectBestTargetCandidate(candidates, parsedRef, snapshotEntry.inTable);
456
773
  if (!selected) continue;
457
774
  if (!bestCandidate) bestCandidate = selected;
@@ -525,3 +842,126 @@ export function resolveParagraphRangeByRefs(xmlDoc, startRef, endRef, options =
525
842
  if (!range.every(node => node && node.parentNode === parent)) return null;
526
843
  return range;
527
844
  }
845
+
846
+ /**
847
+ * Validates a paragraph boundary operation (split, delete, join) before mutation.
848
+ * Returns { valid: true } or { valid: false, code: 'UNSAFE_PARAGRAPH_BOUNDARY', message: string }.
849
+ *
850
+ * @param {Element} targetParagraph - Target paragraph node
851
+ * @param {string} modifiedText - Replacement text
852
+ * @param {Object} [options={}] - Options
853
+ * @returns {{ valid: boolean, code?: string, message?: string }}
854
+ */
855
+ export function validateParagraphBoundaryMutation(targetParagraph, modifiedText, options = {}) {
856
+ if (!targetParagraph || !targetParagraph.parentNode) {
857
+ return { valid: true };
858
+ }
859
+
860
+ const isDelete = modifiedText === '' || options.operationKind === 'delete';
861
+ const isSplit = typeof modifiedText === 'string' && modifiedText.includes('\n');
862
+ const targetEndParagraph = options.targetEndParagraph || null;
863
+
864
+ // 1. Table cell boundary checks
865
+ const containingTc = findContainingWordElement(targetParagraph, 'tc');
866
+ if (containingTc) {
867
+ if (isDelete) {
868
+ const cellParagraphs = Array.from(containingTc.childNodes).filter(node => isWordElement(node, 'p'));
869
+ if (cellParagraphs.length <= 1) {
870
+ return {
871
+ valid: false,
872
+ code: 'UNSAFE_PARAGRAPH_BOUNDARY',
873
+ message: 'Refusing to delete the sole terminal paragraph of a table cell.'
874
+ };
875
+ }
876
+ }
877
+ if (targetEndParagraph) {
878
+ const endTc = findContainingWordElement(targetEndParagraph, 'tc');
879
+ if (endTc !== containingTc) {
880
+ return {
881
+ valid: false,
882
+ code: 'UNSAFE_PARAGRAPH_BOUNDARY',
883
+ message: 'Refusing paragraph boundary join across different table cells.'
884
+ };
885
+ }
886
+ }
887
+ } else if (targetEndParagraph) {
888
+ const endTc = findContainingWordElement(targetEndParagraph, 'tc');
889
+ if (endTc) {
890
+ return {
891
+ valid: false,
892
+ code: 'UNSAFE_PARAGRAPH_BOUNDARY',
893
+ message: 'Refusing paragraph boundary join across table boundaries.'
894
+ };
895
+ }
896
+ }
897
+
898
+ // 2. Section break (w:sectPr) checks
899
+ const hasSectPr = targetParagraph.getElementsByTagNameNS(WORD_MAIN_NS, 'sectPr').length > 0;
900
+ if (hasSectPr && isDelete) {
901
+ return {
902
+ valid: false,
903
+ code: 'UNSAFE_PARAGRAPH_BOUNDARY',
904
+ message: 'Refusing to delete paragraph containing section properties (w:sectPr).'
905
+ };
906
+ }
907
+
908
+ if (targetEndParagraph) {
909
+ let cursor = targetParagraph;
910
+ while (cursor && cursor !== targetEndParagraph) {
911
+ if (isWordElement(cursor, 'p') && cursor.getElementsByTagNameNS(WORD_MAIN_NS, 'sectPr').length > 0) {
912
+ return {
913
+ valid: false,
914
+ code: 'UNSAFE_PARAGRAPH_BOUNDARY',
915
+ message: 'Refusing paragraph boundary join across section break.'
916
+ };
917
+ }
918
+ cursor = cursor.nextSibling;
919
+ }
920
+ }
921
+
922
+ // 3. Field instruction checks during split
923
+ if (isSplit) {
924
+ const fldSimples = targetParagraph.getElementsByTagNameNS(WORD_MAIN_NS, 'fldSimple');
925
+ if (fldSimples.length > 0) {
926
+ return {
927
+ valid: false,
928
+ code: 'UNSAFE_PARAGRAPH_BOUNDARY',
929
+ message: 'Refusing to split paragraph containing a simple field instruction (w:fldSimple).'
930
+ };
931
+ }
932
+
933
+ const fldChars = Array.from(targetParagraph.getElementsByTagNameNS(WORD_MAIN_NS, 'fldChar'));
934
+ if (fldChars.length > 0) {
935
+ let activeFields = 0;
936
+ for (const fc of fldChars) {
937
+ const fldCharType = fc.getAttributeNS(WORD_MAIN_NS, 'fldCharType') || fc.getAttribute('w:fldCharType');
938
+ if (fldCharType === 'begin') activeFields++;
939
+ else if (fldCharType === 'end') activeFields = Math.max(0, activeFields - 1);
940
+ }
941
+ if (activeFields > 0) {
942
+ return {
943
+ valid: false,
944
+ code: 'UNSAFE_PARAGRAPH_BOUNDARY',
945
+ message: 'Refusing to split paragraph across an unclosed field instruction.'
946
+ };
947
+ }
948
+ }
949
+
950
+ // 4. Bookmark range boundary checks
951
+ const bookmarkStarts = Array.from(targetParagraph.getElementsByTagNameNS(WORD_MAIN_NS, 'bookmarkStart'));
952
+ const bookmarkEnds = Array.from(targetParagraph.getElementsByTagNameNS(WORD_MAIN_NS, 'bookmarkEnd'));
953
+ const bStartIds = new Set(bookmarkStarts.map(b => b.getAttributeNS(WORD_MAIN_NS, 'id') || b.getAttribute('w:id')));
954
+ const bEndIds = new Set(bookmarkEnds.map(b => b.getAttributeNS(WORD_MAIN_NS, 'id') || b.getAttribute('w:id')));
955
+ for (const id of bStartIds) {
956
+ if (id && !bEndIds.has(id)) {
957
+ return {
958
+ valid: false,
959
+ code: 'UNSAFE_PARAGRAPH_BOUNDARY',
960
+ message: `Refusing to split paragraph across open bookmark range (ID: ${id}).`
961
+ };
962
+ }
963
+ }
964
+ }
965
+
966
+ return { valid: true };
967
+ }