@ansonlai/docx-redline-js 0.8.0 → 0.8.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 (40) hide show
  1. package/CHANGELOG.md +539 -486
  2. package/README.md +331 -309
  3. package/dist/docx-redline-js.esm.js +1083 -163
  4. package/dist/docx-redline-js.esm.js.map +4 -4
  5. package/dist/docx-redline-js.esm.min.js +107 -107
  6. package/dist/docx-redline-js.esm.min.js.map +4 -4
  7. package/dist/docx-redline.bundle.cjs +1083 -163
  8. package/dist/docx-redline.bundle.cjs.map +4 -4
  9. package/dist/docx-redline.bundle.js +1083 -163
  10. package/dist/docx-redline.bundle.js.map +4 -4
  11. package/docs/AGENT_KNOWLEDGE_BASE.md +69 -8
  12. package/docs/schemas/document-operations.schema.json +29 -0
  13. package/document/docx-document.js +416 -22
  14. package/engine/reconstruction-writer.js +18 -0
  15. package/index.d.ts +598 -575
  16. package/index.js +349 -348
  17. package/node/cli-help.js +5 -5
  18. package/node/cli.js +760 -754
  19. package/node/index.d.ts +1 -1
  20. package/package.json +2 -1
  21. package/scripts/generate-word-comment-edge-case-fixture.ps1 +58 -0
  22. package/scripts/generate-word-comment-thread-fixture.ps1 +36 -0
  23. package/scripts/generate-word-header-footer-fixture.ps1 +32 -0
  24. package/scripts/generate-word-multi-section-fixture.ps1 +27 -0
  25. package/scripts/generate-word-package-integrity-cases.mjs +141 -0
  26. package/scripts/generate-word-thread-state-fixtures.ps1 +50 -0
  27. package/scripts/word-com-package-integrity.ps1 +98 -0
  28. package/services/batch-operation-orchestrator.js +262 -259
  29. package/services/comment-replies.js +96 -95
  30. package/services/comment-thread-parts.js +280 -0
  31. package/services/document-inspection.js +366 -365
  32. package/services/document-operation-applier.js +230 -202
  33. package/services/document-operation-contract.js +17 -2
  34. package/services/error-recovery.js +6 -0
  35. package/services/headers-footers.js +206 -0
  36. package/services/operation-batch-compiler.js +1 -0
  37. package/services/operation-preflight.js +231 -218
  38. package/services/package-parts.js +80 -0
  39. package/services/standalone-docx-plumbing.js +631 -569
  40. package/services/standalone-operation-runner.d.ts +19 -3
@@ -7,10 +7,12 @@
7
7
  */
8
8
 
9
9
  import { getDefaultAuthor } from '../adapters/config.js';
10
+ import { applyThreadResolutionToParts, commentParaId, reconcileCommentSiblingParts } from '../services/comment-thread-parts.js';
11
+ import { collectRevisionIds, countRevisionsInsideFields, discoverHeaderFooterParts, isHeaderFooterPath, renumberCollidingRevisionIds, resolvePartSelector } from '../services/headers-footers.js';
10
12
  import { inspectDocumentParts } from '../services/document-inspection.js';
11
13
  import { applyOperationsToDocumentXml, preflightOperations } from '../services/standalone-operation-runner.js';
12
14
  import { createDynamicNumberingIdState, mergeNumberingXmlBySchemaOrder } from '../services/numbering-helpers.js';
13
- import { ensureCommentsArtifactsInZip, ensureCommentsExtendedArtifactsInZip, ensureNumberingArtifactsInZip, validateDocxPackage } from '../services/standalone-docx-plumbing.js';
15
+ import { ensureCommentsArtifactsInZip, ensureCommentsExtendedArtifactsInZip, ensureNumberingArtifactsInZip, repairKnownContentTypes, validateDocxPackage } from '../services/standalone-docx-plumbing.js';
14
16
  import { validateRedlineOoxml } from '../core/redline-validation.js';
15
17
  import { subtractValidationIssueMultiset, validationErrors } from '../core/validation-delta.js';
16
18
  import { acceptTrackedChangesInOoxml, rejectTrackedChangesInOoxml, deleteCommentsByAuthorInOoxml } from '../services/revision-comment-management.js';
@@ -30,6 +32,17 @@ const text = (entries, path) => {
30
32
  return textDecoder.decode(entry);
31
33
  };
32
34
 
35
+ /** Keeps commentsIds/commentsExtensible in step with comments.xml after comments were added or removed. */
36
+ function reconcileCommentSiblings(working) {
37
+ const updated = reconcileCommentSiblingParts({
38
+ commentsXml: text(working, 'word/comments.xml'),
39
+ commentsIdsXml: text(working, 'word/commentsIds.xml'),
40
+ commentsExtensibleXml: text(working, 'word/commentsExtensible.xml')
41
+ });
42
+ if (updated.commentsIdsXml) working.set('word/commentsIds.xml', textEncoder.encode(updated.commentsIdsXml));
43
+ if (updated.commentsExtensibleXml) working.set('word/commentsExtensible.xml', textEncoder.encode(updated.commentsExtensibleXml));
44
+ }
45
+
33
46
  const cloneEntries = entries => new Map([...entries].map(([name, data]) => [name, new Uint8Array(data)]));
