@ansonlai/docx-redline-js 0.8.4 → 0.9.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 (68) hide show
  1. package/ARCHITECTURE.md +221 -12
  2. package/CHANGELOG.md +176 -0
  3. package/README.md +125 -12
  4. package/core/current-properties.js +136 -0
  5. package/core/paragraph-text.js +211 -209
  6. package/core/types.js +9 -3
  7. package/core/word-revision-semantics.js +256 -0
  8. package/dist/docx-redline-js.esm.js +4189 -767
  9. package/dist/docx-redline-js.esm.js.map +4 -4
  10. package/dist/docx-redline-js.esm.min.js +111 -108
  11. package/dist/docx-redline-js.esm.min.js.map +4 -4
  12. package/dist/docx-redline.bundle.cjs +4292 -870
  13. package/dist/docx-redline.bundle.cjs.map +4 -4
  14. package/dist/docx-redline.bundle.js +4292 -870
  15. package/dist/docx-redline.bundle.js.map +4 -4
  16. package/docs/AGENT_FAST_START.md +2 -0
  17. package/docs/AGENT_KNOWLEDGE_BASE.md +19 -3
  18. package/docs/SKILL_AUTHORING.md +165 -150
  19. package/docs/TESTING.md +1142 -1059
  20. package/docs/schemas/document-operations.schema.json +54 -1
  21. package/document/docx-document.js +1022 -1010
  22. package/engine/oxml-engine.js +736 -749
  23. package/engine/paragraph-mark-revisions.js +292 -0
  24. package/engine/reconstruction-mapper.js +312 -311
  25. package/engine/reconstruction-writer.js +67 -10
  26. package/engine/rpr-helpers.js +42 -0
  27. package/engine/run-builders.js +0 -73
  28. package/engine/surgical-diff-application.js +604 -605
  29. package/engine/table-mode.js +63 -63
  30. package/index.d.ts +47 -1
  31. package/index.js +11 -2
  32. package/node/cli-help.js +1 -0
  33. package/node/cli.js +992 -975
  34. package/orchestration/list-structural-fallback.js +67 -9
  35. package/package.json +1 -1
  36. package/pipeline/diff-engine.js +130 -1
  37. package/pipeline/ingestion-export.js +227 -243
  38. package/pipeline/ingestion-paragraph.js +93 -98
  39. package/pipeline/list-generation.js +415 -419
  40. package/pipeline/pipeline.js +206 -204
  41. package/scripts/export-validation-fixtures.mjs +19 -6
  42. package/scripts/generate-word-revision-semantics-matrix.mjs +134 -0
  43. package/scripts/lib/word-coverage-catalogue.mjs +7 -2
  44. package/scripts/render-multilevel-bullet-visual.ps1 +2 -2
  45. package/scripts/word-com-corpus-visual-suite.ps1 +2 -2
  46. package/scripts/word-com-differential.ps1 +443 -335
  47. package/scripts/word-com-visual-suite.ps1 +2 -2
  48. package/scripts/word-revision-semantics-matrix.ps1 +108 -0
  49. package/services/batch-operation-orchestrator.js +23 -3
  50. package/services/capture-engine.js +228 -226
  51. package/services/document-operation-applier.js +213 -0
  52. package/services/document-operation-contract.js +75 -0
  53. package/services/document-operation-mutations.js +3346 -2721
  54. package/services/error-recovery.js +17 -0
  55. package/services/list-conversion-diagnostics.js +296 -0
  56. package/services/list-format-change.js +586 -0
  57. package/services/list-format-mutation.js +416 -0
  58. package/services/list-format.js +305 -0
  59. package/services/numbering-allocation.js +642 -0
  60. package/services/numbering-helpers.js +354 -524
  61. package/services/operation-batch-compiler.js +183 -11
  62. package/services/operation-preflight.js +1 -0
  63. package/services/receipt-collector.js +288 -288
  64. package/services/revision-comment-management.js +38 -5
  65. package/services/revision-fidelity-verifier.js +498 -0
  66. package/services/standalone-docx-plumbing.js +627 -631
  67. package/services/standalone-operation-runner.d.ts +182 -5
  68. package/services/table-reconciliation.js +59 -7
