@ansonlai/docx-redline-js 0.4.0 → 0.5.1

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 (104) hide show
  1. package/AGENTS.md +646 -288
  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/redline-validation.js +11 -5
  11. package/core/revision-cloning.js +38 -0
  12. package/core/types.js +64 -10
  13. package/core/word-xml.js +43 -15
  14. package/dist/docx-redline-js.esm.js +3145 -505
  15. package/dist/docx-redline-js.esm.js.map +4 -4
  16. package/dist/docx-redline-js.esm.min.js +88 -76
  17. package/dist/docx-redline-js.esm.min.js.map +4 -4
  18. package/docs/TESTING.md +342 -23
  19. package/docs/plans/2026-09-05-structural-revisions-and-fidelity-oracles.md +1669 -0
  20. package/docs/plans/2026-09-08-cross-author-revision-slicing.md +505 -0
  21. package/docs/plans/completed/2026-09-01-performance-and-complexity-reduction.md +669 -0
  22. package/docs/plans/completed/2026-09-03-agent-friendly-document-workflows.md +427 -0
  23. package/docs/plans/completed/2026-09-04-comment-anchor-and-cli-reliability.md +519 -0
  24. package/docs/plans/completed/PERFORMANCE-CONSOLIDATION.md +69 -0
  25. package/docs/plans/completed/structural-revision-capability-matrix.md +115 -0
  26. package/docs/schemas/document-operations.schema.json +109 -0
  27. package/docs/test-comparison-dashboard.html +4250 -7
  28. package/engine/formatting-removal.js +11 -2
  29. package/engine/oxml-engine.js +508 -336
  30. package/engine/reconstruction-mode.js +15 -14
  31. package/engine/reconstruction-writer.js +247 -142
  32. package/engine/route-selection.js +35 -0
  33. package/engine/rpr-helpers.js +334 -35
  34. package/engine/run-builders.js +239 -196
  35. package/engine/surgical-diff-application.js +407 -50
  36. package/engine/surgical-mode.js +142 -6
  37. package/engine/surgical-run-splitting.js +103 -0
  38. package/engine/surgical-spans.js +52 -1
  39. package/engine/table-cell-context.js +3 -6
  40. package/engine/table-mode.js +1 -1
  41. package/index.d.ts +234 -6
  42. package/index.js +24 -1
  43. package/node/cli.js +322 -0
  44. package/node/docx-document.js +302 -0
  45. package/node/index.d.ts +31 -0
  46. package/node/index.js +2 -0
  47. package/node/zip-archive.js +52 -0
  48. package/orchestration/list-markdown.js +10 -16
  49. package/orchestration/list-parsing.js +7 -12
  50. package/orchestration/list-structural-fallback.js +21 -10
  51. package/package.json +123 -102
  52. package/pipeline/content-analysis.js +12 -17
  53. package/pipeline/ingestion-export.js +3 -31
  54. package/pipeline/ingestion-paragraph.js +10 -5
  55. package/pipeline/list-generation.js +150 -55
  56. package/pipeline/list-markers.js +70 -3
  57. package/pipeline/serialization.js +4 -2
  58. package/pipeline/structured-content.js +160 -0
  59. package/scripts/apply_changes.mjs +27 -0
  60. package/scripts/benchmark-operation-session.mjs +137 -0
  61. package/scripts/benchmark-targeting-browser.html +74 -0
  62. package/scripts/benchmark-targeting-hot-paths.mjs +67 -0
  63. package/scripts/benchmark-test-runner.mjs +59 -0
  64. package/scripts/build-test-dashboard.mjs +23 -0
  65. package/scripts/export-lane1-fixtures.mjs +380 -0
  66. package/scripts/export-reredline-stress-fixtures.mjs +317 -0
  67. package/scripts/export-validation-fixtures.mjs +1 -1
  68. package/scripts/extract_text.mjs +7 -0
  69. package/scripts/generate-cross-author-slicing-fixtures.ps1 +256 -0
  70. package/scripts/generate-paragraph-boundary-fixtures.ps1 +215 -0
  71. package/scripts/generate-test-dashboard.mjs +362 -11
  72. package/scripts/lib/word-coverage-catalogue.mjs +6 -2
  73. package/scripts/profile-route-selection.mjs +19 -0
  74. package/scripts/render-agenda-multilevel.mjs +0 -5
  75. package/scripts/render-multilevel-cases.mjs +0 -1
  76. package/scripts/run-tests.mjs +107 -35
  77. package/scripts/word-com-corpus-suite.ps1 +3 -0
  78. package/scripts/word-com-differential.ps1 +64 -4
  79. package/scripts/word-com-suite.ps1 +3 -0
  80. package/services/batch-operation-orchestrator.js +513 -0
  81. package/services/capture-engine.js +226 -0
  82. package/services/comment-builders.js +23 -6
  83. package/services/comment-engine.js +108 -47
  84. package/services/comment-locator.js +187 -82
  85. package/services/comment-replies.js +95 -0
  86. package/services/document-inspection.js +258 -0
  87. package/services/document-operation-applier.js +372 -0
  88. package/services/document-operation-contract.js +345 -0
  89. package/services/document-operation-mutations.js +1749 -0
  90. package/services/document-operation-session.js +258 -0
  91. package/services/numbering-service.js +14 -5
  92. package/services/operation-heuristics.js +173 -0
  93. package/services/operation-preflight.js +390 -0
  94. package/services/receipt-collector.js +288 -0
  95. package/services/revision-comment-management.js +77 -5
  96. package/services/revision-token.js +290 -0
  97. package/services/standalone-docx-plumbing.js +123 -8
  98. package/services/standalone-operation-runner.d.ts +296 -0
  99. package/services/standalone-operation-runner.js +10 -1455
  100. package/services/table-reconciliation.js +15 -6
  101. package/docs/VALIDATION.md +0 -183
  102. package/docs/WORD-MANUAL-REVIEW.md +0 -138
  103. package/docs/plans/2026-09-01-performance-and-complexity-reduction.md +0 -210
  104. /package/docs/plans/{2026-08-30-reliability-testing-improvements.md → completed/2026-08-30-reliability-testing-improvements.md} +0 -0
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Capture entity management, resolution, and validation for batch operations.
3
+ */
4
+
5
+ import { NS_W } from '../core/types.js';
6
+ import {
7
+ createParagraphFingerprint,
8
+ getDocumentParagraphNodes,
9
+ getParagraphId,
10
+ findContainingWordElement
11
+ } from '../core/paragraph-targeting.js';
12
+ import { extractCanonicalParagraphText } from '../core/paragraph-text.js';
13
+
14
+ function createCaptureError(code, message) {
15
+ const error = new Error(message);
16
+ error.code = code;
17
+ return error;
18
+ }
19
+
20
+ export function ensureParagraphIdsOnImportedNode(_node, _session) {
21
+ // Preserves existing paragraph attributes without injecting undeclared namespaces into the document.
22
+ }
23
+
24
+ /**
25
+ * Derives a serializable CapturedEntity record from the live DOM nodes produced by an operation.
26
+ *
27
+ * @param {import('./document-operation-session.js').DocumentOperationSession} session
28
+ * @param {object} operation
29
+ * @param {Array<Element>} liveNodes
30
+ * @returns {object} CapturedEntity
31
+ */
32
+ export function deriveCapturedEntity(session, operation, liveNodes) {
33
+ const paragraphs = [];
34
+ let hasTable = false;
35
+ let hasList = false;
36
+
37
+ for (const node of (liveNodes || [])) {
38
+ if (!node) continue;
39
+ if (node.localName === 'tbl') {
40
+ hasTable = true;
41
+ paragraphs.push(...Array.from(node.getElementsByTagNameNS(NS_W, 'p')));
42
+ } else if (node.localName === 'p') {
43
+ if (node.getElementsByTagNameNS(NS_W, 'numPr').length > 0) {
44
+ hasList = true;
45
+ }
46
+ paragraphs.push(node);
47
+ }
48
+ }
49
+
50
+ const kind = hasTable
51
+ ? 'table'
52
+ : (hasList ? 'list' : (paragraphs.length > 1 ? 'range' : 'paragraph'));
53
+
54
+ const generatedParagraphIds = [];
55
+ const fingerprints = [];
56
+ const expectedText = [];
57
+ const structuralPathHints = [];
58
+
59
+ const xmlDoc = session.document;
60
+ const allDocParagraphs = getDocumentParagraphNodes(xmlDoc);
61
+
62
+ for (const p of paragraphs) {
63
+ const paraId = getParagraphId(p) || '';
64
+ generatedParagraphIds.push(paraId);
65
+ const text = extractCanonicalParagraphText(p);
66
+ expectedText.push(text);
67
+ const pIndex = allDocParagraphs.indexOf(p) + 1;
68
+ fingerprints.push(createParagraphFingerprint(p, {
69
+ text,
70
+ paragraphId: paraId || null,
71
+ index: pIndex > 0 ? pIndex : undefined,
72
+ inTable: hasTable || !!findContainingWordElement(p, 'tbl')
73
+ }));
74
+ const parentTag = p.parentNode?.localName || 'body';
75
+ structuralPathHints.push(`${parentTag}/${p.localName}`);
76
+ }
77
+
78
+ return {
79
+ captureKey: operation.captureKey,
80
+ operationIndex: typeof operation.index === 'number' ? operation.index : 0,
81
+ kind,
82
+ generatedParagraphIds,
83
+ fingerprints,
84
+ expectedText,
85
+ structuralPathHints,
86
+ stale: false
87
+ };
88
+ }
89
+
90
+ /**
91
+ * Invalidates any captures in the session capture table that referenced removed or replaced paragraphs.
92
+ *
93
+ * @param {Map<string, object>} captureTable
94
+ * @param {Array<Element>} removedNodes
95
+ */
96
+ export function invalidateAffectedCaptures(captureTable, removedNodes) {
97
+ if (!captureTable || !(captureTable instanceof Map) || !Array.isArray(removedNodes) || removedNodes.length === 0) return;
98
+ const removedIds = new Set();
99
+ for (const node of removedNodes) {
100
+ if (!node) continue;
101
+ if (node.localName === 'p') {
102
+ const id = getParagraphId(node);
103
+ if (id) removedIds.add(id);
104
+ } else if (typeof node.getElementsByTagNameNS === 'function') {
105
+ for (const p of Array.from(node.getElementsByTagNameNS(NS_W, 'p'))) {
106
+ const id = getParagraphId(p);
107
+ if (id) removedIds.add(id);
108
+ }
109
+ }
110
+ }
111
+ if (removedIds.size === 0) return;
112
+ for (const [, captured] of captureTable) {
113
+ if (captured.stale) continue;
114
+ if (captured.generatedParagraphIds.some(id => id && removedIds.has(id))) {
115
+ captured.stale = true;
116
+ }
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Resolves a target paragraph from an existing capture in the session capture table.
122
+ *
123
+ * @param {Document} xmlDoc
124
+ * @param {import('./document-operation-session.js').DocumentOperationSession} session
125
+ * @param {object} targetDescriptor
126
+ * @param {string} opType
127
+ * @param {object} options
128
+ * @returns {{ paragraph: Element, resolvedBy: 'capture' }}
129
+ */
130
+ export function resolveTargetFromCapture(xmlDoc, session, targetDescriptor, _opType = 'redline', _options = {}) {
131
+ const captureRef = targetDescriptor?.captureRef;
132
+ if (!captureRef) {
133
+ throw createCaptureError('INVALID_OPERATION', 'Operation target is missing captureRef.');
134
+ }
135
+
136
+ if (!session || !session.captureTable || !session.captureTable.has(captureRef)) {
137
+ throw createCaptureError('CAPTURE_NOT_FOUND', `Capture "${captureRef}" was not found in the session capture table.`);
138
+ }
139
+
140
+ const captured = session.captureTable.get(captureRef);
141
+ if (captured.stale) {
142
+ throw createCaptureError('CAPTURE_STALE', `Capture "${captureRef}" is stale; the captured content was modified or removed.`);
143
+ }
144
+
145
+ const metadataIndex = session.getParagraphMetadataIndex();
146
+ const liveParagraphs = [];
147
+
148
+ for (let i = 0; i < captured.expectedText.length; i++) {
149
+ const expectedParaId = captured.generatedParagraphIds[i];
150
+ const expected = captured.expectedText[i];
151
+ let foundNode = null;
152
+
153
+ if (expectedParaId && metadataIndex.byId.has(expectedParaId)) {
154
+ foundNode = metadataIndex.byId.get(expectedParaId).paragraph;
155
+ }
156
+
157
+ if (!foundNode) {
158
+ const candidate = metadataIndex.entries.find(entry =>
159
+ entry.text === expected || entry.fingerprint === captured.fingerprints[i]
160
+ );
161
+ if (candidate) foundNode = candidate.paragraph;
162
+ }
163
+
164
+ if (!foundNode || !foundNode.parentNode) {
165
+ captured.stale = true;
166
+ throw createCaptureError('CAPTURE_STALE', `Capture "${captureRef}" is stale; paragraph ${i + 1} was removed from the document.`);
167
+ }
168
+
169
+ const currentText = extractCanonicalParagraphText(foundNode);
170
+ if (currentText !== expected) {
171
+ captured.stale = true;
172
+ throw createCaptureError('CAPTURE_STALE', `Capture "${captureRef}" is stale; paragraph ${i + 1} content drifted from "${expected}" to "${currentText}".`);
173
+ }
174
+
175
+ liveParagraphs.push(foundNode);
176
+ }
177
+
178
+ if (liveParagraphs.length === 0) {
179
+ captured.stale = true;
180
+ throw createCaptureError('CAPTURE_STALE', `Capture "${captureRef}" is stale; no live paragraphs found.`);
181
+ }
182
+
183
+ const select = targetDescriptor.select;
184
+ if (select == null || select === '') {
185
+ if (liveParagraphs.length === 1) {
186
+ return { paragraph: liveParagraphs[0], resolvedBy: 'capture' };
187
+ }
188
+ throw createCaptureError(
189
+ 'AMBIGUOUS_CAPTURE_SELECTION',
190
+ `Capture "${captureRef}" contains ${liveParagraphs.length} paragraphs; target.select is required to disambiguate.`
191
+ );
192
+ }
193
+
194
+ let matched = [];
195
+ const trimmedSelect = String(select).trim();
196
+ if (trimmedSelect === ':first' || trimmedSelect === 'first') {
197
+ matched = [liveParagraphs[0]];
198
+ } else if (trimmedSelect === ':last' || trimmedSelect === 'last') {
199
+ matched = [liveParagraphs[liveParagraphs.length - 1]];
200
+ } else if (/^\d+$/.test(trimmedSelect)) {
201
+ const index1Based = parseInt(trimmedSelect, 10);
202
+ if (index1Based >= 1 && index1Based <= liveParagraphs.length) {
203
+ matched = [liveParagraphs[index1Based - 1]];
204
+ }
205
+ } else {
206
+ const exactMatches = liveParagraphs.filter(p => extractCanonicalParagraphText(p).trim() === trimmedSelect);
207
+ if (exactMatches.length > 0) {
208
+ matched = exactMatches;
209
+ } else {
210
+ matched = liveParagraphs.filter(p => extractCanonicalParagraphText(p).includes(trimmedSelect));
211
+ }
212
+ }
213
+
214
+ if (matched.length === 0) {
215
+ throw createCaptureError('TARGET_NOT_FOUND', `Target selection "${select}" was not found in capture "${captureRef}".`);
216
+ }
217
+
218
+ if (matched.length > 1) {
219
+ throw createCaptureError(
220
+ 'AMBIGUOUS_CAPTURE_SELECTION',
221
+ `Target selection "${select}" matched ${matched.length} paragraphs in capture "${captureRef}".`
222
+ );
223
+ }
224
+
225
+ return { paragraph: matched[0], resolvedBy: 'capture' };
226
+ }
@@ -2,7 +2,16 @@
2
2
  * Comment XML builders.
3
3
  */
4
4
 
5
- import { NS_W, escapeXml } from '../core/types.js';
5
+ import { NS_W, escapeXml } from '../core/types.js';
6
+
7
+ export const NS_W14 = 'http://schemas.microsoft.com/office/word/2010/wordml';
8
+ export const NS_W15 = 'http://schemas.microsoft.com/office/word/2012/wordml';
9
+
10
+ export function createCommentParaId(commentId) {
11
+ const numeric = Number.parseInt(String(commentId), 10);
12
+ const value = Number.isFinite(numeric) ? (0x70000000 + (numeric >>> 0)) >>> 0 : 0x70000000;
13
+ return value.toString(16).toUpperCase().padStart(8, '0').slice(-8);
14
+ }
6
15
 
7
16
  /**
8
17
  * Builds a single w:comment element.
@@ -13,13 +22,13 @@ import { NS_W, escapeXml } from '../core/types.js';
13
22
  * @param {string} date - ISO date string
14
23
  * @returns {string}
15
24
  */
16
- export function buildCommentElement(commentId, author, content, date) {
25
+ export function buildCommentElement(commentId, author, content, date, paraId = createCommentParaId(commentId)) {
17
26
  const initials = author.split(' ').map(word => word[0]).join('').toUpperCase() || 'AI';
18
27
  const escapedContent = escapeXml(content);
19
28
  const escapedAuthor = escapeXml(author);
20
29
 
21
30
  return `<w:comment w:id="${commentId}" w:author="${escapedAuthor}" w:date="${date}" w:initials="${initials}">
22
- <w:p>
31
+ <w:p w14:paraId="${escapeXml(paraId)}" xmlns:w14="${NS_W14}">
23
32
  <w:r><w:t>${escapedContent}</w:t></w:r>
24
33
  </w:p>
25
34
  </w:comment>`;
@@ -31,19 +40,27 @@ export function buildCommentElement(commentId, author, content, date) {
31
40
  * @param {Array<{id:number,content:string,author:string,date:string}>} comments - Placed comments
32
41
  * @returns {string}
33
42
  */
34
- export function buildCommentsPartXml(comments) {
43
+ export function buildCommentsPartXml(comments) {
35
44
  if (!comments || comments.length === 0) {
36
45
  return `<w:comments xmlns:w="${NS_W}"></w:comments>`;
37
46
  }
38
47
 
39
48
  const commentElements = comments.map(comment =>
40
- buildCommentElement(comment.id, comment.author, comment.content, comment.date)
49
+ buildCommentElement(comment.id, comment.author, comment.content, comment.date, comment.paraId)
41
50
  ).join('\n ');
42
51
 
43
52
  return `<w:comments xmlns:w="${NS_W}">
44
53
  ${commentElements}
45
54
  </w:comments>`;
46
- }
55
+ }
56
+
57
+ export function buildCommentsExtendedPartXml(entries) {
58
+ const body = (entries || []).map(entry => {
59
+ const parent = entry.paraIdParent ? ` w15:paraIdParent="${escapeXml(entry.paraIdParent)}"` : '';
60
+ return `<w15:commentEx w15:paraId="${escapeXml(entry.paraId)}"${parent} w15:done="${entry.done ? '1' : '0'}"/>`;
61
+ }).join('');
62
+ return `<w15:commentsEx xmlns:w15="${NS_W15}">${body}</w15:commentsEx>`;
63
+ }
47
64
 
48
65
  /**
49
66
  * Builds inline range/reference markers for a comment id.
@@ -4,13 +4,19 @@
4
4
  * Provides pure OOXML-based comment insertion without Word JS API calls.
5
5
  */
6
6
 
7
- import { NS_W, getNextRevisionId, getRevisionTimestamp, resetRevisionIdCounter } from '../core/types.js';
7
+ import {
8
+ NS_W,
9
+ createRevisionIdAllocator,
10
+ getNextRevisionId,
11
+ getRevisionTimestamp,
12
+ resetRevisionIdCounter
13
+ } from '../core/types.js';
8
14
  import { createSerializer, parseOoxmlSafe } from '../adapters/xml-adapter.js';
9
15
  import { log, error as logError } from '../adapters/logger.js';
10
16
  import { getElementsByTag, getFirstElementByTag, getXmlParseError } from '../core/xml-query.js';
11
17
  import { buildCommentElement, buildCommentsPartXml, buildCommentMarkers } from './comment-builders.js';
12
18
  import { getDefaultAuthor } from '../adapters/config.js';
13
- import { createParagraphTextIndex, injectMarkersIntoParagraph } from './comment-locator.js';
19
+ import { createParagraphTextIndex, injectMarkersIntoParagraph, resolveTextInParagraphIndex } from './comment-locator.js';
14
20
  import {
15
21
  injectCommentsIntoPackage as injectCommentsIntoExistingPackage,
16
22
  wrapParagraphWithComments,
@@ -63,7 +69,9 @@ export function injectCommentsIntoOoxml(oxml, comments, options = {}) {
63
69
  const author = options?.author || getDefaultAuthor();
64
70
  const date = getRevisionTimestamp();
65
71
  const warnings = [];
66
- const placedComments = [];
72
+ const placedComments = [];
73
+ const resolvedAnchors = [];
74
+ const errors = [];
67
75
 
68
76
  if (!comments || comments.length === 0) {
69
77
  return {
@@ -93,26 +101,29 @@ export function injectCommentsIntoOoxml(oxml, comments, options = {}) {
93
101
  };
94
102
  }
95
103
 
96
- const xmlDoc = parseResult.xmlDoc;
97
- const paragraphs = getElementsByTag(xmlDoc, 'w:p');
104
+ const xmlDoc = parseResult.xmlDoc;
105
+ const revisionIdAllocator = createRevisionIdAllocator(xmlDoc);
106
+ const paragraphs = getElementsByTag(xmlDoc, 'w:p');
98
107
  log(`[CommentEngine] Found ${paragraphs.length} paragraphs, processing ${comments.length} comment requests`);
99
108
 
100
109
  /** @type {Map<number, number>} */
101
110
  const remainingRequestsByParagraph = new Map();
102
- for (const request of comments) {
103
- const paragraphIndex = request.paragraphIndex - 1;
104
- if (paragraphIndex < 0 || paragraphIndex >= paragraphs.length) {
105
- warnings.push(`Paragraph ${request.paragraphIndex} out of range (1-${paragraphs.length})`);
106
- continue;
107
- }
111
+ for (const request of comments) {
112
+ const paragraphIndex = request.paragraphIndex - 1;
113
+ if (paragraphIndex < 0 || paragraphIndex >= paragraphs.length) {
114
+ const message = `Paragraph ${request.paragraphIndex} out of range (1-${paragraphs.length})`;
115
+ warnings.push(message);
116
+ errors.push({ code: 'TARGET_NOT_FOUND', message, paragraphIndex: request.paragraphIndex });
117
+ continue;
118
+ }
108
119
  remainingRequestsByParagraph.set(paragraphIndex, (remainingRequestsByParagraph.get(paragraphIndex) || 0) + 1);
109
120
  }
110
121
 
111
122
  /** @type {Map<number, { fullText: string, runOffsets: Array<{run: Element, start: number, end: number}> }>} */
112
123
  const paragraphIndexes = new Map();
113
124
 
114
- for (const request of comments) {
115
- const paragraphIndex = request.paragraphIndex - 1;
125
+ for (const [requestIndex, request] of comments.entries()) {
126
+ const paragraphIndex = request.paragraphIndex - 1;
116
127
  if (paragraphIndex < 0 || paragraphIndex >= paragraphs.length) {
117
128
  continue;
118
129
  }
@@ -124,32 +135,64 @@ export function injectCommentsIntoOoxml(oxml, comments, options = {}) {
124
135
  paragraphIndexes.set(paragraphIndex, textIndex);
125
136
  }
126
137
 
127
- const commentId = getNextRevisionId();
128
- const success = injectMarkersIntoParagraph(
129
- xmlDoc,
130
- targetParagraph,
131
- request.textToFind,
132
- commentId,
133
- textIndex
134
- );
135
-
136
- const remaining = (remainingRequestsByParagraph.get(paragraphIndex) || 1) - 1;
137
- remainingRequestsByParagraph.set(paragraphIndex, remaining);
138
-
139
- if (!success) {
140
- warnings.push(`Could not find "${request.textToFind.substring(0, 30)}..." in paragraph ${request.paragraphIndex}`);
141
- if (remaining === 0) {
142
- paragraphIndexes.delete(paragraphIndex);
143
- }
138
+ const anchorText = String(request.textToFind ?? '');
139
+ const resolution = resolveTextInParagraphIndex(textIndex, anchorText);
140
+ const remaining = (remainingRequestsByParagraph.get(paragraphIndex) || 1) - 1;
141
+ remainingRequestsByParagraph.set(paragraphIndex, remaining);
142
+ if (!resolution.found) {
143
+ const error = {
144
+ ...resolution.error,
145
+ requestIndex: requestIndex + 1,
146
+ paragraphIndex: request.paragraphIndex
147
+ };
148
+ errors.push(error);
149
+ warnings.push(error.message);
150
+ if (remaining === 0) paragraphIndexes.delete(paragraphIndex);
151
+ continue;
152
+ }
153
+
154
+ const commentId = typeof options.commentIdAllocator === 'function'
155
+ ? options.commentIdAllocator()
156
+ : getNextRevisionId();
157
+ const success = injectMarkersIntoParagraph(
158
+ xmlDoc,
159
+ targetParagraph,
160
+ anchorText,
161
+ commentId,
162
+ textIndex,
163
+ revisionIdAllocator,
164
+ resolution
165
+ );
166
+
167
+ if (!success) {
168
+ const error = {
169
+ code: 'ANCHOR_INSERTION_FAILED',
170
+ message: `Resolved comment anchor could not be inserted in paragraph ${request.paragraphIndex}.`,
171
+ requestIndex: requestIndex + 1,
172
+ paragraphIndex: request.paragraphIndex
173
+ };
174
+ errors.push(error);
175
+ warnings.push(error.message);
176
+ if (remaining === 0) {
177
+ paragraphIndexes.delete(paragraphIndex);
178
+ }
144
179
  continue;
145
180
  }
146
181
 
147
- placedComments.push({
182
+ placedComments.push({
148
183
  id: commentId,
149
184
  content: request.commentContent,
150
185
  author,
151
- date
152
- });
186
+ date
187
+ });
188
+ resolvedAnchors.push({
189
+ requestIndex: requestIndex + 1,
190
+ paragraphIndex: request.paragraphIndex,
191
+ text: anchorText,
192
+ resolvedBy: resolution.resolvedBy,
193
+ start: resolution.start,
194
+ end: resolution.end
195
+ });
153
196
 
154
197
  if (remaining > 0) {
155
198
  // Rebuild only when another request still targets this paragraph.
@@ -159,21 +202,26 @@ export function injectCommentsIntoOoxml(oxml, comments, options = {}) {
159
202
  }
160
203
  }
161
204
 
162
- if (placedComments.length === 0) {
205
+ if (placedComments.length === 0) {
163
206
  return {
164
207
  oxml,
165
208
  hasChanges: false,
166
209
  commentsApplied: 0,
167
- warnings
210
+ warnings,
211
+ resolvedAnchors,
212
+ ...(errors.length > 0 ? { status: 'error', error: errors[0], errors } : {})
168
213
  };
169
- }
214
+ }
170
215
 
171
216
  return {
172
217
  oxml: serializer.serializeToString(xmlDoc),
173
218
  hasChanges: true,
174
219
  commentsXml: buildCommentsPartXml(placedComments),
175
- commentsApplied: placedComments.length,
176
- warnings
220
+ commentsApplied: placedComments.length,
221
+ placedComments,
222
+ warnings,
223
+ resolvedAnchors,
224
+ ...(errors.length > 0 ? { status: 'error', error: errors[0], errors } : {})
177
225
  };
178
226
  }
179
227
 
@@ -187,10 +235,9 @@ export function injectCommentsIntoOoxml(oxml, comments, options = {}) {
187
235
  * @param {string} [options.author='AI Assistant'] - Comment author
188
236
  * @returns {{ success: boolean, package?: string, warning?: string, commentId?: number }}
189
237
  */
190
- export function injectCommentIntoParagraphOoxml(paragraphOoxml, textToFind, commentContent, options = {}) {
191
- const { author = 'AI Assistant' } = options;
192
- const date = getRevisionTimestamp();
193
- const commentId = getNextRevisionId();
238
+ export function injectCommentIntoParagraphOoxml(paragraphOoxml, textToFind, commentContent, options = {}) {
239
+ const { author = 'AI Assistant' } = options;
240
+ const date = getRevisionTimestamp();
194
241
 
195
242
  const serializer = createSerializer();
196
243
  const parseResult = parseDocumentOxml(
@@ -202,15 +249,29 @@ export function injectCommentIntoParagraphOoxml(paragraphOoxml, textToFind, comm
202
249
  return { success: false, warning: parseResult.warning };
203
250
  }
204
251
 
205
- const xmlDoc = parseResult.xmlDoc;
206
- const paragraphs = getElementsByTag(xmlDoc, 'w:p');
252
+ const xmlDoc = parseResult.xmlDoc;
253
+ const revisionIdAllocator = createRevisionIdAllocator(xmlDoc);
254
+ const paragraphs = getElementsByTag(xmlDoc, 'w:p');
207
255
  if (paragraphs.length === 0) {
208
256
  return { success: false, warning: 'No paragraph found in OOXML' };
209
257
  }
210
258
 
211
- const paragraph = paragraphs[0];
212
- const paragraphIndex = createParagraphTextIndex(paragraph);
213
- const success = injectMarkersIntoParagraph(xmlDoc, paragraph, textToFind, commentId, paragraphIndex);
259
+ const paragraph = paragraphs[0];
260
+ const paragraphIndex = createParagraphTextIndex(paragraph);
261
+ const resolution = resolveTextInParagraphIndex(paragraphIndex, textToFind);
262
+ if (!resolution.found) {
263
+ return { success: false, warning: resolution.error.message, error: resolution.error };
264
+ }
265
+ const commentId = getNextRevisionId();
266
+ const success = injectMarkersIntoParagraph(
267
+ xmlDoc,
268
+ paragraph,
269
+ textToFind,
270
+ commentId,
271
+ paragraphIndex,
272
+ revisionIdAllocator,
273
+ resolution
274
+ );
214
275
  if (!success) {
215
276
  return { success: false, warning: `Could not find "${textToFind.substring(0, 30)}..." in paragraph` };
216
277
  }