34
47
 
35
48
  function toBufferCompatible(uint8Arr) {
@@ -108,6 +121,9 @@ export function computePackageRevisionToken(input) {
108
121
  });
109
122
  }
110
123
 
124
+ // Word does not support comments in headers or footers, so these are refused when `part` is set.
125
+ const COMMENT_OPERATION_TYPES = new Set(['comment', 'comment_reply', 'comment_resolve']);
126
+
111
127
  function nextCommentId(entries) {
112
128
  const ids = `${text(entries, 'word/document.xml') || ''} ${text(entries, 'word/comments.xml') || ''}`.match(/(?:w:)?id=["'](\d+)["']/g) || [];
113
129
  let next = ids.reduce((max, token) => Math.max(max, Number(token.match(/\d+/)?.[0] || 0)), 0) + 1;
@@ -127,7 +143,7 @@ function existingCommentDetails(entries) {
127
143
  id,
128
144
  author: comment.getAttribute('w:author') || comment.getAttribute('author') || '',
129
145
  text: String(comment.textContent || '').trim(),
130
- paraId: Array.from(comment.getElementsByTagNameNS('*', 'p'))[0]?.getAttribute('w14:paraId') || null
146
+ paraId: commentParaId(comment) || null
131
147
  };
132
148
  }
133
149
  return details;
@@ -165,13 +181,35 @@ export class DocxDocument {
165
181
 
166
182
  inspect(options = {}) {
167
183
  const digestFn = options.digestFn || (bytes => sha256(bytes));
168
- return inspectDocumentParts({
184
+ const inspection = inspectDocumentParts({
169
185
  documentXml: text(this.entries, 'word/document.xml'),
170
186
  commentsXml: text(this.entries, 'word/comments.xml'),
171
187
  commentsExtendedXml: text(this.entries, 'word/commentsExtended.xml'),
172
188
  numberingXml: text(this.entries, 'word/numbering.xml'),
173
189
  stylesXml: text(this.entries, 'word/styles.xml')
174
190
  }, { ...options, digestFn });
191
+ if (inspection.status === 'error') return inspection;
192
+ return { ...inspection, headersFooters: this.inspectHeadersFooters() };
193
+ }
194
+
195
+ /**
196
+ * Header and footer parts the document uses, with their paragraph text, so operations can target them with
197
+ * `part`. `hasFields` marks parts containing PAGE/NUMPAGES-style fields (edit around them, not inside).
198
+ */
199
+ inspectHeadersFooters() {
200
+ return discoverHeaderFooterParts({
201
+ documentXml: text(this.entries, 'word/document.xml'),
202
+ relsXml: text(this.entries, 'word/_rels/document.xml.rels'),
203
+ settingsXml: text(this.entries, 'word/settings.xml')
204
+ }).map(part => {
205
+ const xml = text(this.entries, part.path) || '';
206
+ const partInspection = xml ? inspectDocumentParts({ documentXml: xml }) : { paragraphs: [] };
207
+ return {
208
+ ...part,
209
+ hasFields: /<w:fldChar\b|<w:fldSimple\b/.test(xml),
210
+ paragraphs: (partInspection.paragraphs || []).map(paragraph => ({ index: paragraph.index, text: paragraph.text }))
211
+ };
212
+ });
175
213
  }
176
214
 
177
215
  getRevisionToken() {
@@ -183,12 +221,54 @@ export class DocxDocument {
183
221
  }
184
222
 
185
223
  preflight(operations, author = getDefaultAuthor(), options = {}) {
186
- return preflightOperations(
224
+ const list = Array.isArray(operations) ? operations : [];
225
+ const runBody = ops => preflightOperations(
187
226
  text(this.entries, 'word/document.xml'),
188
- operations,
227
+ ops,
189
228
  author || getDefaultAuthor(),
190
229
  { ...options, _existingCommentDetails: existingCommentDetails(this.entries) }
191
230
  );
231
+ if (!list.some(operation => operation && typeof operation === 'object' && operation.part != null)) return runBody(operations);
232
+
233
+ const parts = this.inspectHeadersFooters();
234
+ const bodyOps = [];
235
+ const results = [];
236
+ const conflicts = [];
237
+ const authorsUsed = new Set();
238
+ let valid = true;
239
+ list.forEach((operation, i) => {
240
+ if (!operation || typeof operation !== 'object' || operation.part == null) {
241
+ bodyOps.push({ operation, origIndex: i });
242
+ return;
243
+ }
244
+ const resolved = resolvePartSelector(parts, operation.part);
245
+ const refusal = COMMENT_OPERATION_TYPES.has(operation.type)
246
+ ? { code: 'COMMENT_IN_HEADER_FOOTER', message: 'Word does not support comments in headers or footers.' }
247
+ : resolved.error;
248
+ if (refusal) {
249
+ valid = false;
250
+ results.push({ index: i + 1, type: operation.type || 'redline', status: 'error', error: normalizeErrorWithRecovery({ ...refusal, operationIndex: i + 1 }) });
251
+ return;
252
+ }
253
+ const { part: _selector, ...rest } = operation;
254
+ const partResult = preflightOperations(text(this.entries, resolved.part.path), [rest], author || getDefaultAuthor(), { ...options, _existingCommentDetails: {} });
255
+ valid = valid && partResult.valid;
256
+ for (const result of partResult.results || []) results.push({ ...result, index: i + 1, part: resolved.part.path });
257
+ conflicts.push(...(partResult.conflicts || []));
258
+ for (const name of partResult.authorsUsed || []) authorsUsed.add(name);
259
+ });
260
+ let requiredArtifacts = { comments: false, numbering: false };
261
+ if (bodyOps.length) {
262
+ const body = runBody(bodyOps.map(item => item.operation));
263
+ valid = valid && body.valid;
264
+ for (const result of body.results || []) results.push({ ...result, index: bodyOps[result.index - 1].origIndex + 1 });
265
+ conflicts.push(...(body.conflicts || []));
266
+ for (const name of body.authorsUsed || []) authorsUsed.add(name);
267
+ requiredArtifacts = body.requiredArtifacts || requiredArtifacts;
268
+ }
269
+ results.sort((a, b) => a.index - b.index);
270
+ const ok = valid && conflicts.length === 0;
271
+ return { valid: ok, status: ok ? 'ok' : 'error', results, conflicts, authorsUsed: [...authorsUsed], requiredArtifacts };
192
272
  }
193
273
 
194
274
  toUint8Array() {
@@ -199,7 +279,230 @@ export class DocxDocument {
199
279
  return toBufferCompatible(this.toUint8Array());
200
280
  }
201
281
 
282
+ /**
283
+ * Applies operations to the document. Operations without `part` target the body (word/document.xml).
284
+ * An operation with `part` targets one header or footer part:
285
+ * `{ part: { kind: 'footer', type: 'default', section: 0 } | 'word/footer2.xml', target: { exactText }, ... }`.
286
+ * Discover parts with `inspect().headersFooters`.
287
+ */
202
288
  async applyOperations(operations, options = {}) {
289
+ const list = Array.isArray(operations) ? operations : [];
290
+ if (!list.some(operation => operation && typeof operation === 'object' && operation.part != null)) {
291
+ return this.applyBodyOperations(operations, options);
292
+ }
293
+ return this.applyOperationsWithParts(list, options);
294
+ }
295
+
296
+ async applyOperationsWithParts(operations, options = {}) {
297
+ const author = options.author || getDefaultAuthor();
298
+ const atomic = options.atomic === true;
299
+ const snapshotEntries = this.entries;
300
+ const snapshotBytes = this.originalBytes;
301
+ const total = operations.length;
302
+ const restore = () => { this.entries = snapshotEntries; this.originalBytes = snapshotBytes; };
303
+ const original = () => ({
304
+ uint8Array: snapshotBytes,
305
+ toUint8Array: () => new Uint8Array(snapshotBytes),
306
+ buffer: toBufferCompatible(snapshotBytes),
307
+ toBuffer: () => toBufferCompatible(snapshotBytes)
308
+ });
309
+ const refuse = (error, results = [], receipts = [], extra = {}) => ({
310
+ status: 'error', hasChanges: false, written: false, rolledBack: true, results, receipts, executionOrder: [], authorsUsed: [],
311
+ artifactsChanged: [],
312
+ error: normalizeErrorWithRecovery(error),
313
+ retryPlan: createRetryPlan({ atomic: true, rolledBack: true, results, receipts, operationCount: total }),
314
+ validation: { originalIssues: [], generatedIssues: [] },
315
+ ...extra,
316
+ ...original()
317
+ });
318
+
319
+ if (options.expectedRevision) {
320
+ const precondition = await this.applyBodyOperations([], { expectedRevision: options.expectedRevision });
321
+ if (precondition.status === 'error') return precondition;
322
+ }
323
+
324
+ // Resolve every selector before touching anything, so a bad selector fails the whole request.
325
+ const parts = discoverHeaderFooterParts({
326
+ documentXml: text(this.entries, 'word/document.xml'),
327
+ relsXml: text(this.entries, 'word/_rels/document.xml.rels'),
328
+ settingsXml: text(this.entries, 'word/settings.xml')
329
+ });
330
+ const bodyOps = [];
331
+ const partOps = [];
332
+ for (let i = 0; i < operations.length; i++) {
333
+ const operation = operations[i];
334
+ if (!operation || typeof operation !== 'object' || operation.part == null) {
335
+ bodyOps.push({ operation, origIndex: i });
336
+ continue;
337
+ }
338
+ if (COMMENT_OPERATION_TYPES.has(operation.type)) {
339
+ return refuse({ code: 'COMMENT_IN_HEADER_FOOTER', message: `Operation ${i + 1}: Word does not support comments in headers or footers.`, operationIndex: i + 1 });
340
+ }
341
+ const resolved = resolvePartSelector(parts, operation.part);
342
+ if (resolved.error) return refuse({ ...resolved.error, message: `Operation ${i + 1}: ${resolved.error.message}`, operationIndex: i + 1 });
343
+ const { part: _selector, ...rest } = operation;
344
+ partOps.push({ operation: rest, origIndex: i, part: resolved.part });
345
+ }
346
+
347
+ const results = [];
348
+ const receipts = [];
349
+ const executionOrder = [];
350
+ const authorsUsed = new Set();
351
+ const originalIssues = [];
352
+ let anyChange = false;
353
+ let anyFailure = false;
354
+ let bodyResult = null;
355
+
356
+ if (bodyOps.length) {
357
+ bodyResult = await this.applyBodyOperations(bodyOps.map(item => item.operation), { ...options, expectedRevision: undefined });
358
+ const toOriginal = index => bodyOps[index - 1]?.origIndex + 1;
359
+ for (const result of bodyResult.results || []) {
360
+ results.push({
361
+ ...result,
362
+ index: toOriginal(result.index),
363
+ ...(result.receipt ? { receipt: { ...result.receipt, operationIndex: toOriginal(result.receipt.operationIndex) } } : {})
364
+ });
365
+ }
366
+ for (const receipt of bodyResult.receipts || []) receipts.push({ ...receipt, operationIndex: toOriginal(receipt.operationIndex) });
367
+ for (const index of bodyResult.executionOrder || []) executionOrder.push(toOriginal(index));
368
+ for (const name of bodyResult.authorsUsed || []) authorsUsed.add(name);
369
+ originalIssues.push(...(bodyResult.validation?.originalIssues || []));
370
+ if (bodyResult.status === 'error' || bodyResult.rolledBack) {
371
+ anyFailure = true;
372
+ if (atomic) {
373
+ restore();
374
+ return { ...bodyResult, results, receipts, executionOrder, written: false, artifactsChanged: [], rolledBack: true, ...original() };
375
+ }
376
+ }
377
+ anyChange = bodyResult.hasChanges === true;
378
+ }
379
+
380
+ const working = cloneEntries(this.entries);
381
+ const zip = new MemoryZip(working);
382
+ const changedParts = new Set();
383
+ const { expectedRevision: _ignored, ...runnerOptions } = options;
384
+ const failPart = (item, error) => {
385
+ anyFailure = true;
386
+ const failure = normalizeErrorWithRecovery({ ...error, operationIndex: item.origIndex + 1 });
387
+ results.push({ index: item.origIndex + 1, type: item.operation.type || 'redline', status: 'error', authorUsed: author, part: item.part.path, error: failure });
388
+ };
389
+
390
+ for (const item of partOps) {
391
+ const path = item.part.path;
392
+ const xml = text(working, path);
393
+ if (!xml) {
394
+ failPart(item, { code: 'PART_NOT_FOUND', message: `Part '${path}' is referenced but missing from the package.` });
395
+ if (atomic) break;
396
+ continue;
397
+ }
398
+ const numberingText = text(working, 'word/numbering.xml');
399
+ const outcome = await applyOperationsToDocumentXml(xml, [item.operation], author, { numberingIdState: createDynamicNumberingIdState(numberingText || undefined) }, {
400
+ ...runnerOptions, atomic: true, strictTargets: options.strictTargets !== false, _existingCommentDetails: {}
401
+ });
402
+ const single = outcome.results?.[0];
403
+ if (outcome.rolledBack || outcome.status === 'error') {
404
+ failPart(item, single?.error || outcome.error || { code: 'OPERATION_FAILED', message: 'Operation failed.' });
405
+ if (atomic) break;
406
+ continue;
407
+ }
408
+ if (!outcome.hasChanges) {
409
+ results.push({ ...single, index: item.origIndex + 1, part: path });
410
+ continue;
411
+ }
412
+ if (countRevisionsInsideFields(outcome.documentXml) > countRevisionsInsideFields(xml)) {
413
+ failPart(item, { code: 'FIELD_EDIT_REFUSED', message: `The edit would change a field (for example PAGE or NUMPAGES) in ${path}. Word recomputes fields; edit the text around them instead.` });
414
+ if (atomic) break;
415
+ continue;
416
+ }
417
+ const taken = new Set();
418
+ for (const [name, data] of working) {
419
+ if (name === path || !(name === 'word/document.xml' || isHeaderFooterPath(name))) continue;
420
+ for (const id of collectRevisionIds(textDecoder.decode(data))) taken.add(id);
421
+ }
422
+ const renumbered = renumberCollidingRevisionIds(outcome.documentXml, taken);
423
+ working.set(path, textEncoder.encode(renumbered.xml));
424
+ // Receipts must describe what is in the file: the part it lives in and its final revision ids.
425
+ const describeReceipt = receipt => (receipt?.revisionItems
426
+ ? { ...receipt, revisionItems: receipt.revisionItems.map(revision => ({ ...revision, id: renumbered.idMap.get(String(revision.id)) ?? revision.id, partName: path })) }
427
+ : receipt);
428
+ if (outcome.numberingXmlParts?.length) {
429
+ await ensureNumberingArtifactsInZip(zip, outcome.numberingXmlParts, { mergeNumberingXml: mergeNumberingXmlBySchemaOrder });
430
+ }
431
+ changedParts.add(path);
432
+ anyChange = true;
433
+ results.push({
434
+ ...single,
435
+ index: item.origIndex + 1,
436
+ part: path,
437
+ partSections: item.part.appliesToSections,
438
+ ...(single?.receipt ? { receipt: { ...describeReceipt(single.receipt), operationIndex: item.origIndex + 1 } } : {})
439
+ });
440
+ if (outcome.receipts?.[0]) receipts.push({ ...describeReceipt(outcome.receipts[0]), operationIndex: item.origIndex + 1 });
441
+ executionOrder.push(item.origIndex + 1);
442
+ for (const name of outcome.authorsUsed || []) authorsUsed.add(name);
443
+ }
444
+
445
+ // Each changed part must not introduce revision-markup problems, and the package must still validate.
446
+ const generatedIssues = [];
447
+ if (!(atomic && anyFailure)) {
448
+ for (const path of changedParts) {
449
+ const before = validateRedlineOoxml(text(this.entries, path)).issues.map(issue => ({ source: path, ...issue }));
450
+ const after = validateRedlineOoxml(text(working, path)).issues.map(issue => ({ source: path, ...issue }));
451
+ generatedIssues.push(...validationErrors(subtractValidationIssueMultiset(after, before)));
452
+ }
453
+ if (options.validate !== false && changedParts.size) {
454
+ try {
455
+ await validateDocxPackage(zip);
456
+ } catch (error) {
457
+ generatedIssues.push({ source: 'package', code: 'PACKAGE_VALIDATION', severity: 'error', message: error.message });
458
+ }
459
+ }
460
+ }
461
+ if ((atomic && anyFailure) || generatedIssues.length) {
462
+ restore();
463
+ const error = generatedIssues.length
464
+ ? { code: 'PACKAGE_OPERATION_FAILED', message: `Applied operations introduced invalid markup (${[...new Set(generatedIssues.map(issue => issue.code))].join(', ')}).`, issues: generatedIssues }
465
+ : { code: 'BATCH_OPERATION_FAILED', message: 'Atomic batch rolled back because one or more operations failed.' };
466
+ const rolledBackResults = results
467
+ .map(result => (result.receipt ? { ...result, receipt: { ...result.receipt, committed: false, finalDisposition: 'rolled_back' } } : result))
468
+ .sort((a, b) => a.index - b.index);
469
+ const rolledBackReceipts = receipts.map(receipt => ({ ...receipt, committed: false, finalDisposition: 'rolled_back' }));
470
+ return refuse(error, rolledBackResults, rolledBackReceipts, { validation: { originalIssues, generatedIssues }, issues: generatedIssues });
471
+ }
472
+
473
+ results.sort((a, b) => a.index - b.index);
474
+ receipts.sort((a, b) => a.operationIndex - b.operationIndex);
475
+ const status = !anyFailure ? 'ok' : (anyChange ? 'partial' : 'error');
476
+ const failureError = anyFailure
477
+ ? { error: normalizeErrorWithRecovery({ code: 'BATCH_OPERATION_FAILED', message: 'One or more operations failed; the others were applied.' }) }
478
+ : {};
479
+ if (!changedParts.size) {
480
+ // No part changed; the body call (if any) already committed its own result.
481
+ const buf = toBufferCompatible(this.originalBytes);
482
+ return {
483
+ ...(bodyResult || {}), results, receipts, executionOrder, authorsUsed: [...authorsUsed], status, hasChanges: anyChange,
484
+ written: bodyResult?.written === true, artifactsChanged: bodyResult?.artifactsChanged || [],
485
+ validation: { originalIssues, generatedIssues: [] }, ...failureError,
486
+ uint8Array: this.originalBytes, toUint8Array: () => new Uint8Array(this.originalBytes), buffer: buf, toBuffer: () => toBufferCompatible(this.originalBytes)
487
+ };
488
+ }
489
+
490
+ this.entries = working;
491
+ const outputBytes = this.toUint8Array();
492
+ this.originalBytes = outputBytes;
493
+ const artifactsChanged = [...working]
494
+ .filter(([name, data]) => !snapshotEntries.has(name) || !areByteArraysEqual(data, snapshotEntries.get(name)))
495
+ .map(([name]) => name);
496
+ return {
497
+ ...(bodyResult || {}),
498
+ status, hasChanges: true, written: true, results, receipts, executionOrder, authorsUsed: [...authorsUsed], artifactsChanged,
499
+ validation: { originalIssues, generatedIssues: [] }, ...failureError,
500
+ uint8Array: outputBytes, toUint8Array: () => new Uint8Array(outputBytes), buffer: toBufferCompatible(outputBytes),
501
+ inspection: this.inspect(), toBuffer: () => toBufferCompatible(outputBytes)
502
+ };
503
+ }
504
+
505
+ async applyBodyOperations(operations, options = {}) {
203
506
  const failedApply = error => {
204
507
  const results = [];
205
508
  const receipts = [];
@@ -317,7 +620,9 @@ export class DocxDocument {
317
620
  };
318
621
  }
319
622
 
320
- working.set('word/document.xml', textEncoder.encode(result.documentXml));
623
+ // Resolving a thread only changes the comment parts; keep document.xml byte-identical.
624
+ const bodyChanged = (result.results || []).some(item => item?.status !== 'error' && item?.operationType !== 'comment_resolve');
625
+ if (bodyChanged) working.set('word/document.xml', textEncoder.encode(result.documentXml));
321
626
  await ensureNumberingArtifactsInZip(zip, result.numberingXmlParts, { mergeNumberingXml: mergeNumberingXmlBySchemaOrder });
322
627
 
323
628
  const existingCommentsXml = text(working, 'word/comments.xml');
@@ -326,11 +631,16 @@ export class DocxDocument {
326
631
  replaceExisting: result.commentsXmlMode === 'replace' || (!result.commentsXml && !!existingCommentsXml)
327
632
  });
328
633
 
329
- const existingCommentsExtendedXml = text(working, 'word/commentsExtended.xml');
330
- const commentsExtendedXmlForPackaging = result.commentsExtendedXml || existingCommentsExtendedXml;
331
- await ensureCommentsExtendedArtifactsInZip(zip, commentsExtendedXmlForPackaging, {
332
- replaceExisting: result.commentsExtendedXmlMode === 'replace' || (!result.commentsExtendedXml && !!existingCommentsExtendedXml)
333
- });
634
+ // Only touch the extended part (and its content type/rel) when an operation changed it.
635
+ // Rewriting it on every save is what stamped a bad content type onto every
636
+ // already-commented document.
637
+ if (result.commentsExtendedXml) {
638
+ await ensureCommentsExtendedArtifactsInZip(zip, result.commentsExtendedXml, {
639
+ replaceExisting: result.commentsExtendedXmlMode === 'replace'
640
+ });
641
+ }
642
+ await repairKnownContentTypes(zip);
643
+ reconcileCommentSiblings(working);
334
644
 
335
645
  if (options.validate !== false) {
336
646
  const generated = validateRedlineOoxml(result.documentXml);
@@ -407,6 +717,7 @@ export class DocxDocument {
407
717
  if (!transform) return packageFailure(this.originalBytes, 'INVALID_ACTION', `Unknown revision action: ${action}`);
408
718
 
409
719
  const sourceBytes = this.originalBytes;
720
+ const originalEntries = this.entries;
410
721
  const working = cloneEntries(this.entries);
411
722
  const zip = new MemoryZip(working);
412
723
  const result = transform(text(working, 'word/document.xml'), { author: options.author, allAuthors: options.allAuthors === true });
@@ -415,7 +726,20 @@ export class DocxDocument {
415
726
  return packageFailure(sourceBytes, result.error?.code || 'REVISION_OPERATION_FAILED', result.error?.message || 'Revision operation failed.');
416
727
  }
417
728
 
418
- if (!result.hasChanges) {
729
+ // Headers and footers carry their own tracked changes; resolve them with the same filter.
730
+ const partUpdates = new Map();
731
+ for (const part of this.inspectHeadersFooters()) {
732
+ const partXml = text(working, part.path);
733
+ if (!partXml) continue;
734
+ const partResult = transform(partXml, { author: options.author, allAuthors: options.allAuthors === true });
735
+ if (partResult.status === 'error' || partResult.error) {
736
+ return packageFailure(sourceBytes, partResult.error?.code || 'REVISION_OPERATION_FAILED', `${part.path}: ${partResult.error?.message || 'Revision operation failed.'}`);
737
+ }
738
+ if (partResult.hasChanges) partUpdates.set(part.path, partResult.oxml);
739
+ }
740
+ const hasChanges = result.hasChanges || partUpdates.size > 0;
741
+
742
+ if (!hasChanges) {
419
743
  const buf = toBufferCompatible(sourceBytes);
420
744
  return {
421
745
  ...result,
@@ -429,8 +753,10 @@ export class DocxDocument {
429
753
  };
430
754
  }
431
755
 
432
- working.set('word/document.xml', textEncoder.encode(result.oxml));
756
+ if (result.hasChanges) working.set('word/document.xml', textEncoder.encode(result.oxml));
757
+ for (const [path, xml] of partUpdates) working.set(path, textEncoder.encode(xml));
433
758
  try {
759
+ await repairKnownContentTypes(zip);
434
760
  if (options.validate !== false) await validateDocxPackage(zip);
435
761
  } catch (error) {
436
762
  return packageFailure(sourceBytes, 'PACKAGE_VALIDATION', error.message);
@@ -445,7 +771,10 @@ export class DocxDocument {
445
771
  ...result,
446
772
  status: 'ok',
447
773
  written: true,
448
- artifactsChanged: ['word/document.xml'],
774
+ hasChanges: true,
775
+ artifactsChanged: [...working]
776
+ .filter(([name, data]) => !originalEntries.has(name) || !areByteArraysEqual(data, originalEntries.get(name)))
777
+ .map(([name]) => name),
449
778
  uint8Array: outputBytes,
450
779
  toUint8Array: () => new Uint8Array(outputBytes),
451
780
  buffer: outputBuf,
@@ -453,6 +782,55 @@ export class DocxDocument {
453
782
  };
454
783
  }
455
784
 
785
+ /**
786
+ * Resolves (or reopens) the comment thread containing `commentId`.
787
+ * Resolved is a thread-level state in Word, so the root and every reply are updated together.
788
+ */
789
+ async resolveComment(commentId, options = {}) {
790
+ const sourceBytes = this.originalBytes;
791
+ const working = cloneEntries(this.entries);
792
+ const resolved = options.resolved !== false;
793
+ const noChange = extra => {
794
+ const buf = toBufferCompatible(sourceBytes);
795
+ return {
796
+ status: 'ok', hasChanges: false, written: false, artifactsChanged: [], ...extra,
797
+ uint8Array: sourceBytes, toUint8Array: () => new Uint8Array(sourceBytes), buffer: buf, toBuffer: () => toBufferCompatible(sourceBytes)
798
+ };
799
+ };
800
+
801
+ const result = applyThreadResolutionToParts({
802
+ commentsXml: text(working, 'word/comments.xml'),
803
+ commentsExtendedXml: text(working, 'word/commentsExtended.xml'),
804
+ commentId,
805
+ resolved
806
+ });
807
+ if (result.status === 'error') return packageFailure(sourceBytes, result.error.code, result.error.message);
808
+ if (!result.hasChanges) return noChange({ resolved, threadRootId: result.threadRootId, commentIds: result.commentIds });
809
+
810
+ const zip = new MemoryZip(working);
811
+ try {
812
+ if (result.commentsXml) working.set('word/comments.xml', textEncoder.encode(result.commentsXml));
813
+ await ensureCommentsExtendedArtifactsInZip(zip, result.commentsExtendedXml, { replaceExisting: true });
814
+ await repairKnownContentTypes(zip);
815
+ if (options.validate !== false) await validateDocxPackage(zip);
816
+ } catch (error) {
817
+ return packageFailure(sourceBytes, 'PACKAGE_VALIDATION', error.message);
818
+ }
819
+
820
+ const artifactsChanged = [...working]
821
+ .filter(([name, data]) => !this.entries.has(name) || !areByteArraysEqual(data, this.entries.get(name)))
822
+ .map(([name]) => name);
823
+ this.entries = working;
824
+ const outputBytes = this.toUint8Array();
825
+ this.originalBytes = outputBytes;
826
+ return {
827
+ status: 'ok', hasChanges: true, written: true, resolved,
828
+ threadRootId: result.threadRootId, commentIds: result.commentIds, artifactsChanged,
829
+ uint8Array: outputBytes, toUint8Array: () => new Uint8Array(outputBytes),
830
+ buffer: toBufferCompatible(outputBytes), toBuffer: () => toBufferCompatible(outputBytes)
831
+ };
832
+ }
833
+
456
834
  async deleteComments(options = {}) {
457
835
  const sourceBytes = this.originalBytes;
458
836
  const working = cloneEntries(this.entries);
@@ -479,7 +857,16 @@ export class DocxDocument {
479
857
  return packageFailure(sourceBytes, 'PARSE_ERROR', parsed.error?.message || 'Could not parse comments.xml.');
480
858
  }
481
859
 
482
- const matches = comment => options.allAuthors === true || (comment.getAttribute('w:author') || comment.getAttribute('author')) === options.author;
860
+ // `ids` deletes specific comments (a thread root also takes its replies); otherwise filter by author.
861
+ const requestedIds = Array.isArray(options.ids) ? new Set(options.ids.map(String)) : null;
862
+ if (requestedIds) {
863
+ const known = new Set(Array.from(parsed.doc.getElementsByTagNameNS('*', 'comment')).map(node => node.getAttribute('w:id') || node.getAttribute('id')));
864
+ const missing = [...requestedIds].filter(id => !known.has(id));
865
+ if (missing.length) return packageFailure(sourceBytes, 'COMMENT_NOT_FOUND', `Comment id(s) not found: ${missing.join(', ')}.`);
866
+ }
867
+ const matches = comment => (requestedIds
868
+ ? requestedIds.has(comment.getAttribute('w:id') || comment.getAttribute('id'))
869
+ : options.allAuthors === true || (comment.getAttribute('w:author') || comment.getAttribute('author')) === options.author);
483
870
  const ids = new Set(
484
871
  Array.from(parsed.doc.getElementsByTagNameNS('*', 'comment'))
485
872
  .filter(matches)
@@ -514,9 +901,8 @@ export class DocxDocument {
514
901
  const idByParaId = new Map();
515
902
  for (const comment of Array.from(parsed.doc.getElementsByTagNameNS('*', 'comment'))) {
516
903
  const id = comment.getAttribute('w:id') || comment.getAttribute('id');
517
- const paragraph = Array.from(comment.getElementsByTagNameNS('*', 'p'))[0];
518
- const paraId = paragraph?.getAttribute('w14:paraId') || paragraph?.getAttribute('paraId');
519
- if (paraId) idByParaId.set(paraId.toUpperCase(), id);
904
+ const paraId = commentParaId(comment);
905
+ if (paraId) idByParaId.set(paraId, id);
520
906
  }
521
907
  for (const [paraId, id] of idByParaId) {
522
908
  if (ids.has(id)) removedParaIds.add(paraId);
@@ -536,8 +922,10 @@ export class DocxDocument {
536
922
  }
537
923
  }
538
924
 
539
- const commentsResult = deleteCommentsByAuthorInOoxml(commentsXml, { author: options.author, allAuthors: options.allAuthors === true });
540
- const remainingParsed = parseOoxmlSafe(commentsResult.oxml, 'application/xml');
925
+ const remainingXml = requestedIds
926
+ ? commentsXml
927
+ : deleteCommentsByAuthorInOoxml(commentsXml, { author: options.author, allAuthors: options.allAuthors === true }).oxml;
928
+ const remainingParsed = parseOoxmlSafe(remainingXml, 'application/xml');
541
929
  if (!remainingParsed.doc || remainingParsed.error) {
542
930
  return packageFailure(sourceBytes, 'PARSE_ERROR', remainingParsed.error?.message || 'Could not parse updated comments.xml.');
543
931
  }
@@ -580,11 +968,17 @@ export class DocxDocument {
580
968
  working.set('word/document.xml', textEncoder.encode(serializer.serializeToString(documentParsed.doc)));
581
969
 
582
970
  try {
583
- if (options.validate !== false) await validateDocxPackage(new MemoryZip(working));
971
+ reconcileCommentSiblings(working);
972
+ const finalZip = new MemoryZip(working);
973
+ await repairKnownContentTypes(finalZip);
974
+ if (options.validate !== false) await validateDocxPackage(finalZip);
584
975
  } catch (error) {
585
976
  return packageFailure(sourceBytes, 'PACKAGE_VALIDATION', error.message);
586
977
  }
587
978
 
979
+ const artifactsChanged = [...working]
980
+ .filter(([name, data]) => !this.entries.has(name) || !areByteArraysEqual(data, this.entries.get(name)))
981
+ .map(([name]) => name);
588
982
  this.entries = working;
589
983
  const outputBytes = this.toUint8Array();
590
984
  this.originalBytes = outputBytes;
@@ -596,7 +990,7 @@ export class DocxDocument {
596
990
  written: true,
597
991
  commentsRemoved: ids.size,
598
992
  referencesRemoved,
599
- artifactsChanged: ['word/document.xml', 'word/comments.xml', ...(extendedParsed?.doc ? ['word/commentsExtended.xml'] : [])],
993
+ artifactsChanged,
600
994
  uint8Array: outputBytes,
601
995
  toUint8Array: () => new Uint8Array(outputBytes),
602
996
  buffer: outputBuf,
@@ -164,6 +164,24 @@ export function applyReconstructionDiffs(xmlDoc, diffs, context, serializer, aut
164
164
  }
165
165
  }
166
166
 
167
+ // Comment markers are emitted when a diff part starts at their offset. Markers at the very end of the
168
+ // paragraph text (commentRangeEnd and the reference run of a comment on the last words) are only reached
169
+ // when the final diff part is an insert; after an unchanged or deleted tail nothing starts there, so they
170
+ // were dropped and the comment lost its end anchor. Flush whatever is left, in original order.
171
+ for (const offset of Array.from(sentinelMapByStart.keys()).sort((a, b) => a - b)) {
172
+ for (const sentinel of sentinelMapByStart.get(offset)) {
173
+ if (!sentinel.isCommentMarker || emittedCommentMarkers.has(sentinel.node)) continue;
174
+ emittedCommentMarkers.add(sentinel.node);
175
+ if (isWordElement(sentinel.node, 'commentReference')) {
176
+ const run = createWordElement(xmlDoc, 'w:r');
177
+ run.appendChild(sentinel.node.cloneNode(true));
178
+ currentParagraph.appendChild(run);
179
+ } else {
180
+ currentParagraph.appendChild(sentinel.node.cloneNode(true));
181
+ }
182
+ }
183
+ }
184
+
167
185
  if (generateRedlines) {
168
186
  containerFragments.forEach(fragment => {
169
187
  Array.from(fragment.childNodes).forEach(node => {