@@ -1,1010 +1,1022 @@
1
- /**
2
- * Universal DOCX document facade and package-level operation runner.
3
- *
4
- * Implements complete-document manipulation using cross-runtime standards
5
- * (Uint8Array, TextEncoder/Decoder) and the universal ZIP archive layer.
6
- * Runs in Node.js, browsers, Cloudflare Workers, Deno, and sandboxed runtimes.
7
- */
8
-
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';
12
- import { inspectDocumentParts } from '../services/document-inspection.js';
13
- import { applyOperationsToDocumentXml, preflightOperations } from '../services/standalone-operation-runner.js';
14
- import { createDynamicNumberingIdState, mergeNumberingXmlBySchemaOrder } from '../services/numbering-helpers.js';
15
- import { ensureCommentsArtifactsInZip, ensureCommentsExtendedArtifactsInZip, ensureNumberingArtifactsInZip, repairKnownContentTypes, validateDocxPackage } from '../services/standalone-docx-plumbing.js';
16
- import { validateRedlineOoxml } from '../core/redline-validation.js';
17
- import { subtractValidationIssueMultiset, validationErrors } from '../core/validation-delta.js';
18
- import { acceptTrackedChangesInOoxml, rejectTrackedChangesInOoxml, deleteCommentsByAuthorInOoxml } from '../services/revision-comment-management.js';
19
- import { createSerializer, parseOoxmlSafe } from '../adapters/xml-adapter.js';
20
- import { sha256 } from '../core/sha256.js';
21
- import { MemoryZip, unzipDocx, zipDocx, toUint8Array } from './zip-archive.js';
22
- import { computeRevisionTokenSync, validateRevisionToken, areRevisionTokensEqual } from '../services/revision-token.js';
23
- import { createRetryPlan, normalizeErrorWithRecovery } from '../services/error-recovery.js';
24
-
25
- const textEncoder = new TextEncoder();
26
- const textDecoder = new TextDecoder('utf-8');
27
-
28
- const text = (entries, path) => {
29
- const entry = entries.get(path);
30
- if (!entry) return null;
31
- if (typeof entry === 'string') return entry;
32
- return textDecoder.decode(entry);
33
- };
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
-
46
- const cloneEntries = entries => new Map([...entries].map(([name, data]) => [name, new Uint8Array(data)]));
47
-
48
- function toBufferCompatible(uint8Arr) {
49
- if (typeof Buffer !== 'undefined' && typeof Buffer.from === 'function') {
50
- return Buffer.from(uint8Arr.buffer, uint8Arr.byteOffset, uint8Arr.byteLength);
51
- }
52
- return uint8Arr;
53
- }
54
-
55
- function areByteArraysEqual(a, b) {
56
- if (a === b) return true;
57
- if (!a || !b) return false;
58
- if (a.length !== b.length) return false;
59
- for (let i = 0; i < a.length; i++) {
60
- if (a[i] !== b[i]) return false;
61
- }
62
- return true;
63
- }
64
-
65
- function rolledBackOperationPayload(operationResult, operationCount) {
66
- const rollbackReceipt = receipt => {
67
- if (!receipt || typeof receipt !== 'object') return receipt;
68
- if (receipt.attemptedDisposition !== 'applied' && receipt.committed !== true) return { ...receipt };
69
- return { ...receipt, committed: false, finalDisposition: 'rolled_back' };
70
- };
71
- const receipts = (operationResult?.receipts || []).map(rollbackReceipt);
72
- const receiptByIndex = new Map(receipts.map(receipt => [receipt.operationIndex, receipt]));
73
- const results = (operationResult?.results || []).map(result => ({
74
- ...result,
75
- ...(result.receipt ? {
76
- receipt: receiptByIndex.get(result.index) || rollbackReceipt(result.receipt)
77
- } : {})
78
- }));
79
- return {
80
- results,
81
- receipts,
82
- executionOrder: operationResult?.executionOrder || [],
83
- authorsUsed: [],
84
- rolledBack: true,
85
- retryPlan: createRetryPlan({
86
- atomic: true,
87
- rolledBack: true,
88
- results,
89
- receipts,
90
- operationCount
91
- })
92
- };
93
- }
94
-
95
- /**
96
- * Computes a package-scoped revision token over all uncompressed entries in a DOCX archive.
97
- *
98
- * @param {Uint8Array|ArrayBuffer|Map<string, Uint8Array>|DocxDocument|object} input
99
- * @returns {{ algorithm: 'sha256', version: number, scope: 'package', value: string, coveredParts: string[] }}
100
- */
101
- export function computePackageRevisionToken(input) {
102
- let entries;
103
- if (input instanceof Uint8Array || (typeof Buffer !== 'undefined' && Buffer.isBuffer?.(input))) {
104
- entries = unzipDocx(input);
105
- } else if (input instanceof Map) {
106
- entries = input;
107
- } else if (input?.entries instanceof Map) {
108
- entries = input.entries;
109
- } else if (typeof input?.toUint8Array === 'function') {
110
- entries = unzipDocx(input.toUint8Array());
111
- } else if (typeof input?.toBuffer === 'function') {
112
- entries = unzipDocx(input.toBuffer());
113
- } else if (input instanceof ArrayBuffer || ArrayBuffer.isView(input)) {
114
- entries = unzipDocx(toUint8Array(input));
115
- } else {
116
- throw new TypeError('computePackageRevisionToken requires a Uint8Array, Buffer, Map of entries, or DocxDocument.');
117
- }
118
- return computeRevisionTokenSync({
119
- scope: 'package',
120
- entries
121
- });
122
- }
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
-
127
- function nextCommentId(entries) {
128
- const ids = `${text(entries, 'word/document.xml') || ''} ${text(entries, 'word/comments.xml') || ''}`.match(/(?:w:)?id=["'](\d+)["']/g) || [];
129
- let next = ids.reduce((max, token) => Math.max(max, Number(token.match(/\d+/)?.[0] || 0)), 0) + 1;
130
- return () => next++;
131
- }
132
-
133
- function existingCommentDetails(entries) {
134
- const commentsXml = text(entries, 'word/comments.xml');
135
- if (!commentsXml) return {};
136
- const parsed = parseOoxmlSafe(commentsXml, 'application/xml');
137
- if (!parsed.doc || parsed.error) return {};
138
- const details = {};
139
- for (const comment of Array.from(parsed.doc.getElementsByTagNameNS('*', 'comment'))) {
140
- const id = comment.getAttribute('w:id') || comment.getAttribute('id');
141
- if (id === '') continue;
142
- details[id] = {
143
- id,
144
- author: comment.getAttribute('w:author') || comment.getAttribute('author') || '',
145
- text: String(comment.textContent || '').trim(),
146
- paraId: commentParaId(comment) || null
147
- };
148
- }
149
- return details;
150
- }
151
-
152
- function packageFailure(sourceBytes, code, message) {
153
- const buf = toBufferCompatible(sourceBytes);
154
- return {
155
- status: 'error',
156
- hasChanges: false,
157
- written: false,
158
- rolledBack: true,
159
- error: normalizeErrorWithRecovery({ code, message }),
160
- retryPlan: createRetryPlan({ atomic: true, rolledBack: true }),
161
- artifactsChanged: [],
162
- uint8Array: sourceBytes,
163
- toUint8Array: () => new Uint8Array(sourceBytes),
164
- buffer: buf,
165
- toBuffer: () => toBufferCompatible(sourceBytes)
166
- };
167
- }
168
-
169
- /**
170
- * Universal DOCX document representation.
171
- */
172
- export class DocxDocument {
173
- constructor(input) {
174
- this.originalBytes = toUint8Array(input);
175
- this.entries = unzipDocx(this.originalBytes);
176
- }
177
-
178
- get originalBuffer() {
179
- return toBufferCompatible(this.originalBytes);
180
- }
181
-
182
- inspect(options = {}) {
183
- const digestFn = options.digestFn || (bytes => sha256(bytes));
184
- const inspection = inspectDocumentParts({
185
- documentXml: text(this.entries, 'word/document.xml'),
186
- commentsXml: text(this.entries, 'word/comments.xml'),
187
- commentsExtendedXml: text(this.entries, 'word/commentsExtended.xml'),
188
- numberingXml: text(this.entries, 'word/numbering.xml'),
189
- stylesXml: text(this.entries, 'word/styles.xml')
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
- });
213
- }
214
-
215
- getRevisionToken() {
216
- return computePackageRevisionToken(this.entries);
217
- }
218
-
219
- get revisionToken() {
220
- return this.getRevisionToken();
221
- }
222
-
223
- preflight(operations, author = getDefaultAuthor(), options = {}) {
224
- const list = Array.isArray(operations) ? operations : [];
225
- const runBody = ops => preflightOperations(
226
- text(this.entries, 'word/document.xml'),
227
- ops,
228
- author || getDefaultAuthor(),
229
- { ...options, _existingCommentDetails: existingCommentDetails(this.entries) }
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 };
272
- }
273
-
274
- toUint8Array() {
275
- return zipDocx(this.entries);
276
- }
277
-
278
- toBuffer() {
279
- return toBufferCompatible(this.toUint8Array());
280
- }
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
- */
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 = {}) {
506
- const failedApply = error => {
507
- const results = [];
508
- const receipts = [];
509
- const buf = toBufferCompatible(this.originalBytes);
510
- return {
511
- status: 'error',
512
- hasChanges: false,
513
- written: false,
514
- rolledBack: true,
515
- results,
516
- receipts,
517
- artifactsChanged: [],
518
- error: normalizeErrorWithRecovery(error),
519
- retryPlan: createRetryPlan({
520
- atomic: true,
521
- rolledBack: true,
522
- results,
523
- receipts,
524
- operationCount: Array.isArray(operations) ? operations.length : 0
525
- }),
526
- validation: { originalIssues: [], generatedIssues: [] },
527
- uint8Array: this.originalBytes,
528
- toUint8Array: () => new Uint8Array(this.originalBytes),
529
- buffer: buf,
530
- toBuffer: () => toBufferCompatible(this.originalBytes)
531
- };
532
- };
533
-
534
- if (options?.expectedRevision) {
535
- const tokenValidation = validateRevisionToken(options.expectedRevision);
536
- if (!tokenValidation.valid) {
537
- return failedApply({
538
- code: tokenValidation.error?.code || 'INVALID_REVISION_TOKEN',
539
- message: tokenValidation.error?.message || 'Invalid revision token.'
540
- });
541
- }
542
- if (options.expectedRevision.scope !== 'package') {
543
- return failedApply({
544
- code: 'REVISION_TOKEN_SCOPE_MISMATCH',
545
- message: `Revision token scope mismatch: expected 'package', got '${options.expectedRevision.scope}'.`,
546
- expectedScope: 'package',
547
- actualScope: options.expectedRevision.scope
548
- });
549
- }
550
- const currentToken = computePackageRevisionToken(this.entries);
551
- if (!areRevisionTokensEqual(currentToken.value, options.expectedRevision.value)) {
552
- return failedApply({
553
- code: 'REVISION_MISMATCH',
554
- message: `Document revision mismatch: expected '${options.expectedRevision.value}', current is '${currentToken.value}'.`,
555
- expectedRevision: options.expectedRevision,
556
- currentRevision: currentToken
557
- });
558
- }
559
- }
560
-
561
- const originalEntries = this.entries;
562
- const working = cloneEntries(originalEntries);
563
- const zip = new MemoryZip(working);
564
- const documentXml = text(working, 'word/document.xml');
565
- let originalIssues = [];
566
- let operationResult = null;
567
-
568
- try {
569
- if (!documentXml) throw new Error('Missing word/document.xml.');
570
- const baseline = validateRedlineOoxml(documentXml);
571
- originalIssues = baseline.issues.map(issue => ({ source: 'word/document.xml', ...issue }));
572
- try {
573
- await validateDocxPackage(new MemoryZip(cloneEntries(originalEntries)));
574
- } catch (error) {
575
- originalIssues.push({ source: 'package', code: 'PACKAGE_VALIDATION', severity: 'error', message: error.message });
576
- }
577
-
578
- const context = {
579
- numberingIdState: createDynamicNumberingIdState(text(working, 'word/numbering.xml') || undefined),
580
- commentsXml: text(working, 'word/comments.xml'),
581
- commentsExtendedXml: text(working, 'word/commentsExtended.xml')
582
- };
583
- const { expectedRevision: _pkgExpectedRevision, ...runnerOptions } = options;
584
- const author = options.author || getDefaultAuthor();
585
- const result = operationResult = await applyOperationsToDocumentXml(documentXml, operations, author, context, {
586
- ...runnerOptions,
587
- atomic: options.atomic === true,
588
- strictTargets: options.strictTargets !== false,
589
- _existingCommentDetails: existingCommentDetails(working),
590
- commentIdAllocator: nextCommentId(working)
591
- });
592
-
593
- if (result.rolledBack || result.status === 'error') {
594
- const buf = toBufferCompatible(this.originalBytes);
595
- return {
596
- ...result,
597
- ...rolledBackOperationPayload(result, Array.isArray(operations) ? operations.length : 0),
598
- written: false,
599
- artifactsChanged: [],
600
- validation: { originalIssues, generatedIssues: [] },
601
- uint8Array: this.originalBytes,
602
- toUint8Array: () => new Uint8Array(this.originalBytes),
603
- buffer: buf,
604
- toBuffer: () => toBufferCompatible(this.originalBytes)
605
- };
606
- }
607
-
608
- if (!result.hasChanges) {
609
- const buf = toBufferCompatible(this.originalBytes);
610
- return {
611
- ...result,
612
- status: result.status || 'ok',
613
- written: false,
614
- artifactsChanged: [],
615
- validation: { originalIssues, generatedIssues: [] },
616
- uint8Array: this.originalBytes,
617
- toUint8Array: () => new Uint8Array(this.originalBytes),
618
- buffer: buf,
619
- toBuffer: () => toBufferCompatible(this.originalBytes)
620
- };
621
- }
622
-
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));
626
- await ensureNumberingArtifactsInZip(zip, result.numberingXmlParts, { mergeNumberingXml: mergeNumberingXmlBySchemaOrder });
627
-
628
- const existingCommentsXml = text(working, 'word/comments.xml');
629
- const commentsXmlForPackaging = result.commentsXml || existingCommentsXml;
630
- await ensureCommentsArtifactsInZip(zip, commentsXmlForPackaging, {
631
- replaceExisting: result.commentsXmlMode === 'replace' || (!result.commentsXml && !!existingCommentsXml)
632
- });
633
-
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);
644
-
645
- if (options.validate !== false) {
646
- const generated = validateRedlineOoxml(result.documentXml);
647
- const outputIssues = generated.issues.map(issue => ({ source: 'word/document.xml', ...issue }));
648
- try {
649
- await validateDocxPackage(zip);
650
- } catch (error) {
651
- outputIssues.push({ source: 'package', code: 'PACKAGE_VALIDATION', severity: 'error', message: error.message });
652
- }
653
- const introduced = subtractValidationIssueMultiset(outputIssues, originalIssues);
654
- const introducedErrors = validationErrors(introduced);
655
- if (introducedErrors.length) {
656
- const codes = [...new Set(introducedErrors.map(issue => issue.code))].join(', ');
657
- throw Object.assign(
658
- new Error(`Applied operations introduced invalid revision markup (${codes}); these are generated-output issues, not pre-existing input issues.`),
659
- { issues: introducedErrors }
660
- );
661
- }
662
- }
663
-
664
- this.entries = working;
665
- const outputBytes = this.toUint8Array();
666
- this.originalBytes = outputBytes;
667
- const outputBuf = toBufferCompatible(outputBytes);
668
-
669
- const artifactsChanged = [...working]
670
- .filter(([name, data]) => !originalEntries.has(name) || !areByteArraysEqual(data, originalEntries.get(name)))
671
- .map(([name]) => name);
672
-
673
- return {
674
- ...result,
675
- status: result.status || 'ok',
676
- written: true,
677
- artifactsChanged,
678
- validation: { originalIssues, generatedIssues: [] },
679
- uint8Array: outputBytes,
680
- toUint8Array: () => new Uint8Array(outputBytes),
681
- buffer: outputBuf,
682
- inspection: this.inspect(),
683
- toBuffer: () => toBufferCompatible(outputBytes)
684
- };
685
- } catch (error) {
686
- this.entries = originalEntries;
687
- const generatedIssues = error.issues || [{ source: 'package', code: 'PACKAGE_OPERATION_FAILED', severity: 'error', message: error.message }];
688
- const rollbackPayload = rolledBackOperationPayload(
689
- operationResult,
690
- Array.isArray(operations) ? operations.length : 0
691
- );
692
- const buf = toBufferCompatible(this.originalBytes);
693
- return {
694
- ...rollbackPayload,
695
- status: 'error',
696
- hasChanges: false,
697
- written: false,
698
- artifactsChanged: [],
699
- error: normalizeErrorWithRecovery({
700
- code: 'PACKAGE_OPERATION_FAILED',
701
- message: error.message,
702
- stage: 'package',
703
- issues: generatedIssues
704
- }),
705
- validation: { originalIssues, generatedIssues },
706
- issues: generatedIssues,
707
- uint8Array: this.originalBytes,
708
- toUint8Array: () => new Uint8Array(this.originalBytes),
709
- buffer: buf,
710
- toBuffer: () => toBufferCompatible(this.originalBytes)
711
- };
712
- }
713
- }
714
-
715
- async resolveRevisions(action, options = {}) {
716
- const transform = action === 'accept' ? acceptTrackedChangesInOoxml : action === 'reject' ? rejectTrackedChangesInOoxml : null;
717
- if (!transform) return packageFailure(this.originalBytes, 'INVALID_ACTION', `Unknown revision action: ${action}`);
718
-
719
- const sourceBytes = this.originalBytes;
720
- const originalEntries = this.entries;
721
- const working = cloneEntries(this.entries);
722
- const zip = new MemoryZip(working);
723
- const result = transform(text(working, 'word/document.xml'), { author: options.author, allAuthors: options.allAuthors === true });
724
-
725
- if (result.status === 'error' || result.error) {
726
- return packageFailure(sourceBytes, result.error?.code || 'REVISION_OPERATION_FAILED', result.error?.message || 'Revision operation failed.');
727
- }
728
-
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) {
743
- const buf = toBufferCompatible(sourceBytes);
744
- return {
745
- ...result,
746
- status: 'ok',
747
- written: false,
748
- artifactsChanged: [],
749
- uint8Array: sourceBytes,
750
- toUint8Array: () => new Uint8Array(sourceBytes),
751
- buffer: buf,
752
- toBuffer: () => toBufferCompatible(sourceBytes)
753
- };
754
- }
755
-
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));
758
- try {
759
- await repairKnownContentTypes(zip);
760
- if (options.validate !== false) await validateDocxPackage(zip);
761
- } catch (error) {
762
- return packageFailure(sourceBytes, 'PACKAGE_VALIDATION', error.message);
763
- }
764
-
765
- this.entries = working;
766
- const outputBytes = this.toUint8Array();
767
- this.originalBytes = outputBytes;
768
- const outputBuf = toBufferCompatible(outputBytes);
769
-
770
- return {
771
- ...result,
772
- status: 'ok',
773
- written: true,
774
- hasChanges: true,
775
- artifactsChanged: [...working]
776
- .filter(([name, data]) => !originalEntries.has(name) || !areByteArraysEqual(data, originalEntries.get(name)))
777
- .map(([name]) => name),
778
- uint8Array: outputBytes,
779
- toUint8Array: () => new Uint8Array(outputBytes),
780
- buffer: outputBuf,
781
- toBuffer: () => toBufferCompatible(outputBytes)
782
- };
783
- }
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
-
834
- async deleteComments(options = {}) {
835
- const sourceBytes = this.originalBytes;
836
- const working = cloneEntries(this.entries);
837
- const commentsXml = text(working, 'word/comments.xml');
838
- const buf = toBufferCompatible(sourceBytes);
839
-
840
- if (!commentsXml) {
841
- return {
842
- status: 'ok',
843
- hasChanges: false,
844
- written: false,
845
- commentsRemoved: 0,
846
- referencesRemoved: 0,
847
- artifactsChanged: [],
848
- uint8Array: sourceBytes,
849
- toUint8Array: () => new Uint8Array(sourceBytes),
850
- buffer: buf,
851
- toBuffer: () => toBufferCompatible(sourceBytes)
852
- };
853
- }
854
-
855
- const parsed = parseOoxmlSafe(commentsXml, 'application/xml');
856
- if (!parsed.doc || parsed.error) {
857
- return packageFailure(sourceBytes, 'PARSE_ERROR', parsed.error?.message || 'Could not parse comments.xml.');
858
- }
859
-
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);
870
- const ids = new Set(
871
- Array.from(parsed.doc.getElementsByTagNameNS('*', 'comment'))
872
- .filter(matches)
873
- .map(node => node.getAttribute('w:id') || node.getAttribute('id'))
874
- .filter(Boolean)
875
- );
876
-
877
- if (!ids.size) {
878
- return {
879
- status: 'ok',
880
- hasChanges: false,
881
- written: false,
882
- commentsRemoved: 0,
883
- referencesRemoved: 0,
884
- artifactsChanged: [],
885
- uint8Array: sourceBytes,
886
- toUint8Array: () => new Uint8Array(sourceBytes),
887
- buffer: buf,
888
- toBuffer: () => toBufferCompatible(sourceBytes)
889
- };
890
- }
891
-
892
- const commentsExtendedXml = text(working, 'word/commentsExtended.xml');
893
- let extendedParsed = null;
894
- const removedParaIds = new Set();
895
-
896
- if (commentsExtendedXml) {
897
- extendedParsed = parseOoxmlSafe(commentsExtendedXml, 'application/xml');
898
- if (!extendedParsed.doc || extendedParsed.error) {
899
- return packageFailure(sourceBytes, 'PARSE_ERROR', extendedParsed.error?.message || 'Could not parse commentsExtended.xml.');
900
- }
901
- const idByParaId = new Map();
902
- for (const comment of Array.from(parsed.doc.getElementsByTagNameNS('*', 'comment'))) {
903
- const id = comment.getAttribute('w:id') || comment.getAttribute('id');
904
- const paraId = commentParaId(comment);
905
- if (paraId) idByParaId.set(paraId, id);
906
- }
907
- for (const [paraId, id] of idByParaId) {
908
- if (ids.has(id)) removedParaIds.add(paraId);
909
- }
910
- let expanded = true;
911
- while (expanded) {
912
- expanded = false;
913
- for (const entry of Array.from(extendedParsed.doc.getElementsByTagNameNS('*', 'commentEx'))) {
914
- const paraId = (entry.getAttribute('w15:paraId') || entry.getAttribute('paraId') || '').toUpperCase();
915
- const parentParaId = (entry.getAttribute('w15:paraIdParent') || entry.getAttribute('paraIdParent') || '').toUpperCase();
916
- if (parentParaId && removedParaIds.has(parentParaId) && !removedParaIds.has(paraId)) {
917
- removedParaIds.add(paraId);
918
- if (idByParaId.has(paraId)) ids.add(idByParaId.get(paraId));
919
- expanded = true;
920
- }
921
- }
922
- }
923
- }
924
-
925
- const remainingXml = requestedIds
926
- ? commentsXml
927
- : deleteCommentsByAuthorInOoxml(commentsXml, { author: options.author, allAuthors: options.allAuthors === true }).oxml;
928
- const remainingParsed = parseOoxmlSafe(remainingXml, 'application/xml');
929
- if (!remainingParsed.doc || remainingParsed.error) {
930
- return packageFailure(sourceBytes, 'PARSE_ERROR', remainingParsed.error?.message || 'Could not parse updated comments.xml.');
931
- }
932
-
933
- for (const comment of Array.from(remainingParsed.doc.getElementsByTagNameNS('*', 'comment'))) {
934
- const id = comment.getAttribute('w:id') || comment.getAttribute('id');
935
- if (ids.has(id)) comment.parentNode?.removeChild(comment);
936
- }
937
-
938
- const documentParsed = parseOoxmlSafe(text(working, 'word/document.xml'), 'application/xml');
939
- if (!documentParsed.doc || documentParsed.error) {
940
- return packageFailure(sourceBytes, 'PARSE_ERROR', documentParsed.error?.message || 'Could not parse document.xml.');
941
- }
942
-
943
- let referencesRemoved = 0;
944
- for (const name of ['commentRangeStart', 'commentRangeEnd', 'commentReference']) {
945
- for (const node of Array.from(documentParsed.doc.getElementsByTagNameNS('*', name))) {
946
- const id = node.getAttribute('w:id') || node.getAttribute('id');
947
- if (!ids.has(id) || !node.parentNode) continue;
948
- const parent = node.parentNode;
949
- parent.removeChild(node);
950
- referencesRemoved += 1;
951
- if (name === 'commentReference' && parent.localName === 'r' && !Array.from(parent.childNodes || []).some(child => child.nodeType === 1 && child.localName !== 'rPr')) {
952
- parent.parentNode?.removeChild(parent);
953
- }
954
- }
955
- }
956
-
957
- const serializer = createSerializer();
958
- working.set('word/comments.xml', textEncoder.encode(serializer.serializeToString(remainingParsed.doc)));
959
-
960
- if (extendedParsed?.doc) {
961
- for (const entry of Array.from(extendedParsed.doc.getElementsByTagNameNS('*', 'commentEx'))) {
962
- const paraId = (entry.getAttribute('w15:paraId') || entry.getAttribute('paraId') || '').toUpperCase();
963
- if (removedParaIds.has(paraId)) entry.parentNode?.removeChild(entry);
964
- }
965
- working.set('word/commentsExtended.xml', textEncoder.encode(serializer.serializeToString(extendedParsed.doc)));
966
- }
967
-
968
- working.set('word/document.xml', textEncoder.encode(serializer.serializeToString(documentParsed.doc)));
969
-
970
- try {
971
- reconcileCommentSiblings(working);
972
- const finalZip = new MemoryZip(working);
973
- await repairKnownContentTypes(finalZip);
974
- if (options.validate !== false) await validateDocxPackage(finalZip);
975
- } catch (error) {
976
- return packageFailure(sourceBytes, 'PACKAGE_VALIDATION', error.message);
977
- }
978
-
979
- const artifactsChanged = [...working]
980
- .filter(([name, data]) => !this.entries.has(name) || !areByteArraysEqual(data, this.entries.get(name)))
981
- .map(([name]) => name);
982
- this.entries = working;
983
- const outputBytes = this.toUint8Array();
984
- this.originalBytes = outputBytes;
985
- const outputBuf = toBufferCompatible(outputBytes);
986
-
987
- return {
988
- status: 'ok',
989
- hasChanges: true,
990
- written: true,
991
- commentsRemoved: ids.size,
992
- referencesRemoved,
993
- artifactsChanged,
994
- uint8Array: outputBytes,
995
- toUint8Array: () => new Uint8Array(outputBytes),
996
- buffer: outputBuf,
997
- toBuffer: () => toBufferCompatible(outputBytes)
998
- };
999
- }
1000
- }
1001
-
1002
- /**
1003
- * Opens a DOCX document package from a byte array or Buffer.
1004
- *
1005
- * @param {Uint8Array|ArrayBuffer|ArrayBufferView|string} input - Binary DOCX archive
1006
- * @returns {DocxDocument}
1007
- */
1008
- export function openDocx(input) {
1009
- return new DocxDocument(input);
1010
- }
1
+ /**
2
+ * Universal DOCX document facade and package-level operation runner.
3
+ *
4
+ * Implements complete-document manipulation using cross-runtime standards
5
+ * (Uint8Array, TextEncoder/Decoder) and the universal ZIP archive layer.
6
+ * Runs in Node.js, browsers, Cloudflare Workers, Deno, and sandboxed runtimes.
7
+ */
8
+
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';
12
+ import { inspectDocumentParts } from '../services/document-inspection.js';
13
+ import { applyOperationsToDocumentXml, preflightOperations } from '../services/standalone-operation-runner.js';
14
+ import { createDynamicNumberingIdState } from '../services/numbering-helpers.js';
15
+ import { validateNumberingIntegrity } from '../services/numbering-allocation.js';
16
+ import { ensureCommentsArtifactsInZip, ensureCommentsExtendedArtifactsInZip, ensureNumberingArtifactsInZip, repairKnownContentTypes, validateDocxPackage } from '../services/standalone-docx-plumbing.js';
17
+ import { validateRedlineOoxml } from '../core/redline-validation.js';
18
+ import { subtractValidationIssueMultiset, validationErrors } from '../core/validation-delta.js';
19
+ import { acceptTrackedChangesInOoxml, rejectTrackedChangesInOoxml, deleteCommentsByAuthorInOoxml } from '../services/revision-comment-management.js';
20
+ import { createSerializer, parseOoxmlSafe } from '../adapters/xml-adapter.js';
21
+ import { sha256 } from '../core/sha256.js';
22
+ import { MemoryZip, unzipDocx, zipDocx, toUint8Array } from './zip-archive.js';
23
+ import { computeRevisionTokenSync, validateRevisionToken, areRevisionTokensEqual } from '../services/revision-token.js';
24
+ import { createRetryPlan, normalizeErrorWithRecovery } from '../services/error-recovery.js';
25
+
26
+ const textEncoder = new TextEncoder();
27
+ const textDecoder = new TextDecoder('utf-8');
28
+
29
+ const text = (entries, path) => {
30
+ const entry = entries.get(path);
31
+ if (!entry) return null;
32
+ if (typeof entry === 'string') return entry;
33
+ return textDecoder.decode(entry);
34
+ };
35
+
36
+ /** Keeps commentsIds/commentsExtensible in step with comments.xml after comments were added or removed. */
37
+ function reconcileCommentSiblings(working) {
38
+ const updated = reconcileCommentSiblingParts({
39
+ commentsXml: text(working, 'word/comments.xml'),
40
+ commentsIdsXml: text(working, 'word/commentsIds.xml'),
41
+ commentsExtensibleXml: text(working, 'word/commentsExtensible.xml')
42
+ });
43
+ if (updated.commentsIdsXml) working.set('word/commentsIds.xml', textEncoder.encode(updated.commentsIdsXml));
44
+ if (updated.commentsExtensibleXml) working.set('word/commentsExtensible.xml', textEncoder.encode(updated.commentsExtensibleXml));
45
+ }
46
+
47
+ const cloneEntries = entries => new Map([...entries].map(([name, data]) => [name, new Uint8Array(data)]));
48
+
49
+ function toBufferCompatible(uint8Arr) {
50
+ if (typeof Buffer !== 'undefined' && typeof Buffer.from === 'function') {
51
+ return Buffer.from(uint8Arr.buffer, uint8Arr.byteOffset, uint8Arr.byteLength);
52
+ }
53
+ return uint8Arr;
54
+ }
55
+
56
+ function areByteArraysEqual(a, b) {
57
+ if (a === b) return true;
58
+ if (!a || !b) return false;
59
+ if (a.length !== b.length) return false;
60
+ for (let i = 0; i < a.length; i++) {
61
+ if (a[i] !== b[i]) return false;
62
+ }
63
+ return true;
64
+ }
65
+
66
+ function rolledBackOperationPayload(operationResult, operationCount) {
67
+ const rollbackReceipt = receipt => {
68
+ if (!receipt || typeof receipt !== 'object') return receipt;
69
+ if (receipt.attemptedDisposition !== 'applied' && receipt.committed !== true) return { ...receipt };
70
+ return { ...receipt, committed: false, finalDisposition: 'rolled_back' };
71
+ };
72
+ const receipts = (operationResult?.receipts || []).map(rollbackReceipt);
73
+ const receiptByIndex = new Map(receipts.map(receipt => [receipt.operationIndex, receipt]));
74
+ const results = (operationResult?.results || []).map(result => ({
75
+ ...result,
76
+ ...(result.receipt ? {
77
+ receipt: receiptByIndex.get(result.index) || rollbackReceipt(result.receipt)
78
+ } : {})
79
+ }));
80
+ return {
81
+ results,
82
+ receipts,
83
+ executionOrder: operationResult?.executionOrder || [],
84
+ authorsUsed: [],
85
+ rolledBack: true,
86
+ retryPlan: createRetryPlan({
87
+ atomic: true,
88
+ rolledBack: true,
89
+ results,
90
+ receipts,
91
+ operationCount
92
+ })
93
+ };
94
+ }
95
+
96
+ /**
97
+ * Computes a package-scoped revision token over all uncompressed entries in a DOCX archive.
98
+ *
99
+ * @param {Uint8Array|ArrayBuffer|Map<string, Uint8Array>|DocxDocument|object} input
100
+ * @returns {{ algorithm: 'sha256', version: number, scope: 'package', value: string, coveredParts: string[] }}
101
+ */
102
+ export function computePackageRevisionToken(input) {
103
+ let entries;
104
+ if (input instanceof Uint8Array || (typeof Buffer !== 'undefined' && Buffer.isBuffer?.(input))) {
105
+ entries = unzipDocx(input);
106
+ } else if (input instanceof Map) {
107
+ entries = input;
108
+ } else if (input?.entries instanceof Map) {
109
+ entries = input.entries;
110
+ } else if (typeof input?.toUint8Array === 'function') {
111
+ entries = unzipDocx(input.toUint8Array());
112
+ } else if (typeof input?.toBuffer === 'function') {
113
+ entries = unzipDocx(input.toBuffer());
114
+ } else if (input instanceof ArrayBuffer || ArrayBuffer.isView(input)) {
115
+ entries = unzipDocx(toUint8Array(input));
116
+ } else {
117
+ throw new TypeError('computePackageRevisionToken requires a Uint8Array, Buffer, Map of entries, or DocxDocument.');
118
+ }
119
+ return computeRevisionTokenSync({
120
+ scope: 'package',
121
+ entries
122
+ });
123
+ }
124
+
125
+ // Word does not support comments in headers or footers, so these are refused when `part` is set.
126
+ const COMMENT_OPERATION_TYPES = new Set(['comment', 'comment_reply', 'comment_resolve']);
127
+
128
+ function nextCommentId(entries) {
129
+ const ids = `${text(entries, 'word/document.xml') || ''} ${text(entries, 'word/comments.xml') || ''}`.match(/(?:w:)?id=["'](\d+)["']/g) || [];
130
+ let next = ids.reduce((max, token) => Math.max(max, Number(token.match(/\d+/)?.[0] || 0)), 0) + 1;
131
+ return () => next++;
132
+ }
133
+
134
+ function existingCommentDetails(entries) {
135
+ const commentsXml = text(entries, 'word/comments.xml');
136
+ if (!commentsXml) return {};
137
+ const parsed = parseOoxmlSafe(commentsXml, 'application/xml');
138
+ if (!parsed.doc || parsed.error) return {};
139
+ const details = {};
140
+ for (const comment of Array.from(parsed.doc.getElementsByTagNameNS('*', 'comment'))) {
141
+ const id = comment.getAttribute('w:id') || comment.getAttribute('id');
142
+ if (id === '') continue;
143
+ details[id] = {
144
+ id,
145
+ author: comment.getAttribute('w:author') || comment.getAttribute('author') || '',
146
+ text: String(comment.textContent || '').trim(),
147
+ paraId: commentParaId(comment) || null
148
+ };
149
+ }
150
+ return details;
151
+ }
152
+
153
+ function packageFailure(sourceBytes, code, message) {
154
+ const buf = toBufferCompatible(sourceBytes);
155
+ return {
156
+ status: 'error',
157
+ hasChanges: false,
158
+ written: false,
159
+ rolledBack: true,
160
+ error: normalizeErrorWithRecovery({ code, message }),
161
+ retryPlan: createRetryPlan({ atomic: true, rolledBack: true }),
162
+ artifactsChanged: [],
163
+ uint8Array: sourceBytes,
164
+ toUint8Array: () => new Uint8Array(sourceBytes),
165
+ buffer: buf,
166
+ toBuffer: () => toBufferCompatible(sourceBytes)
167
+ };
168
+ }
169
+
170
+ /**
171
+ * Universal DOCX document representation.
172
+ */
173
+ export class DocxDocument {
174
+ constructor(input) {
175
+ this.originalBytes = toUint8Array(input);
176
+ this.entries = unzipDocx(this.originalBytes);
177
+ }
178
+
179
+ get originalBuffer() {
180
+ return toBufferCompatible(this.originalBytes);
181
+ }
182
+
183
+ inspect(options = {}) {
184
+ const digestFn = options.digestFn || (bytes => sha256(bytes));
185
+ const inspection = inspectDocumentParts({
186
+ documentXml: text(this.entries, 'word/document.xml'),
187
+ commentsXml: text(this.entries, 'word/comments.xml'),
188
+ commentsExtendedXml: text(this.entries, 'word/commentsExtended.xml'),
189
+ numberingXml: text(this.entries, 'word/numbering.xml'),
190
+ stylesXml: text(this.entries, 'word/styles.xml')
191
+ }, { ...options, digestFn });
192
+ if (inspection.status === 'error') return inspection;
193
+ return { ...inspection, headersFooters: this.inspectHeadersFooters() };
194
+ }
195
+
196
+ /**
197
+ * Header and footer parts the document uses, with their paragraph text, so operations can target them with
198
+ * `part`. `hasFields` marks parts containing PAGE/NUMPAGES-style fields (edit around them, not inside).
199
+ */
200
+ inspectHeadersFooters() {
201
+ return discoverHeaderFooterParts({
202
+ documentXml: text(this.entries, 'word/document.xml'),
203
+ relsXml: text(this.entries, 'word/_rels/document.xml.rels'),
204
+ settingsXml: text(this.entries, 'word/settings.xml')
205
+ }).map(part => {
206
+ const xml = text(this.entries, part.path) || '';
207
+ const partInspection = xml ? inspectDocumentParts({ documentXml: xml }) : { paragraphs: [] };
208
+ return {
209
+ ...part,
210
+ hasFields: /<w:fldChar\b|<w:fldSimple\b/.test(xml),
211
+ paragraphs: (partInspection.paragraphs || []).map(paragraph => ({ index: paragraph.index, text: paragraph.text }))
212
+ };
213
+ });
214
+ }
215
+
216
+ getRevisionToken() {
217
+ return computePackageRevisionToken(this.entries);
218
+ }
219
+
220
+ get revisionToken() {
221
+ return this.getRevisionToken();
222
+ }
223
+
224
+ preflight(operations, author = getDefaultAuthor(), options = {}) {
225
+ const list = Array.isArray(operations) ? operations : [];
226
+ const runBody = ops => preflightOperations(
227
+ text(this.entries, 'word/document.xml'),
228
+ ops,
229
+ author || getDefaultAuthor(),
230
+ { ...options, _existingCommentDetails: existingCommentDetails(this.entries) }
231
+ );
232
+ if (!list.some(operation => operation && typeof operation === 'object' && operation.part != null)) return runBody(operations);
233
+
234
+ const parts = this.inspectHeadersFooters();
235
+ const bodyOps = [];
236
+ const results = [];
237
+ const conflicts = [];
238
+ const authorsUsed = new Set();
239
+ let valid = true;
240
+ list.forEach((operation, i) => {
241
+ if (!operation || typeof operation !== 'object' || operation.part == null) {
242
+ bodyOps.push({ operation, origIndex: i });
243
+ return;
244
+ }
245
+ const resolved = resolvePartSelector(parts, operation.part);
246
+ const refusal = COMMENT_OPERATION_TYPES.has(operation.type)
247
+ ? { code: 'COMMENT_IN_HEADER_FOOTER', message: 'Word does not support comments in headers or footers.' }
248
+ : resolved.error;
249
+ if (refusal) {
250
+ valid = false;
251
+ results.push({ index: i + 1, type: operation.type || 'redline', status: 'error', error: normalizeErrorWithRecovery({ ...refusal, operationIndex: i + 1 }) });
252
+ return;
253
+ }
254
+ const { part: _selector, ...rest } = operation;
255
+ const partResult = preflightOperations(text(this.entries, resolved.part.path), [rest], author || getDefaultAuthor(), { ...options, _existingCommentDetails: {} });
256
+ valid = valid && partResult.valid;
257
+ for (const result of partResult.results || []) results.push({ ...result, index: i + 1, part: resolved.part.path });
258
+ conflicts.push(...(partResult.conflicts || []));
259
+ for (const name of partResult.authorsUsed || []) authorsUsed.add(name);
260
+ });
261
+ let requiredArtifacts = { comments: false, numbering: false };
262
+ if (bodyOps.length) {
263
+ const body = runBody(bodyOps.map(item => item.operation));
264
+ valid = valid && body.valid;
265
+ for (const result of body.results || []) results.push({ ...result, index: bodyOps[result.index - 1].origIndex + 1 });
266
+ conflicts.push(...(body.conflicts || []));
267
+ for (const name of body.authorsUsed || []) authorsUsed.add(name);
268
+ requiredArtifacts = body.requiredArtifacts || requiredArtifacts;
269
+ }
270
+ results.sort((a, b) => a.index - b.index);
271
+ const ok = valid && conflicts.length === 0;
272
+ return { valid: ok, status: ok ? 'ok' : 'error', results, conflicts, authorsUsed: [...authorsUsed], requiredArtifacts };
273
+ }
274
+
275
+ toUint8Array() {
276
+ return zipDocx(this.entries);
277
+ }
278
+
279
+ toBuffer() {
280
+ return toBufferCompatible(this.toUint8Array());
281
+ }
282
+
283
+ /**
284
+ * Applies operations to the document. Operations without `part` target the body (word/document.xml).
285
+ * An operation with `part` targets one header or footer part:
286
+ * `{ part: { kind: 'footer', type: 'default', section: 0 } | 'word/footer2.xml', target: { exactText }, ... }`.
287
+ * Discover parts with `inspect().headersFooters`.
288
+ */
289
+ async applyOperations(operations, options = {}) {
290
+ const list = Array.isArray(operations) ? operations : [];
291
+ if (!list.some(operation => operation && typeof operation === 'object' && operation.part != null)) {
292
+ return this.applyBodyOperations(operations, options);
293
+ }
294
+ return this.applyOperationsWithParts(list, options);
295
+ }
296
+
297
+ async applyOperationsWithParts(operations, options = {}) {
298
+ const author = options.author || getDefaultAuthor();
299
+ const atomic = options.atomic === true;
300
+ const snapshotEntries = this.entries;
301
+ const snapshotBytes = this.originalBytes;
302
+ const total = operations.length;
303
+ const restore = () => { this.entries = snapshotEntries; this.originalBytes = snapshotBytes; };
304
+ const original = () => ({
305
+ uint8Array: snapshotBytes,
306
+ toUint8Array: () => new Uint8Array(snapshotBytes),
307
+ buffer: toBufferCompatible(snapshotBytes),
308
+ toBuffer: () => toBufferCompatible(snapshotBytes)
309
+ });
310
+ const refuse = (error, results = [], receipts = [], extra = {}) => ({
311
+ status: 'error', hasChanges: false, written: false, rolledBack: true, results, receipts, executionOrder: [], authorsUsed: [],
312
+ artifactsChanged: [],
313
+ error: normalizeErrorWithRecovery(error),
314
+ retryPlan: createRetryPlan({ atomic: true, rolledBack: true, results, receipts, operationCount: total }),
315
+ validation: { originalIssues: [], generatedIssues: [] },
316
+ ...extra,
317
+ ...original()
318
+ });
319
+
320
+ if (options.expectedRevision) {
321
+ const precondition = await this.applyBodyOperations([], { expectedRevision: options.expectedRevision });
322
+ if (precondition.status === 'error') return precondition;
323
+ }
324
+
325
+ // Resolve every selector before touching anything, so a bad selector fails the whole request.
326
+ const parts = discoverHeaderFooterParts({
327
+ documentXml: text(this.entries, 'word/document.xml'),
328
+ relsXml: text(this.entries, 'word/_rels/document.xml.rels'),
329
+ settingsXml: text(this.entries, 'word/settings.xml')
330
+ });
331
+ const bodyOps = [];
332
+ const partOps = [];
333
+ for (let i = 0; i < operations.length; i++) {
334
+ const operation = operations[i];
335
+ if (!operation || typeof operation !== 'object' || operation.part == null) {
336
+ bodyOps.push({ operation, origIndex: i });
337
+ continue;
338
+ }
339
+ if (COMMENT_OPERATION_TYPES.has(operation.type)) {
340
+ return refuse({ code: 'COMMENT_IN_HEADER_FOOTER', message: `Operation ${i + 1}: Word does not support comments in headers or footers.`, operationIndex: i + 1 });
341
+ }
342
+ if (operation.type === 'list-format') {
343
+ return refuse({ code: 'INVALID_OPERATION', field: 'part', message: `Operation ${i + 1}: list-format is body-only in list-format-v1; headers and footers are not supported.`, operationIndex: i + 1 });
344
+ }
345
+ const resolved = resolvePartSelector(parts, operation.part);
346
+ if (resolved.error) return refuse({ ...resolved.error, message: `Operation ${i + 1}: ${resolved.error.message}`, operationIndex: i + 1 });
347
+ const { part: _selector, ...rest } = operation;
348
+ partOps.push({ operation: rest, origIndex: i, part: resolved.part });
349
+ }
350
+
351
+ const results = [];
352
+ const receipts = [];
353
+ const executionOrder = [];
354
+ const authorsUsed = new Set();
355
+ const originalIssues = [];
356
+ let anyChange = false;
357
+ let anyFailure = false;
358
+ let bodyResult = null;
359
+
360
+ if (bodyOps.length) {
361
+ bodyResult = await this.applyBodyOperations(bodyOps.map(item => item.operation), { ...options, expectedRevision: undefined });
362
+ const toOriginal = index => bodyOps[index - 1]?.origIndex + 1;
363
+ for (const result of bodyResult.results || []) {
364
+ results.push({
365
+ ...result,
366
+ index: toOriginal(result.index),
367
+ ...(result.receipt ? { receipt: { ...result.receipt, operationIndex: toOriginal(result.receipt.operationIndex) } } : {})
368
+ });
369
+ }
370
+ for (const receipt of bodyResult.receipts || []) receipts.push({ ...receipt, operationIndex: toOriginal(receipt.operationIndex) });
371
+ for (const index of bodyResult.executionOrder || []) executionOrder.push(toOriginal(index));
372
+ for (const name of bodyResult.authorsUsed || []) authorsUsed.add(name);
373
+ originalIssues.push(...(bodyResult.validation?.originalIssues || []));
374
+ if (bodyResult.status === 'error' || bodyResult.rolledBack) {
375
+ anyFailure = true;
376
+ if (atomic) {
377
+ restore();
378
+ return { ...bodyResult, results, receipts, executionOrder, written: false, artifactsChanged: [], rolledBack: true, ...original() };
379
+ }
380
+ }
381
+ anyChange = bodyResult.hasChanges === true;
382
+ }
383
+
384
+ const working = cloneEntries(this.entries);
385
+ const zip = new MemoryZip(working);
386
+ const changedParts = new Set();
387
+ const { expectedRevision: _ignored, ...runnerOptions } = options;
388
+ const failPart = (item, error) => {
389
+ anyFailure = true;
390
+ const failure = normalizeErrorWithRecovery({ ...error, operationIndex: item.origIndex + 1 });
391
+ results.push({ index: item.origIndex + 1, type: item.operation.type || 'redline', status: 'error', authorUsed: author, part: item.part.path, error: failure });
392
+ };
393
+
394
+ for (const item of partOps) {
395
+ const path = item.part.path;
396
+ const xml = text(working, path);
397
+ if (!xml) {
398
+ failPart(item, { code: 'PART_NOT_FOUND', message: `Part '${path}' is referenced but missing from the package.` });
399
+ if (atomic) break;
400
+ continue;
401
+ }
402
+ const numberingText = text(working, 'word/numbering.xml');
403
+ const outcome = await applyOperationsToDocumentXml(xml, [item.operation], author, { numberingIdState: createDynamicNumberingIdState(numberingText || undefined) }, {
404
+ ...runnerOptions, atomic: true, strictTargets: options.strictTargets !== false, _existingCommentDetails: {}
405
+ });
406
+ const single = outcome.results?.[0];
407
+ if (outcome.rolledBack || outcome.status === 'error') {
408
+ failPart(item, single?.error || outcome.error || { code: 'OPERATION_FAILED', message: 'Operation failed.' });
409
+ if (atomic) break;
410
+ continue;
411
+ }
412
+ if (!outcome.hasChanges) {
413
+ results.push({ ...single, index: item.origIndex + 1, part: path });
414
+ continue;
415
+ }
416
+ if (countRevisionsInsideFields(outcome.documentXml) > countRevisionsInsideFields(xml)) {
417
+ 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.` });
418
+ if (atomic) break;
419
+ continue;
420
+ }
421
+ const taken = new Set();
422
+ for (const [name, data] of working) {
423
+ if (name === path || !(name === 'word/document.xml' || isHeaderFooterPath(name))) continue;
424
+ for (const id of collectRevisionIds(textDecoder.decode(data))) taken.add(id);
425
+ }
426
+ const renumbered = renumberCollidingRevisionIds(outcome.documentXml, taken);
427
+ working.set(path, textEncoder.encode(renumbered.xml));
428
+ // Receipts must describe what is in the file: the part it lives in and its final revision ids.
429
+ const describeReceipt = receipt => (receipt?.revisionItems
430
+ ? { ...receipt, revisionItems: receipt.revisionItems.map(revision => ({ ...revision, id: renumbered.idMap.get(String(revision.id)) ?? revision.id, partName: path })) }
431
+ : receipt);
432
+ if (outcome.numberingXmlParts?.length) {
433
+ await ensureNumberingArtifactsInZip(zip, outcome.numberingXmlParts);
434
+ }
435
+ changedParts.add(path);
436
+ anyChange = true;
437
+ results.push({
438
+ ...single,
439
+ index: item.origIndex + 1,
440
+ part: path,
441
+ partSections: item.part.appliesToSections,
442
+ ...(single?.receipt ? { receipt: { ...describeReceipt(single.receipt), operationIndex: item.origIndex + 1 } } : {})
443
+ });
444
+ if (outcome.receipts?.[0]) receipts.push({ ...describeReceipt(outcome.receipts[0]), operationIndex: item.origIndex + 1 });
445
+ executionOrder.push(item.origIndex + 1);
446
+ for (const name of outcome.authorsUsed || []) authorsUsed.add(name);
447
+ }
448
+
449
+ // Each changed part must not introduce revision-markup problems, and the package must still validate.
450
+ const generatedIssues = [];
451
+ if (!(atomic && anyFailure)) {
452
+ for (const path of changedParts) {
453
+ const before = validateRedlineOoxml(text(this.entries, path)).issues.map(issue => ({ source: path, ...issue }));
454
+ const after = validateRedlineOoxml(text(working, path)).issues.map(issue => ({ source: path, ...issue }));
455
+ before.push(...validateNumberingIntegrity(text(this.entries, path), text(this.entries, 'word/numbering.xml')).map(issue => ({ source: path, ...issue })));
456
+ after.push(...validateNumberingIntegrity(text(working, path), text(working, 'word/numbering.xml')).map(issue => ({ source: path, ...issue })));
457
+ generatedIssues.push(...validationErrors(subtractValidationIssueMultiset(after, before)));
458
+ }
459
+ if (options.validate !== false && changedParts.size) {
460
+ try {
461
+ await validateDocxPackage(zip);
462
+ } catch (error) {
463
+ generatedIssues.push({ source: 'package', code: 'PACKAGE_VALIDATION', severity: 'error', message: error.message });
464
+ }
465
+ }
466
+ }
467
+ if ((atomic && anyFailure) || generatedIssues.length) {
468
+ restore();
469
+ const error = generatedIssues.length
470
+ ? { code: 'PACKAGE_OPERATION_FAILED', message: `Applied operations introduced invalid markup (${[...new Set(generatedIssues.map(issue => issue.code))].join(', ')}).`, issues: generatedIssues }
471
+ : { code: 'BATCH_OPERATION_FAILED', message: 'Atomic batch rolled back because one or more operations failed.' };
472
+ const rolledBackResults = results
473
+ .map(result => (result.receipt ? { ...result, receipt: { ...result.receipt, committed: false, finalDisposition: 'rolled_back' } } : result))
474
+ .sort((a, b) => a.index - b.index);
475
+ const rolledBackReceipts = receipts.map(receipt => ({ ...receipt, committed: false, finalDisposition: 'rolled_back' }));
476
+ return refuse(error, rolledBackResults, rolledBackReceipts, { validation: { originalIssues, generatedIssues }, issues: generatedIssues });
477
+ }
478
+
479
+ results.sort((a, b) => a.index - b.index);
480
+ receipts.sort((a, b) => a.operationIndex - b.operationIndex);
481
+ const status = !anyFailure ? 'ok' : (anyChange ? 'partial' : 'error');
482
+ const failureError = anyFailure
483
+ ? { error: normalizeErrorWithRecovery({ code: 'BATCH_OPERATION_FAILED', message: 'One or more operations failed; the others were applied.' }) }
484
+ : {};
485
+ if (!changedParts.size) {
486
+ // No part changed; the body call (if any) already committed its own result.
487
+ const buf = toBufferCompatible(this.originalBytes);
488
+ return {
489
+ ...(bodyResult || {}), results, receipts, executionOrder, authorsUsed: [...authorsUsed], status, hasChanges: anyChange,
490
+ written: bodyResult?.written === true, artifactsChanged: bodyResult?.artifactsChanged || [],
491
+ validation: { originalIssues, generatedIssues: [] }, ...failureError,
492
+ uint8Array: this.originalBytes, toUint8Array: () => new Uint8Array(this.originalBytes), buffer: buf, toBuffer: () => toBufferCompatible(this.originalBytes)
493
+ };
494
+ }
495
+
496
+ this.entries = working;
497
+ const outputBytes = this.toUint8Array();
498
+ this.originalBytes = outputBytes;
499
+ const artifactsChanged = [...working]
500
+ .filter(([name, data]) => !snapshotEntries.has(name) || !areByteArraysEqual(data, snapshotEntries.get(name)))
501
+ .map(([name]) => name);
502
+ return {
503
+ ...(bodyResult || {}),
504
+ status, hasChanges: true, written: true, results, receipts, executionOrder, authorsUsed: [...authorsUsed], artifactsChanged,
505
+ validation: { originalIssues, generatedIssues: [] }, ...failureError,
506
+ uint8Array: outputBytes, toUint8Array: () => new Uint8Array(outputBytes), buffer: toBufferCompatible(outputBytes),
507
+ inspection: this.inspect(), toBuffer: () => toBufferCompatible(outputBytes)
508
+ };
509
+ }
510
+
511
+ async applyBodyOperations(operations, options = {}) {
512
+ const failedApply = error => {
513
+ const results = [];
514
+ const receipts = [];
515
+ const buf = toBufferCompatible(this.originalBytes);
516
+ return {
517
+ status: 'error',
518
+ hasChanges: false,
519
+ written: false,
520
+ rolledBack: true,
521
+ results,
522
+ receipts,
523
+ artifactsChanged: [],
524
+ error: normalizeErrorWithRecovery(error),
525
+ retryPlan: createRetryPlan({
526
+ atomic: true,
527
+ rolledBack: true,
528
+ results,
529
+ receipts,
530
+ operationCount: Array.isArray(operations) ? operations.length : 0
531
+ }),
532
+ validation: { originalIssues: [], generatedIssues: [] },
533
+ uint8Array: this.originalBytes,
534
+ toUint8Array: () => new Uint8Array(this.originalBytes),
535
+ buffer: buf,
536
+ toBuffer: () => toBufferCompatible(this.originalBytes)
537
+ };
538
+ };
539
+
540
+ if (options?.expectedRevision) {
541
+ const tokenValidation = validateRevisionToken(options.expectedRevision);
542
+ if (!tokenValidation.valid) {
543
+ return failedApply({
544
+ code: tokenValidation.error?.code || 'INVALID_REVISION_TOKEN',
545
+ message: tokenValidation.error?.message || 'Invalid revision token.'
546
+ });
547
+ }
548
+ if (options.expectedRevision.scope !== 'package') {
549
+ return failedApply({
550
+ code: 'REVISION_TOKEN_SCOPE_MISMATCH',
551
+ message: `Revision token scope mismatch: expected 'package', got '${options.expectedRevision.scope}'.`,
552
+ expectedScope: 'package',
553
+ actualScope: options.expectedRevision.scope
554
+ });
555
+ }
556
+ const currentToken = computePackageRevisionToken(this.entries);
557
+ if (!areRevisionTokensEqual(currentToken.value, options.expectedRevision.value)) {
558
+ return failedApply({
559
+ code: 'REVISION_MISMATCH',
560
+ message: `Document revision mismatch: expected '${options.expectedRevision.value}', current is '${currentToken.value}'.`,
561
+ expectedRevision: options.expectedRevision,
562
+ currentRevision: currentToken
563
+ });
564
+ }
565
+ }
566
+
567
+ const originalEntries = this.entries;
568
+ const working = cloneEntries(originalEntries);
569
+ const zip = new MemoryZip(working);
570
+ const documentXml = text(working, 'word/document.xml');
571
+ let originalIssues = [];
572
+ let operationResult = null;
573
+
574
+ try {
575
+ if (!documentXml) throw new Error('Missing word/document.xml.');
576
+ const baseline = validateRedlineOoxml(documentXml);
577
+ originalIssues = baseline.issues.map(issue => ({ source: 'word/document.xml', ...issue }));
578
+ originalIssues.push(...validateNumberingIntegrity(documentXml, text(working, 'word/numbering.xml'))
579
+ .map(issue => ({ source: 'word/numbering.xml', ...issue })));
580
+ try {
581
+ await validateDocxPackage(new MemoryZip(cloneEntries(originalEntries)));
582
+ } catch (error) {
583
+ originalIssues.push({ source: 'package', code: 'PACKAGE_VALIDATION', severity: 'error', message: error.message });
584
+ }
585
+
586
+ const context = {
587
+ numberingIdState: createDynamicNumberingIdState(text(working, 'word/numbering.xml') || undefined),
588
+ // list-format refuses paragraphs numbered through their style (style-linked numbering).
589
+ stylesXml: text(working, 'word/styles.xml'),
590
+ commentsXml: text(working, 'word/comments.xml'),
591
+ commentsExtendedXml: text(working, 'word/commentsExtended.xml')
592
+ };
593
+ const { expectedRevision: _pkgExpectedRevision, ...runnerOptions } = options;
594
+ const author = options.author || getDefaultAuthor();
595
+ const result = operationResult = await applyOperationsToDocumentXml(documentXml, operations, author, context, {
596
+ ...runnerOptions,
597
+ atomic: options.atomic === true,
598
+ strictTargets: options.strictTargets !== false,
599
+ _existingCommentDetails: existingCommentDetails(working),
600
+ commentIdAllocator: nextCommentId(working)
601
+ });
602
+
603
+ if (result.rolledBack || result.status === 'error') {
604
+ const buf = toBufferCompatible(this.originalBytes);
605
+ return {
606
+ ...result,
607
+ ...rolledBackOperationPayload(result, Array.isArray(operations) ? operations.length : 0),
608
+ written: false,
609
+ artifactsChanged: [],
610
+ validation: { originalIssues, generatedIssues: [] },
611
+ uint8Array: this.originalBytes,
612
+ toUint8Array: () => new Uint8Array(this.originalBytes),
613
+ buffer: buf,
614
+ toBuffer: () => toBufferCompatible(this.originalBytes)
615
+ };
616
+ }
617
+
618
+ if (!result.hasChanges) {
619
+ const buf = toBufferCompatible(this.originalBytes);
620
+ return {
621
+ ...result,
622
+ status: result.status || 'ok',
623
+ written: false,
624
+ artifactsChanged: [],
625
+ validation: { originalIssues, generatedIssues: [] },
626
+ uint8Array: this.originalBytes,
627
+ toUint8Array: () => new Uint8Array(this.originalBytes),
628
+ buffer: buf,
629
+ toBuffer: () => toBufferCompatible(this.originalBytes)
630
+ };
631
+ }
632
+
633
+ // Resolving a thread only changes the comment parts; keep document.xml byte-identical.
634
+ const bodyChanged = (result.results || []).some(item => item?.status !== 'error' && item?.operationType !== 'comment_resolve');
635
+ if (bodyChanged) working.set('word/document.xml', textEncoder.encode(result.documentXml));
636
+ await ensureNumberingArtifactsInZip(zip, result.numberingXmlParts);
637
+
638
+ const existingCommentsXml = text(working, 'word/comments.xml');
639
+ const commentsXmlForPackaging = result.commentsXml || existingCommentsXml;
640
+ await ensureCommentsArtifactsInZip(zip, commentsXmlForPackaging, {
641
+ replaceExisting: result.commentsXmlMode === 'replace' || (!result.commentsXml && !!existingCommentsXml)
642
+ });
643
+
644
+ // Only touch the extended part (and its content type/rel) when an operation changed it.
645
+ // Rewriting it on every save is what stamped a bad content type onto every
646
+ // already-commented document.
647
+ if (result.commentsExtendedXml) {
648
+ await ensureCommentsExtendedArtifactsInZip(zip, result.commentsExtendedXml, {
649
+ replaceExisting: result.commentsExtendedXmlMode === 'replace'
650
+ });
651
+ }
652
+ await repairKnownContentTypes(zip);
653
+ reconcileCommentSiblings(working);
654
+
655
+ if (options.validate !== false) {
656
+ const generated = validateRedlineOoxml(result.documentXml);
657
+ const outputIssues = generated.issues.map(issue => ({ source: 'word/document.xml', ...issue }));
658
+ outputIssues.push(...validateNumberingIntegrity(result.documentXml, text(working, 'word/numbering.xml'))
659
+ .map(issue => ({ source: 'word/numbering.xml', ...issue })));
660
+ try {
661
+ await validateDocxPackage(zip);
662
+ } catch (error) {
663
+ outputIssues.push({ source: 'package', code: 'PACKAGE_VALIDATION', severity: 'error', message: error.message });
664
+ }
665
+ const introduced = subtractValidationIssueMultiset(outputIssues, originalIssues);
666
+ const introducedErrors = validationErrors(introduced);
667
+ if (introducedErrors.length) {
668
+ const codes = [...new Set(introducedErrors.map(issue => issue.code))].join(', ');
669
+ throw Object.assign(
670
+ new Error(`Applied operations introduced invalid revision markup (${codes}); these are generated-output issues, not pre-existing input issues.`),
671
+ { issues: introducedErrors }
672
+ );
673
+ }
674
+ }
675
+
676
+ this.entries = working;
677
+ const outputBytes = this.toUint8Array();
678
+ this.originalBytes = outputBytes;
679
+ const outputBuf = toBufferCompatible(outputBytes);
680
+
681
+ const artifactsChanged = [...working]
682
+ .filter(([name, data]) => !originalEntries.has(name) || !areByteArraysEqual(data, originalEntries.get(name)))
683
+ .map(([name]) => name);
684
+
685
+ return {
686
+ ...result,
687
+ status: result.status || 'ok',
688
+ written: true,
689
+ artifactsChanged,
690
+ validation: { originalIssues, generatedIssues: [] },
691
+ uint8Array: outputBytes,
692
+ toUint8Array: () => new Uint8Array(outputBytes),
693
+ buffer: outputBuf,
694
+ inspection: this.inspect(),
695
+ toBuffer: () => toBufferCompatible(outputBytes)
696
+ };
697
+ } catch (error) {
698
+ this.entries = originalEntries;
699
+ const generatedIssues = error.issues || [{ source: 'package', code: 'PACKAGE_OPERATION_FAILED', severity: 'error', message: error.message }];
700
+ const rollbackPayload = rolledBackOperationPayload(
701
+ operationResult,
702
+ Array.isArray(operations) ? operations.length : 0
703
+ );
704
+ const buf = toBufferCompatible(this.originalBytes);
705
+ return {
706
+ ...rollbackPayload,
707
+ status: 'error',
708
+ hasChanges: false,
709
+ written: false,
710
+ artifactsChanged: [],
711
+ error: normalizeErrorWithRecovery({
712
+ code: 'PACKAGE_OPERATION_FAILED',
713
+ message: error.message,
714
+ stage: 'package',
715
+ issues: generatedIssues
716
+ }),
717
+ validation: { originalIssues, generatedIssues },
718
+ issues: generatedIssues,
719
+ uint8Array: this.originalBytes,
720
+ toUint8Array: () => new Uint8Array(this.originalBytes),
721
+ buffer: buf,
722
+ toBuffer: () => toBufferCompatible(this.originalBytes)
723
+ };
724
+ }
725
+ }
726
+
727
+ async resolveRevisions(action, options = {}) {
728
+ const transform = action === 'accept' ? acceptTrackedChangesInOoxml : action === 'reject' ? rejectTrackedChangesInOoxml : null;
729
+ if (!transform) return packageFailure(this.originalBytes, 'INVALID_ACTION', `Unknown revision action: ${action}`);
730
+
731
+ const sourceBytes = this.originalBytes;
732
+ const originalEntries = this.entries;
733
+ const working = cloneEntries(this.entries);
734
+ const zip = new MemoryZip(working);
735
+ const result = transform(text(working, 'word/document.xml'), { author: options.author, allAuthors: options.allAuthors === true });
736
+
737
+ if (result.status === 'error' || result.error) {
738
+ return packageFailure(sourceBytes, result.error?.code || 'REVISION_OPERATION_FAILED', result.error?.message || 'Revision operation failed.');
739
+ }
740
+
741
+ // Headers and footers carry their own tracked changes; resolve them with the same filter.
742
+ const partUpdates = new Map();
743
+ for (const part of this.inspectHeadersFooters()) {
744
+ const partXml = text(working, part.path);
745
+ if (!partXml) continue;
746
+ const partResult = transform(partXml, { author: options.author, allAuthors: options.allAuthors === true });
747
+ if (partResult.status === 'error' || partResult.error) {
748
+ return packageFailure(sourceBytes, partResult.error?.code || 'REVISION_OPERATION_FAILED', `${part.path}: ${partResult.error?.message || 'Revision operation failed.'}`);
749
+ }
750
+ if (partResult.hasChanges) partUpdates.set(part.path, partResult.oxml);
751
+ }
752
+ const hasChanges = result.hasChanges || partUpdates.size > 0;
753
+
754
+ if (!hasChanges) {
755
+ const buf = toBufferCompatible(sourceBytes);
756
+ return {
757
+ ...result,
758
+ status: 'ok',
759
+ written: false,
760
+ artifactsChanged: [],
761
+ uint8Array: sourceBytes,
762
+ toUint8Array: () => new Uint8Array(sourceBytes),
763
+ buffer: buf,
764
+ toBuffer: () => toBufferCompatible(sourceBytes)
765
+ };
766
+ }
767
+
768
+ if (result.hasChanges) working.set('word/document.xml', textEncoder.encode(result.oxml));
769
+ for (const [path, xml] of partUpdates) working.set(path, textEncoder.encode(xml));
770
+ try {
771
+ await repairKnownContentTypes(zip);
772
+ if (options.validate !== false) await validateDocxPackage(zip);
773
+ } catch (error) {
774
+ return packageFailure(sourceBytes, 'PACKAGE_VALIDATION', error.message);
775
+ }
776
+
777
+ this.entries = working;
778
+ const outputBytes = this.toUint8Array();
779
+ this.originalBytes = outputBytes;
780
+ const outputBuf = toBufferCompatible(outputBytes);
781
+
782
+ return {
783
+ ...result,
784
+ status: 'ok',
785
+ written: true,
786
+ hasChanges: true,
787
+ artifactsChanged: [...working]
788
+ .filter(([name, data]) => !originalEntries.has(name) || !areByteArraysEqual(data, originalEntries.get(name)))
789
+ .map(([name]) => name),
790
+ uint8Array: outputBytes,
791
+ toUint8Array: () => new Uint8Array(outputBytes),
792
+ buffer: outputBuf,
793
+ toBuffer: () => toBufferCompatible(outputBytes)
794
+ };
795
+ }
796
+
797
+ /**
798
+ * Resolves (or reopens) the comment thread containing `commentId`.
799
+ * Resolved is a thread-level state in Word, so the root and every reply are updated together.
800
+ */
801
+ async resolveComment(commentId, options = {}) {
802
+ const sourceBytes = this.originalBytes;
803
+ const working = cloneEntries(this.entries);
804
+ const resolved = options.resolved !== false;
805
+ const noChange = extra => {
806
+ const buf = toBufferCompatible(sourceBytes);
807
+ return {
808
+ status: 'ok', hasChanges: false, written: false, artifactsChanged: [], ...extra,
809
+ uint8Array: sourceBytes, toUint8Array: () => new Uint8Array(sourceBytes), buffer: buf, toBuffer: () => toBufferCompatible(sourceBytes)
810
+ };
811
+ };
812
+
813
+ const result = applyThreadResolutionToParts({
814
+ commentsXml: text(working, 'word/comments.xml'),
815
+ commentsExtendedXml: text(working, 'word/commentsExtended.xml'),
816
+ commentId,
817
+ resolved
818
+ });
819
+ if (result.status === 'error') return packageFailure(sourceBytes, result.error.code, result.error.message);
820
+ if (!result.hasChanges) return noChange({ resolved, threadRootId: result.threadRootId, commentIds: result.commentIds });
821
+
822
+ const zip = new MemoryZip(working);
823
+ try {
824
+ if (result.commentsXml) working.set('word/comments.xml', textEncoder.encode(result.commentsXml));
825
+ await ensureCommentsExtendedArtifactsInZip(zip, result.commentsExtendedXml, { replaceExisting: true });
826
+ await repairKnownContentTypes(zip);
827
+ if (options.validate !== false) await validateDocxPackage(zip);
828
+ } catch (error) {
829
+ return packageFailure(sourceBytes, 'PACKAGE_VALIDATION', error.message);
830
+ }
831
+
832
+ const artifactsChanged = [...working]
833
+ .filter(([name, data]) => !this.entries.has(name) || !areByteArraysEqual(data, this.entries.get(name)))
834
+ .map(([name]) => name);
835
+ this.entries = working;
836
+ const outputBytes = this.toUint8Array();
837
+ this.originalBytes = outputBytes;
838
+ return {
839
+ status: 'ok', hasChanges: true, written: true, resolved,
840
+ threadRootId: result.threadRootId, commentIds: result.commentIds, artifactsChanged,
841
+ uint8Array: outputBytes, toUint8Array: () => new Uint8Array(outputBytes),
842
+ buffer: toBufferCompatible(outputBytes), toBuffer: () => toBufferCompatible(outputBytes)
843
+ };
844
+ }
845
+
846
+ async deleteComments(options = {}) {
847
+ const sourceBytes = this.originalBytes;
848
+ const working = cloneEntries(this.entries);
849
+ const commentsXml = text(working, 'word/comments.xml');
850
+ const buf = toBufferCompatible(sourceBytes);
851
+
852
+ if (!commentsXml) {
853
+ return {
854
+ status: 'ok',
855
+ hasChanges: false,
856
+ written: false,
857
+ commentsRemoved: 0,
858
+ referencesRemoved: 0,
859
+ artifactsChanged: [],
860
+ uint8Array: sourceBytes,
861
+ toUint8Array: () => new Uint8Array(sourceBytes),
862
+ buffer: buf,
863
+ toBuffer: () => toBufferCompatible(sourceBytes)
864
+ };
865
+ }
866
+
867
+ const parsed = parseOoxmlSafe(commentsXml, 'application/xml');
868
+ if (!parsed.doc || parsed.error) {
869
+ return packageFailure(sourceBytes, 'PARSE_ERROR', parsed.error?.message || 'Could not parse comments.xml.');
870
+ }
871
+
872
+ // `ids` deletes specific comments (a thread root also takes its replies); otherwise filter by author.
873
+ const requestedIds = Array.isArray(options.ids) ? new Set(options.ids.map(String)) : null;
874
+ if (requestedIds) {
875
+ const known = new Set(Array.from(parsed.doc.getElementsByTagNameNS('*', 'comment')).map(node => node.getAttribute('w:id') || node.getAttribute('id')));
876
+ const missing = [...requestedIds].filter(id => !known.has(id));
877
+ if (missing.length) return packageFailure(sourceBytes, 'COMMENT_NOT_FOUND', `Comment id(s) not found: ${missing.join(', ')}.`);
878
+ }
879
+ const matches = comment => (requestedIds
880
+ ? requestedIds.has(comment.getAttribute('w:id') || comment.getAttribute('id'))
881
+ : options.allAuthors === true || (comment.getAttribute('w:author') || comment.getAttribute('author')) === options.author);
882
+ const ids = new Set(
883
+ Array.from(parsed.doc.getElementsByTagNameNS('*', 'comment'))
884
+ .filter(matches)
885
+ .map(node => node.getAttribute('w:id') || node.getAttribute('id'))
886
+ .filter(Boolean)
887
+ );
888
+
889
+ if (!ids.size) {
890
+ return {
891
+ status: 'ok',
892
+ hasChanges: false,
893
+ written: false,
894
+ commentsRemoved: 0,
895
+ referencesRemoved: 0,
896
+ artifactsChanged: [],
897
+ uint8Array: sourceBytes,
898
+ toUint8Array: () => new Uint8Array(sourceBytes),
899
+ buffer: buf,
900
+ toBuffer: () => toBufferCompatible(sourceBytes)
901
+ };
902
+ }
903
+
904
+ const commentsExtendedXml = text(working, 'word/commentsExtended.xml');
905
+ let extendedParsed = null;
906
+ const removedParaIds = new Set();
907
+
908
+ if (commentsExtendedXml) {
909
+ extendedParsed = parseOoxmlSafe(commentsExtendedXml, 'application/xml');
910
+ if (!extendedParsed.doc || extendedParsed.error) {
911
+ return packageFailure(sourceBytes, 'PARSE_ERROR', extendedParsed.error?.message || 'Could not parse commentsExtended.xml.');
912
+ }
913
+ const idByParaId = new Map();
914
+ for (const comment of Array.from(parsed.doc.getElementsByTagNameNS('*', 'comment'))) {
915
+ const id = comment.getAttribute('w:id') || comment.getAttribute('id');
916
+ const paraId = commentParaId(comment);
917
+ if (paraId) idByParaId.set(paraId, id);
918
+ }
919
+ for (const [paraId, id] of idByParaId) {
920
+ if (ids.has(id)) removedParaIds.add(paraId);
921
+ }
922
+ let expanded = true;
923
+ while (expanded) {
924
+ expanded = false;
925
+ for (const entry of Array.from(extendedParsed.doc.getElementsByTagNameNS('*', 'commentEx'))) {
926
+ const paraId = (entry.getAttribute('w15:paraId') || entry.getAttribute('paraId') || '').toUpperCase();
927
+ const parentParaId = (entry.getAttribute('w15:paraIdParent') || entry.getAttribute('paraIdParent') || '').toUpperCase();
928
+ if (parentParaId && removedParaIds.has(parentParaId) && !removedParaIds.has(paraId)) {
929
+ removedParaIds.add(paraId);
930
+ if (idByParaId.has(paraId)) ids.add(idByParaId.get(paraId));
931
+ expanded = true;
932
+ }
933
+ }
934
+ }
935
+ }
936
+
937
+ const remainingXml = requestedIds
938
+ ? commentsXml
939
+ : deleteCommentsByAuthorInOoxml(commentsXml, { author: options.author, allAuthors: options.allAuthors === true }).oxml;
940
+ const remainingParsed = parseOoxmlSafe(remainingXml, 'application/xml');
941
+ if (!remainingParsed.doc || remainingParsed.error) {
942
+ return packageFailure(sourceBytes, 'PARSE_ERROR', remainingParsed.error?.message || 'Could not parse updated comments.xml.');
943
+ }
944
+
945
+ for (const comment of Array.from(remainingParsed.doc.getElementsByTagNameNS('*', 'comment'))) {
946
+ const id = comment.getAttribute('w:id') || comment.getAttribute('id');
947
+ if (ids.has(id)) comment.parentNode?.removeChild(comment);
948
+ }
949
+
950
+ const documentParsed = parseOoxmlSafe(text(working, 'word/document.xml'), 'application/xml');
951
+ if (!documentParsed.doc || documentParsed.error) {
952
+ return packageFailure(sourceBytes, 'PARSE_ERROR', documentParsed.error?.message || 'Could not parse document.xml.');
953
+ }
954
+
955
+ let referencesRemoved = 0;
956
+ for (const name of ['commentRangeStart', 'commentRangeEnd', 'commentReference']) {
957
+ for (const node of Array.from(documentParsed.doc.getElementsByTagNameNS('*', name))) {
958
+ const id = node.getAttribute('w:id') || node.getAttribute('id');
959
+ if (!ids.has(id) || !node.parentNode) continue;
960
+ const parent = node.parentNode;
961
+ parent.removeChild(node);
962
+ referencesRemoved += 1;
963
+ if (name === 'commentReference' && parent.localName === 'r' && !Array.from(parent.childNodes || []).some(child => child.nodeType === 1 && child.localName !== 'rPr')) {
964
+ parent.parentNode?.removeChild(parent);
965
+ }
966
+ }
967
+ }
968
+
969
+ const serializer = createSerializer();
970
+ working.set('word/comments.xml', textEncoder.encode(serializer.serializeToString(remainingParsed.doc)));
971
+
972
+ if (extendedParsed?.doc) {
973
+ for (const entry of Array.from(extendedParsed.doc.getElementsByTagNameNS('*', 'commentEx'))) {
974
+ const paraId = (entry.getAttribute('w15:paraId') || entry.getAttribute('paraId') || '').toUpperCase();
975
+ if (removedParaIds.has(paraId)) entry.parentNode?.removeChild(entry);
976
+ }
977
+ working.set('word/commentsExtended.xml', textEncoder.encode(serializer.serializeToString(extendedParsed.doc)));
978
+ }
979
+
980
+ working.set('word/document.xml', textEncoder.encode(serializer.serializeToString(documentParsed.doc)));
981
+
982
+ try {
983
+ reconcileCommentSiblings(working);
984
+ const finalZip = new MemoryZip(working);
985
+ await repairKnownContentTypes(finalZip);
986
+ if (options.validate !== false) await validateDocxPackage(finalZip);
987
+ } catch (error) {
988
+ return packageFailure(sourceBytes, 'PACKAGE_VALIDATION', error.message);
989
+ }
990
+
991
+ const artifactsChanged = [...working]
992
+ .filter(([name, data]) => !this.entries.has(name) || !areByteArraysEqual(data, this.entries.get(name)))
993
+ .map(([name]) => name);
994
+ this.entries = working;
995
+ const outputBytes = this.toUint8Array();
996
+ this.originalBytes = outputBytes;
997
+ const outputBuf = toBufferCompatible(outputBytes);
998
+
999
+ return {
1000
+ status: 'ok',
1001
+ hasChanges: true,
1002
+ written: true,
1003
+ commentsRemoved: ids.size,
1004
+ referencesRemoved,
1005
+ artifactsChanged,
1006
+ uint8Array: outputBytes,
1007
+ toUint8Array: () => new Uint8Array(outputBytes),
1008
+ buffer: outputBuf,
1009
+ toBuffer: () => toBufferCompatible(outputBytes)
1010
+ };
1011
+ }
1012
+ }
1013
+
1014
+ /**
1015
+ * Opens a DOCX document package from a byte array or Buffer.
1016
+ *
1017
+ * @param {Uint8Array|ArrayBuffer|ArrayBufferView|string} input - Binary DOCX archive
1018
+ * @returns {DocxDocument}
1019
+ */
1020
+ export function openDocx(input) {
1021
+ return new DocxDocument(input);
1022
+ }