@ansonlai/docx-redline-js 0.7.1 → 0.8.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.
- package/AGENTS.md +38 -49
- package/ARCHITECTURE.md +30 -10
- package/CHANGELOG.md +63 -3
- package/README.md +35 -23
- package/adapters/xml-adapter.js +84 -56
- package/core/sha256.js +120 -0
- package/dist/docx-redline-js.esm.js +7969 -600
- package/dist/docx-redline-js.esm.js.map +4 -4
- package/dist/docx-redline-js.esm.min.js +115 -102
- package/dist/docx-redline-js.esm.min.js.map +4 -4
- package/dist/docx-redline.bundle.cjs +27385 -0
- package/dist/docx-redline.bundle.cjs.map +7 -0
- package/dist/docx-redline.bundle.js +27376 -0
- package/dist/docx-redline.bundle.js.map +7 -0
- package/docs/AGENT_FAST_START.md +32 -34
- package/docs/AGENT_KNOWLEDGE_BASE.md +408 -380
- package/docs/SKILL_AUTHORING.md +85 -101
- package/docs/TESTING.md +2 -1
- package/docs/validation-reports/2026-09-14-agent-surface-simplification.md +75 -0
- package/document/docx-document.d.ts +39 -0
- package/document/docx-document.js +616 -0
- package/document/zip-archive.js +258 -0
- package/index.d.ts +45 -1
- package/index.js +8 -0
- package/node/cli-help.js +1 -1
- package/node/cli.js +12 -4
- package/node/docx-document.js +11 -355
- package/node/zip-archive.js +28 -38
- package/package.json +16 -11
- package/scripts/build.mjs +40 -5
- package/services/localized-replacement-compiler.js +73 -7
- package/services/revision-token.js +11 -7
- package/services/standalone-operation-runner.d.ts +1 -0
|
@@ -0,0 +1,616 @@
|
|
|
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 { inspectDocumentParts } from '../services/document-inspection.js';
|
|
11
|
+
import { applyOperationsToDocumentXml, preflightOperations } from '../services/standalone-operation-runner.js';
|
|
12
|
+
import { createDynamicNumberingIdState, mergeNumberingXmlBySchemaOrder } from '../services/numbering-helpers.js';
|
|
13
|
+
import { ensureCommentsArtifactsInZip, ensureCommentsExtendedArtifactsInZip, ensureNumberingArtifactsInZip, validateDocxPackage } from '../services/standalone-docx-plumbing.js';
|
|
14
|
+
import { validateRedlineOoxml } from '../core/redline-validation.js';
|
|
15
|
+
import { subtractValidationIssueMultiset, validationErrors } from '../core/validation-delta.js';
|
|
16
|
+
import { acceptTrackedChangesInOoxml, rejectTrackedChangesInOoxml, deleteCommentsByAuthorInOoxml } from '../services/revision-comment-management.js';
|
|
17
|
+
import { createSerializer, parseOoxmlSafe } from '../adapters/xml-adapter.js';
|
|
18
|
+
import { sha256 } from '../core/sha256.js';
|
|
19
|
+
import { MemoryZip, unzipDocx, zipDocx, toUint8Array } from './zip-archive.js';
|
|
20
|
+
import { computeRevisionTokenSync, validateRevisionToken, areRevisionTokensEqual } from '../services/revision-token.js';
|
|
21
|
+
import { createRetryPlan, normalizeErrorWithRecovery } from '../services/error-recovery.js';
|
|
22
|
+
|
|
23
|
+
const textEncoder = new TextEncoder();
|
|
24
|
+
const textDecoder = new TextDecoder('utf-8');
|
|
25
|
+
|
|
26
|
+
const text = (entries, path) => {
|
|
27
|
+
const entry = entries.get(path);
|
|
28
|
+
if (!entry) return null;
|
|
29
|
+
if (typeof entry === 'string') return entry;
|
|
30
|
+
return textDecoder.decode(entry);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const cloneEntries = entries => new Map([...entries].map(([name, data]) => [name, new Uint8Array(data)]));
|
|
34
|
+
|
|
35
|
+
function toBufferCompatible(uint8Arr) {
|
|
36
|
+
if (typeof Buffer !== 'undefined' && typeof Buffer.from === 'function') {
|
|
37
|
+
return Buffer.from(uint8Arr.buffer, uint8Arr.byteOffset, uint8Arr.byteLength);
|
|
38
|
+
}
|
|
39
|
+
return uint8Arr;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function areByteArraysEqual(a, b) {
|
|
43
|
+
if (a === b) return true;
|
|
44
|
+
if (!a || !b) return false;
|
|
45
|
+
if (a.length !== b.length) return false;
|
|
46
|
+
for (let i = 0; i < a.length; i++) {
|
|
47
|
+
if (a[i] !== b[i]) return false;
|
|
48
|
+
}
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function rolledBackOperationPayload(operationResult, operationCount) {
|
|
53
|
+
const rollbackReceipt = receipt => {
|
|
54
|
+
if (!receipt || typeof receipt !== 'object') return receipt;
|
|
55
|
+
if (receipt.attemptedDisposition !== 'applied' && receipt.committed !== true) return { ...receipt };
|
|
56
|
+
return { ...receipt, committed: false, finalDisposition: 'rolled_back' };
|
|
57
|
+
};
|
|
58
|
+
const receipts = (operationResult?.receipts || []).map(rollbackReceipt);
|
|
59
|
+
const receiptByIndex = new Map(receipts.map(receipt => [receipt.operationIndex, receipt]));
|
|
60
|
+
const results = (operationResult?.results || []).map(result => ({
|
|
61
|
+
...result,
|
|
62
|
+
...(result.receipt ? {
|
|
63
|
+
receipt: receiptByIndex.get(result.index) || rollbackReceipt(result.receipt)
|
|
64
|
+
} : {})
|
|
65
|
+
}));
|
|
66
|
+
return {
|
|
67
|
+
results,
|
|
68
|
+
receipts,
|
|
69
|
+
executionOrder: operationResult?.executionOrder || [],
|
|
70
|
+
authorsUsed: [],
|
|
71
|
+
rolledBack: true,
|
|
72
|
+
retryPlan: createRetryPlan({
|
|
73
|
+
atomic: true,
|
|
74
|
+
rolledBack: true,
|
|
75
|
+
results,
|
|
76
|
+
receipts,
|
|
77
|
+
operationCount
|
|
78
|
+
})
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Computes a package-scoped revision token over all uncompressed entries in a DOCX archive.
|
|
84
|
+
*
|
|
85
|
+
* @param {Uint8Array|ArrayBuffer|Map<string, Uint8Array>|DocxDocument|object} input
|
|
86
|
+
* @returns {{ algorithm: 'sha256', version: number, scope: 'package', value: string, coveredParts: string[] }}
|
|
87
|
+
*/
|
|
88
|
+
export function computePackageRevisionToken(input) {
|
|
89
|
+
let entries;
|
|
90
|
+
if (input instanceof Uint8Array || (typeof Buffer !== 'undefined' && Buffer.isBuffer?.(input))) {
|
|
91
|
+
entries = unzipDocx(input);
|
|
92
|
+
} else if (input instanceof Map) {
|
|
93
|
+
entries = input;
|
|
94
|
+
} else if (input?.entries instanceof Map) {
|
|
95
|
+
entries = input.entries;
|
|
96
|
+
} else if (typeof input?.toUint8Array === 'function') {
|
|
97
|
+
entries = unzipDocx(input.toUint8Array());
|
|
98
|
+
} else if (typeof input?.toBuffer === 'function') {
|
|
99
|
+
entries = unzipDocx(input.toBuffer());
|
|
100
|
+
} else if (input instanceof ArrayBuffer || ArrayBuffer.isView(input)) {
|
|
101
|
+
entries = unzipDocx(toUint8Array(input));
|
|
102
|
+
} else {
|
|
103
|
+
throw new TypeError('computePackageRevisionToken requires a Uint8Array, Buffer, Map of entries, or DocxDocument.');
|
|
104
|
+
}
|
|
105
|
+
return computeRevisionTokenSync({
|
|
106
|
+
scope: 'package',
|
|
107
|
+
entries
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function nextCommentId(entries) {
|
|
112
|
+
const ids = `${text(entries, 'word/document.xml') || ''} ${text(entries, 'word/comments.xml') || ''}`.match(/(?:w:)?id=["'](\d+)["']/g) || [];
|
|
113
|
+
let next = ids.reduce((max, token) => Math.max(max, Number(token.match(/\d+/)?.[0] || 0)), 0) + 1;
|
|
114
|
+
return () => next++;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function existingCommentDetails(entries) {
|
|
118
|
+
const commentsXml = text(entries, 'word/comments.xml');
|
|
119
|
+
if (!commentsXml) return {};
|
|
120
|
+
const parsed = parseOoxmlSafe(commentsXml, 'application/xml');
|
|
121
|
+
if (!parsed.doc || parsed.error) return {};
|
|
122
|
+
const details = {};
|
|
123
|
+
for (const comment of Array.from(parsed.doc.getElementsByTagNameNS('*', 'comment'))) {
|
|
124
|
+
const id = comment.getAttribute('w:id') || comment.getAttribute('id');
|
|
125
|
+
if (id === '') continue;
|
|
126
|
+
details[id] = {
|
|
127
|
+
id,
|
|
128
|
+
author: comment.getAttribute('w:author') || comment.getAttribute('author') || '',
|
|
129
|
+
text: String(comment.textContent || '').trim(),
|
|
130
|
+
paraId: Array.from(comment.getElementsByTagNameNS('*', 'p'))[0]?.getAttribute('w14:paraId') || null
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
return details;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function packageFailure(sourceBytes, code, message) {
|
|
137
|
+
const buf = toBufferCompatible(sourceBytes);
|
|
138
|
+
return {
|
|
139
|
+
status: 'error',
|
|
140
|
+
hasChanges: false,
|
|
141
|
+
written: false,
|
|
142
|
+
rolledBack: true,
|
|
143
|
+
error: normalizeErrorWithRecovery({ code, message }),
|
|
144
|
+
retryPlan: createRetryPlan({ atomic: true, rolledBack: true }),
|
|
145
|
+
artifactsChanged: [],
|
|
146
|
+
uint8Array: sourceBytes,
|
|
147
|
+
toUint8Array: () => new Uint8Array(sourceBytes),
|
|
148
|
+
buffer: buf,
|
|
149
|
+
toBuffer: () => toBufferCompatible(sourceBytes)
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Universal DOCX document representation.
|
|
155
|
+
*/
|
|
156
|
+
export class DocxDocument {
|
|
157
|
+
constructor(input) {
|
|
158
|
+
this.originalBytes = toUint8Array(input);
|
|
159
|
+
this.entries = unzipDocx(this.originalBytes);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
get originalBuffer() {
|
|
163
|
+
return toBufferCompatible(this.originalBytes);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
inspect(options = {}) {
|
|
167
|
+
const digestFn = options.digestFn || (bytes => sha256(bytes));
|
|
168
|
+
return inspectDocumentParts({
|
|
169
|
+
documentXml: text(this.entries, 'word/document.xml'),
|
|
170
|
+
commentsXml: text(this.entries, 'word/comments.xml'),
|
|
171
|
+
commentsExtendedXml: text(this.entries, 'word/commentsExtended.xml'),
|
|
172
|
+
numberingXml: text(this.entries, 'word/numbering.xml'),
|
|
173
|
+
stylesXml: text(this.entries, 'word/styles.xml')
|
|
174
|
+
}, { ...options, digestFn });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
getRevisionToken() {
|
|
178
|
+
return computePackageRevisionToken(this.entries);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
get revisionToken() {
|
|
182
|
+
return this.getRevisionToken();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
preflight(operations, author = getDefaultAuthor(), options = {}) {
|
|
186
|
+
return preflightOperations(
|
|
187
|
+
text(this.entries, 'word/document.xml'),
|
|
188
|
+
operations,
|
|
189
|
+
author || getDefaultAuthor(),
|
|
190
|
+
{ ...options, _existingCommentDetails: existingCommentDetails(this.entries) }
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
toUint8Array() {
|
|
195
|
+
return zipDocx(this.entries);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
toBuffer() {
|
|
199
|
+
return toBufferCompatible(this.toUint8Array());
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async applyOperations(operations, options = {}) {
|
|
203
|
+
const failedApply = error => {
|
|
204
|
+
const results = [];
|
|
205
|
+
const receipts = [];
|
|
206
|
+
const buf = toBufferCompatible(this.originalBytes);
|
|
207
|
+
return {
|
|
208
|
+
status: 'error',
|
|
209
|
+
hasChanges: false,
|
|
210
|
+
written: false,
|
|
211
|
+
rolledBack: true,
|
|
212
|
+
results,
|
|
213
|
+
receipts,
|
|
214
|
+
artifactsChanged: [],
|
|
215
|
+
error: normalizeErrorWithRecovery(error),
|
|
216
|
+
retryPlan: createRetryPlan({
|
|
217
|
+
atomic: true,
|
|
218
|
+
rolledBack: true,
|
|
219
|
+
results,
|
|
220
|
+
receipts,
|
|
221
|
+
operationCount: Array.isArray(operations) ? operations.length : 0
|
|
222
|
+
}),
|
|
223
|
+
validation: { originalIssues: [], generatedIssues: [] },
|
|
224
|
+
uint8Array: this.originalBytes,
|
|
225
|
+
toUint8Array: () => new Uint8Array(this.originalBytes),
|
|
226
|
+
buffer: buf,
|
|
227
|
+
toBuffer: () => toBufferCompatible(this.originalBytes)
|
|
228
|
+
};
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
if (options?.expectedRevision) {
|
|
232
|
+
const tokenValidation = validateRevisionToken(options.expectedRevision);
|
|
233
|
+
if (!tokenValidation.valid) {
|
|
234
|
+
return failedApply({
|
|
235
|
+
code: tokenValidation.error?.code || 'INVALID_REVISION_TOKEN',
|
|
236
|
+
message: tokenValidation.error?.message || 'Invalid revision token.'
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
if (options.expectedRevision.scope !== 'package') {
|
|
240
|
+
return failedApply({
|
|
241
|
+
code: 'REVISION_TOKEN_SCOPE_MISMATCH',
|
|
242
|
+
message: `Revision token scope mismatch: expected 'package', got '${options.expectedRevision.scope}'.`,
|
|
243
|
+
expectedScope: 'package',
|
|
244
|
+
actualScope: options.expectedRevision.scope
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
const currentToken = computePackageRevisionToken(this.entries);
|
|
248
|
+
if (!areRevisionTokensEqual(currentToken.value, options.expectedRevision.value)) {
|
|
249
|
+
return failedApply({
|
|
250
|
+
code: 'REVISION_MISMATCH',
|
|
251
|
+
message: `Document revision mismatch: expected '${options.expectedRevision.value}', current is '${currentToken.value}'.`,
|
|
252
|
+
expectedRevision: options.expectedRevision,
|
|
253
|
+
currentRevision: currentToken
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const originalEntries = this.entries;
|
|
259
|
+
const working = cloneEntries(originalEntries);
|
|
260
|
+
const zip = new MemoryZip(working);
|
|
261
|
+
const documentXml = text(working, 'word/document.xml');
|
|
262
|
+
let originalIssues = [];
|
|
263
|
+
let operationResult = null;
|
|
264
|
+
|
|
265
|
+
try {
|
|
266
|
+
if (!documentXml) throw new Error('Missing word/document.xml.');
|
|
267
|
+
const baseline = validateRedlineOoxml(documentXml);
|
|
268
|
+
originalIssues = baseline.issues.map(issue => ({ source: 'word/document.xml', ...issue }));
|
|
269
|
+
try {
|
|
270
|
+
await validateDocxPackage(new MemoryZip(cloneEntries(originalEntries)));
|
|
271
|
+
} catch (error) {
|
|
272
|
+
originalIssues.push({ source: 'package', code: 'PACKAGE_VALIDATION', severity: 'error', message: error.message });
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const context = {
|
|
276
|
+
numberingIdState: createDynamicNumberingIdState(text(working, 'word/numbering.xml') || undefined),
|
|
277
|
+
commentsXml: text(working, 'word/comments.xml'),
|
|
278
|
+
commentsExtendedXml: text(working, 'word/commentsExtended.xml')
|
|
279
|
+
};
|
|
280
|
+
const { expectedRevision: _pkgExpectedRevision, ...runnerOptions } = options;
|
|
281
|
+
const author = options.author || getDefaultAuthor();
|
|
282
|
+
const result = operationResult = await applyOperationsToDocumentXml(documentXml, operations, author, context, {
|
|
283
|
+
...runnerOptions,
|
|
284
|
+
atomic: options.atomic === true,
|
|
285
|
+
strictTargets: options.strictTargets !== false,
|
|
286
|
+
_existingCommentDetails: existingCommentDetails(working),
|
|
287
|
+
commentIdAllocator: nextCommentId(working)
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
if (result.rolledBack || result.status === 'error') {
|
|
291
|
+
const buf = toBufferCompatible(this.originalBytes);
|
|
292
|
+
return {
|
|
293
|
+
...result,
|
|
294
|
+
...rolledBackOperationPayload(result, Array.isArray(operations) ? operations.length : 0),
|
|
295
|
+
written: false,
|
|
296
|
+
artifactsChanged: [],
|
|
297
|
+
validation: { originalIssues, generatedIssues: [] },
|
|
298
|
+
uint8Array: this.originalBytes,
|
|
299
|
+
toUint8Array: () => new Uint8Array(this.originalBytes),
|
|
300
|
+
buffer: buf,
|
|
301
|
+
toBuffer: () => toBufferCompatible(this.originalBytes)
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (!result.hasChanges) {
|
|
306
|
+
const buf = toBufferCompatible(this.originalBytes);
|
|
307
|
+
return {
|
|
308
|
+
...result,
|
|
309
|
+
status: result.status || 'ok',
|
|
310
|
+
written: false,
|
|
311
|
+
artifactsChanged: [],
|
|
312
|
+
validation: { originalIssues, generatedIssues: [] },
|
|
313
|
+
uint8Array: this.originalBytes,
|
|
314
|
+
toUint8Array: () => new Uint8Array(this.originalBytes),
|
|
315
|
+
buffer: buf,
|
|
316
|
+
toBuffer: () => toBufferCompatible(this.originalBytes)
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
working.set('word/document.xml', textEncoder.encode(result.documentXml));
|
|
321
|
+
await ensureNumberingArtifactsInZip(zip, result.numberingXmlParts, { mergeNumberingXml: mergeNumberingXmlBySchemaOrder });
|
|
322
|
+
|
|
323
|
+
const existingCommentsXml = text(working, 'word/comments.xml');
|
|
324
|
+
const commentsXmlForPackaging = result.commentsXml || existingCommentsXml;
|
|
325
|
+
await ensureCommentsArtifactsInZip(zip, commentsXmlForPackaging, {
|
|
326
|
+
replaceExisting: result.commentsXmlMode === 'replace' || (!result.commentsXml && !!existingCommentsXml)
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
const existingCommentsExtendedXml = text(working, 'word/commentsExtended.xml');
|
|
330
|
+
const commentsExtendedXmlForPackaging = result.commentsExtendedXml || existingCommentsExtendedXml;
|
|
331
|
+
await ensureCommentsExtendedArtifactsInZip(zip, commentsExtendedXmlForPackaging, {
|
|
332
|
+
replaceExisting: result.commentsExtendedXmlMode === 'replace' || (!result.commentsExtendedXml && !!existingCommentsExtendedXml)
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
if (options.validate !== false) {
|
|
336
|
+
const generated = validateRedlineOoxml(result.documentXml);
|
|
337
|
+
const outputIssues = generated.issues.map(issue => ({ source: 'word/document.xml', ...issue }));
|
|
338
|
+
try {
|
|
339
|
+
await validateDocxPackage(zip);
|
|
340
|
+
} catch (error) {
|
|
341
|
+
outputIssues.push({ source: 'package', code: 'PACKAGE_VALIDATION', severity: 'error', message: error.message });
|
|
342
|
+
}
|
|
343
|
+
const introduced = subtractValidationIssueMultiset(outputIssues, originalIssues);
|
|
344
|
+
const introducedErrors = validationErrors(introduced);
|
|
345
|
+
if (introducedErrors.length) {
|
|
346
|
+
const codes = [...new Set(introducedErrors.map(issue => issue.code))].join(', ');
|
|
347
|
+
throw Object.assign(
|
|
348
|
+
new Error(`Applied operations introduced invalid revision markup (${codes}); these are generated-output issues, not pre-existing input issues.`),
|
|
349
|
+
{ issues: introducedErrors }
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
this.entries = working;
|
|
355
|
+
const outputBytes = this.toUint8Array();
|
|
356
|
+
this.originalBytes = outputBytes;
|
|
357
|
+
const outputBuf = toBufferCompatible(outputBytes);
|
|
358
|
+
|
|
359
|
+
const artifactsChanged = [...working]
|
|
360
|
+
.filter(([name, data]) => !originalEntries.has(name) || !areByteArraysEqual(data, originalEntries.get(name)))
|
|
361
|
+
.map(([name]) => name);
|
|
362
|
+
|
|
363
|
+
return {
|
|
364
|
+
...result,
|
|
365
|
+
status: result.status || 'ok',
|
|
366
|
+
written: true,
|
|
367
|
+
artifactsChanged,
|
|
368
|
+
validation: { originalIssues, generatedIssues: [] },
|
|
369
|
+
uint8Array: outputBytes,
|
|
370
|
+
toUint8Array: () => new Uint8Array(outputBytes),
|
|
371
|
+
buffer: outputBuf,
|
|
372
|
+
inspection: this.inspect(),
|
|
373
|
+
toBuffer: () => toBufferCompatible(outputBytes)
|
|
374
|
+
};
|
|
375
|
+
} catch (error) {
|
|
376
|
+
this.entries = originalEntries;
|
|
377
|
+
const generatedIssues = error.issues || [{ source: 'package', code: 'PACKAGE_OPERATION_FAILED', severity: 'error', message: error.message }];
|
|
378
|
+
const rollbackPayload = rolledBackOperationPayload(
|
|
379
|
+
operationResult,
|
|
380
|
+
Array.isArray(operations) ? operations.length : 0
|
|
381
|
+
);
|
|
382
|
+
const buf = toBufferCompatible(this.originalBytes);
|
|
383
|
+
return {
|
|
384
|
+
...rollbackPayload,
|
|
385
|
+
status: 'error',
|
|
386
|
+
hasChanges: false,
|
|
387
|
+
written: false,
|
|
388
|
+
artifactsChanged: [],
|
|
389
|
+
error: normalizeErrorWithRecovery({
|
|
390
|
+
code: 'PACKAGE_OPERATION_FAILED',
|
|
391
|
+
message: error.message,
|
|
392
|
+
stage: 'package',
|
|
393
|
+
issues: generatedIssues
|
|
394
|
+
}),
|
|
395
|
+
validation: { originalIssues, generatedIssues },
|
|
396
|
+
issues: generatedIssues,
|
|
397
|
+
uint8Array: this.originalBytes,
|
|
398
|
+
toUint8Array: () => new Uint8Array(this.originalBytes),
|
|
399
|
+
buffer: buf,
|
|
400
|
+
toBuffer: () => toBufferCompatible(this.originalBytes)
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
async resolveRevisions(action, options = {}) {
|
|
406
|
+
const transform = action === 'accept' ? acceptTrackedChangesInOoxml : action === 'reject' ? rejectTrackedChangesInOoxml : null;
|
|
407
|
+
if (!transform) return packageFailure(this.originalBytes, 'INVALID_ACTION', `Unknown revision action: ${action}`);
|
|
408
|
+
|
|
409
|
+
const sourceBytes = this.originalBytes;
|
|
410
|
+
const working = cloneEntries(this.entries);
|
|
411
|
+
const zip = new MemoryZip(working);
|
|
412
|
+
const result = transform(text(working, 'word/document.xml'), { author: options.author, allAuthors: options.allAuthors === true });
|
|
413
|
+
|
|
414
|
+
if (result.status === 'error' || result.error) {
|
|
415
|
+
return packageFailure(sourceBytes, result.error?.code || 'REVISION_OPERATION_FAILED', result.error?.message || 'Revision operation failed.');
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if (!result.hasChanges) {
|
|
419
|
+
const buf = toBufferCompatible(sourceBytes);
|
|
420
|
+
return {
|
|
421
|
+
...result,
|
|
422
|
+
status: 'ok',
|
|
423
|
+
written: false,
|
|
424
|
+
artifactsChanged: [],
|
|
425
|
+
uint8Array: sourceBytes,
|
|
426
|
+
toUint8Array: () => new Uint8Array(sourceBytes),
|
|
427
|
+
buffer: buf,
|
|
428
|
+
toBuffer: () => toBufferCompatible(sourceBytes)
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
working.set('word/document.xml', textEncoder.encode(result.oxml));
|
|
433
|
+
try {
|
|
434
|
+
if (options.validate !== false) await validateDocxPackage(zip);
|
|
435
|
+
} catch (error) {
|
|
436
|
+
return packageFailure(sourceBytes, 'PACKAGE_VALIDATION', error.message);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
this.entries = working;
|
|
440
|
+
const outputBytes = this.toUint8Array();
|
|
441
|
+
this.originalBytes = outputBytes;
|
|
442
|
+
const outputBuf = toBufferCompatible(outputBytes);
|
|
443
|
+
|
|
444
|
+
return {
|
|
445
|
+
...result,
|
|
446
|
+
status: 'ok',
|
|
447
|
+
written: true,
|
|
448
|
+
artifactsChanged: ['word/document.xml'],
|
|
449
|
+
uint8Array: outputBytes,
|
|
450
|
+
toUint8Array: () => new Uint8Array(outputBytes),
|
|
451
|
+
buffer: outputBuf,
|
|
452
|
+
toBuffer: () => toBufferCompatible(outputBytes)
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
async deleteComments(options = {}) {
|
|
457
|
+
const sourceBytes = this.originalBytes;
|
|
458
|
+
const working = cloneEntries(this.entries);
|
|
459
|
+
const commentsXml = text(working, 'word/comments.xml');
|
|
460
|
+
const buf = toBufferCompatible(sourceBytes);
|
|
461
|
+
|
|
462
|
+
if (!commentsXml) {
|
|
463
|
+
return {
|
|
464
|
+
status: 'ok',
|
|
465
|
+
hasChanges: false,
|
|
466
|
+
written: false,
|
|
467
|
+
commentsRemoved: 0,
|
|
468
|
+
referencesRemoved: 0,
|
|
469
|
+
artifactsChanged: [],
|
|
470
|
+
uint8Array: sourceBytes,
|
|
471
|
+
toUint8Array: () => new Uint8Array(sourceBytes),
|
|
472
|
+
buffer: buf,
|
|
473
|
+
toBuffer: () => toBufferCompatible(sourceBytes)
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const parsed = parseOoxmlSafe(commentsXml, 'application/xml');
|
|
478
|
+
if (!parsed.doc || parsed.error) {
|
|
479
|
+
return packageFailure(sourceBytes, 'PARSE_ERROR', parsed.error?.message || 'Could not parse comments.xml.');
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const matches = comment => options.allAuthors === true || (comment.getAttribute('w:author') || comment.getAttribute('author')) === options.author;
|
|
483
|
+
const ids = new Set(
|
|
484
|
+
Array.from(parsed.doc.getElementsByTagNameNS('*', 'comment'))
|
|
485
|
+
.filter(matches)
|
|
486
|
+
.map(node => node.getAttribute('w:id') || node.getAttribute('id'))
|
|
487
|
+
.filter(Boolean)
|
|
488
|
+
);
|
|
489
|
+
|
|
490
|
+
if (!ids.size) {
|
|
491
|
+
return {
|
|
492
|
+
status: 'ok',
|
|
493
|
+
hasChanges: false,
|
|
494
|
+
written: false,
|
|
495
|
+
commentsRemoved: 0,
|
|
496
|
+
referencesRemoved: 0,
|
|
497
|
+
artifactsChanged: [],
|
|
498
|
+
uint8Array: sourceBytes,
|
|
499
|
+
toUint8Array: () => new Uint8Array(sourceBytes),
|
|
500
|
+
buffer: buf,
|
|
501
|
+
toBuffer: () => toBufferCompatible(sourceBytes)
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const commentsExtendedXml = text(working, 'word/commentsExtended.xml');
|
|
506
|
+
let extendedParsed = null;
|
|
507
|
+
const removedParaIds = new Set();
|
|
508
|
+
|
|
509
|
+
if (commentsExtendedXml) {
|
|
510
|
+
extendedParsed = parseOoxmlSafe(commentsExtendedXml, 'application/xml');
|
|
511
|
+
if (!extendedParsed.doc || extendedParsed.error) {
|
|
512
|
+
return packageFailure(sourceBytes, 'PARSE_ERROR', extendedParsed.error?.message || 'Could not parse commentsExtended.xml.');
|
|
513
|
+
}
|
|
514
|
+
const idByParaId = new Map();
|
|
515
|
+
for (const comment of Array.from(parsed.doc.getElementsByTagNameNS('*', 'comment'))) {
|
|
516
|
+
const id = comment.getAttribute('w:id') || comment.getAttribute('id');
|
|
517
|
+
const paragraph = Array.from(comment.getElementsByTagNameNS('*', 'p'))[0];
|
|
518
|
+
const paraId = paragraph?.getAttribute('w14:paraId') || paragraph?.getAttribute('paraId');
|
|
519
|
+
if (paraId) idByParaId.set(paraId.toUpperCase(), id);
|
|
520
|
+
}
|
|
521
|
+
for (const [paraId, id] of idByParaId) {
|
|
522
|
+
if (ids.has(id)) removedParaIds.add(paraId);
|
|
523
|
+
}
|
|
524
|
+
let expanded = true;
|
|
525
|
+
while (expanded) {
|
|
526
|
+
expanded = false;
|
|
527
|
+
for (const entry of Array.from(extendedParsed.doc.getElementsByTagNameNS('*', 'commentEx'))) {
|
|
528
|
+
const paraId = (entry.getAttribute('w15:paraId') || entry.getAttribute('paraId') || '').toUpperCase();
|
|
529
|
+
const parentParaId = (entry.getAttribute('w15:paraIdParent') || entry.getAttribute('paraIdParent') || '').toUpperCase();
|
|
530
|
+
if (parentParaId && removedParaIds.has(parentParaId) && !removedParaIds.has(paraId)) {
|
|
531
|
+
removedParaIds.add(paraId);
|
|
532
|
+
if (idByParaId.has(paraId)) ids.add(idByParaId.get(paraId));
|
|
533
|
+
expanded = true;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const commentsResult = deleteCommentsByAuthorInOoxml(commentsXml, { author: options.author, allAuthors: options.allAuthors === true });
|
|
540
|
+
const remainingParsed = parseOoxmlSafe(commentsResult.oxml, 'application/xml');
|
|
541
|
+
if (!remainingParsed.doc || remainingParsed.error) {
|
|
542
|
+
return packageFailure(sourceBytes, 'PARSE_ERROR', remainingParsed.error?.message || 'Could not parse updated comments.xml.');
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
for (const comment of Array.from(remainingParsed.doc.getElementsByTagNameNS('*', 'comment'))) {
|
|
546
|
+
const id = comment.getAttribute('w:id') || comment.getAttribute('id');
|
|
547
|
+
if (ids.has(id)) comment.parentNode?.removeChild(comment);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const documentParsed = parseOoxmlSafe(text(working, 'word/document.xml'), 'application/xml');
|
|
551
|
+
if (!documentParsed.doc || documentParsed.error) {
|
|
552
|
+
return packageFailure(sourceBytes, 'PARSE_ERROR', documentParsed.error?.message || 'Could not parse document.xml.');
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
let referencesRemoved = 0;
|
|
556
|
+
for (const name of ['commentRangeStart', 'commentRangeEnd', 'commentReference']) {
|
|
557
|
+
for (const node of Array.from(documentParsed.doc.getElementsByTagNameNS('*', name))) {
|
|
558
|
+
const id = node.getAttribute('w:id') || node.getAttribute('id');
|
|
559
|
+
if (!ids.has(id) || !node.parentNode) continue;
|
|
560
|
+
const parent = node.parentNode;
|
|
561
|
+
parent.removeChild(node);
|
|
562
|
+
referencesRemoved += 1;
|
|
563
|
+
if (name === 'commentReference' && parent.localName === 'r' && !Array.from(parent.childNodes || []).some(child => child.nodeType === 1 && child.localName !== 'rPr')) {
|
|
564
|
+
parent.parentNode?.removeChild(parent);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
const serializer = createSerializer();
|
|
570
|
+
working.set('word/comments.xml', textEncoder.encode(serializer.serializeToString(remainingParsed.doc)));
|
|
571
|
+
|
|
572
|
+
if (extendedParsed?.doc) {
|
|
573
|
+
for (const entry of Array.from(extendedParsed.doc.getElementsByTagNameNS('*', 'commentEx'))) {
|
|
574
|
+
const paraId = (entry.getAttribute('w15:paraId') || entry.getAttribute('paraId') || '').toUpperCase();
|
|
575
|
+
if (removedParaIds.has(paraId)) entry.parentNode?.removeChild(entry);
|
|
576
|
+
}
|
|
577
|
+
working.set('word/commentsExtended.xml', textEncoder.encode(serializer.serializeToString(extendedParsed.doc)));
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
working.set('word/document.xml', textEncoder.encode(serializer.serializeToString(documentParsed.doc)));
|
|
581
|
+
|
|
582
|
+
try {
|
|
583
|
+
if (options.validate !== false) await validateDocxPackage(new MemoryZip(working));
|
|
584
|
+
} catch (error) {
|
|
585
|
+
return packageFailure(sourceBytes, 'PACKAGE_VALIDATION', error.message);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
this.entries = working;
|
|
589
|
+
const outputBytes = this.toUint8Array();
|
|
590
|
+
this.originalBytes = outputBytes;
|
|
591
|
+
const outputBuf = toBufferCompatible(outputBytes);
|
|
592
|
+
|
|
593
|
+
return {
|
|
594
|
+
status: 'ok',
|
|
595
|
+
hasChanges: true,
|
|
596
|
+
written: true,
|
|
597
|
+
commentsRemoved: ids.size,
|
|
598
|
+
referencesRemoved,
|
|
599
|
+
artifactsChanged: ['word/document.xml', 'word/comments.xml', ...(extendedParsed?.doc ? ['word/commentsExtended.xml'] : [])],
|
|
600
|
+
uint8Array: outputBytes,
|
|
601
|
+
toUint8Array: () => new Uint8Array(outputBytes),
|
|
602
|
+
buffer: outputBuf,
|
|
603
|
+
toBuffer: () => toBufferCompatible(outputBytes)
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* Opens a DOCX document package from a byte array or Buffer.
|
|
610
|
+
*
|
|
611
|
+
* @param {Uint8Array|ArrayBuffer|ArrayBufferView|string} input - Binary DOCX archive
|
|
612
|
+
* @returns {DocxDocument}
|
|
613
|
+
*/
|
|
614
|
+
export function openDocx(input) {
|
|
615
|
+
return new DocxDocument(input);
|
|
616
|
+
}
|