@ansonlai/docx-redline-js 0.7.2 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +15 -10
- package/ARCHITECTURE.md +30 -10
- package/CHANGELOG.md +539 -431
- package/README.md +331 -309
- package/adapters/xml-adapter.js +84 -56
- package/core/sha256.js +120 -0
- package/dist/docx-redline-js.esm.js +8982 -693
- package/dist/docx-redline-js.esm.js.map +4 -4
- package/dist/docx-redline-js.esm.min.js +102 -89
- package/dist/docx-redline-js.esm.min.js.map +4 -4
- package/dist/docx-redline.bundle.cjs +28305 -0
- package/dist/docx-redline.bundle.cjs.map +7 -0
- package/dist/docx-redline.bundle.js +28296 -0
- package/dist/docx-redline.bundle.js.map +7 -0
- package/docs/AGENT_FAST_START.md +2 -1
- package/docs/AGENT_KNOWLEDGE_BASE.md +480 -393
- package/docs/SKILL_AUTHORING.md +5 -2
- package/docs/TESTING.md +2 -1
- package/docs/schemas/document-operations.schema.json +29 -0
- package/document/docx-document.d.ts +39 -0
- package/document/docx-document.js +1010 -0
- package/document/zip-archive.js +258 -0
- package/engine/reconstruction-writer.js +18 -0
- package/index.d.ts +598 -531
- package/index.js +349 -340
- package/node/cli-help.js +5 -5
- package/node/cli.js +760 -754
- package/node/docx-document.js +11 -355
- package/node/index.d.ts +1 -1
- package/node/zip-archive.js +28 -38
- package/package.json +14 -11
- package/scripts/build.mjs +40 -5
- package/scripts/generate-word-comment-edge-case-fixture.ps1 +58 -0
- package/scripts/generate-word-comment-thread-fixture.ps1 +36 -0
- package/scripts/generate-word-header-footer-fixture.ps1 +32 -0
- package/scripts/generate-word-multi-section-fixture.ps1 +27 -0
- package/scripts/generate-word-package-integrity-cases.mjs +141 -0
- package/scripts/generate-word-thread-state-fixtures.ps1 +50 -0
- package/scripts/word-com-package-integrity.ps1 +98 -0
- package/services/batch-operation-orchestrator.js +262 -259
- package/services/comment-replies.js +96 -95
- package/services/comment-thread-parts.js +280 -0
- package/services/document-inspection.js +366 -365
- package/services/document-operation-applier.js +230 -202
- package/services/document-operation-contract.js +17 -2
- package/services/error-recovery.js +6 -0
- package/services/headers-footers.js +206 -0
- package/services/operation-batch-compiler.js +1 -0
- package/services/operation-preflight.js +231 -218
- package/services/package-parts.js +80 -0
- package/services/revision-token.js +11 -7
- package/services/standalone-docx-plumbing.js +631 -569
- package/services/standalone-operation-runner.d.ts +19 -3
package/node/docx-document.js
CHANGED
|
@@ -1,356 +1,12 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
import { ensureCommentsArtifactsInZip, ensureCommentsExtendedArtifactsInZip, ensureNumberingArtifactsInZip, validateDocxPackage } from '../services/standalone-docx-plumbing.js';
|
|
8
|
-
import { validateRedlineOoxml } from '../core/redline-validation.js';
|
|
9
|
-
import { subtractValidationIssueMultiset, validationErrors } from '../core/validation-delta.js';
|
|
10
|
-
import { acceptTrackedChangesInOoxml, rejectTrackedChangesInOoxml, deleteCommentsByAuthorInOoxml } from '../services/revision-comment-management.js';
|
|
11
|
-
import { createSerializer, parseOoxmlSafe } from '../adapters/xml-adapter.js';
|
|
12
|
-
import { createHash } from 'node:crypto';
|
|
13
|
-
import { MemoryZip, unzipDocx, zipDocx } from './zip-archive.js';
|
|
14
|
-
import { computeRevisionTokenSync, validateRevisionToken, areRevisionTokensEqual } from '../services/revision-token.js';
|
|
15
|
-
import { createRetryPlan, normalizeErrorWithRecovery } from '../services/error-recovery.js';
|
|
16
|
-
|
|
17
|
-
configureXmlProvider({ DOMParser, XMLSerializer });
|
|
18
|
-
const text = (entries, path) => entries.get(path)?.toString('utf8') || null;
|
|
19
|
-
const cloneEntries = entries => new Map([...entries].map(([name, data]) => [name, Buffer.from(data)]));
|
|
1
|
+
/**
|
|
2
|
+
* Node.js compatibility layer for DocxDocument.
|
|
3
|
+
*
|
|
4
|
+
* Re-exports the universal DocxDocument and openDocx implementations from
|
|
5
|
+
* document/docx-document.js for downstream callers importing @ansonlai/docx-redline-js/node.
|
|
6
|
+
*/
|
|
20
7
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
};
|
|
27
|
-
const receipts = (operationResult?.receipts || []).map(rollbackReceipt);
|
|
28
|
-
const receiptByIndex = new Map(receipts.map(receipt => [receipt.operationIndex, receipt]));
|
|
29
|
-
const results = (operationResult?.results || []).map(result => ({
|
|
30
|
-
...result,
|
|
31
|
-
...(result.receipt ? {
|
|
32
|
-
receipt: receiptByIndex.get(result.index) || rollbackReceipt(result.receipt)
|
|
33
|
-
} : {})
|
|
34
|
-
}));
|
|
35
|
-
return {
|
|
36
|
-
results,
|
|
37
|
-
receipts,
|
|
38
|
-
executionOrder: operationResult?.executionOrder || [],
|
|
39
|
-
authorsUsed: [],
|
|
40
|
-
rolledBack: true,
|
|
41
|
-
retryPlan: createRetryPlan({
|
|
42
|
-
atomic: true,
|
|
43
|
-
rolledBack: true,
|
|
44
|
-
results,
|
|
45
|
-
receipts,
|
|
46
|
-
operationCount
|
|
47
|
-
})
|
|
48
|
-
};
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Computes a package-scoped revision token over all uncompressed entries in a DOCX archive.
|
|
53
|
-
*
|
|
54
|
-
* @param {Buffer|Uint8Array|Map<string, Buffer>|DocxDocument|object} input
|
|
55
|
-
* @returns {{ algorithm: 'sha256', version: number, scope: 'package', value: string, coveredParts: string[] }}
|
|
56
|
-
*/
|
|
57
|
-
export function computePackageRevisionToken(input) {
|
|
58
|
-
let entries;
|
|
59
|
-
if (Buffer.isBuffer(input) || input instanceof Uint8Array) {
|
|
60
|
-
entries = unzipDocx(input);
|
|
61
|
-
} else if (input instanceof Map) {
|
|
62
|
-
entries = input;
|
|
63
|
-
} else if (input?.entries instanceof Map) {
|
|
64
|
-
entries = input.entries;
|
|
65
|
-
} else if (typeof input?.toBuffer === 'function') {
|
|
66
|
-
entries = unzipDocx(input.toBuffer());
|
|
67
|
-
} else {
|
|
68
|
-
throw new TypeError('computePackageRevisionToken requires a Buffer, Uint8Array, Map of entries, or DocxDocument.');
|
|
69
|
-
}
|
|
70
|
-
return computeRevisionTokenSync({
|
|
71
|
-
scope: 'package',
|
|
72
|
-
entries,
|
|
73
|
-
digestFn: bytes => createHash('sha256').update(bytes).digest('hex')
|
|
74
|
-
});
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function nextCommentId(entries) {
|
|
78
|
-
const ids = `${text(entries, 'word/document.xml') || ''} ${text(entries, 'word/comments.xml') || ''}`.match(/(?:w:)?id=["'](\d+)["']/g) || [];
|
|
79
|
-
let next = ids.reduce((max, token) => Math.max(max, Number(token.match(/\d+/)?.[0] || 0)), 0) + 1;
|
|
80
|
-
return () => next++;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function existingCommentDetails(entries) {
|
|
84
|
-
const commentsXml = text(entries, 'word/comments.xml');
|
|
85
|
-
if (!commentsXml) return {};
|
|
86
|
-
const parsed = parseOoxmlSafe(commentsXml, 'application/xml');
|
|
87
|
-
if (!parsed.doc || parsed.error) return {};
|
|
88
|
-
const details = {};
|
|
89
|
-
for (const comment of Array.from(parsed.doc.getElementsByTagNameNS('*', 'comment'))) {
|
|
90
|
-
const id = comment.getAttribute('w:id') || comment.getAttribute('id');
|
|
91
|
-
if (id === '') continue;
|
|
92
|
-
details[id] = {
|
|
93
|
-
id,
|
|
94
|
-
author: comment.getAttribute('w:author') || comment.getAttribute('author') || '',
|
|
95
|
-
text: String(comment.textContent || '').trim(),
|
|
96
|
-
paraId: Array.from(comment.getElementsByTagNameNS('*', 'p'))[0]?.getAttribute('w14:paraId') || null
|
|
97
|
-
};
|
|
98
|
-
}
|
|
99
|
-
return details;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
export class DocxDocument {
|
|
103
|
-
constructor(buffer) { this.originalBuffer = Buffer.from(buffer); this.entries = unzipDocx(this.originalBuffer); }
|
|
104
|
-
inspect(options = {}) {
|
|
105
|
-
const digestFn = options.digestFn || (bytes => createHash('sha256').update(bytes).digest('hex'));
|
|
106
|
-
return inspectDocumentParts({
|
|
107
|
-
documentXml: text(this.entries, 'word/document.xml'),
|
|
108
|
-
commentsXml: text(this.entries, 'word/comments.xml'),
|
|
109
|
-
commentsExtendedXml: text(this.entries, 'word/commentsExtended.xml'),
|
|
110
|
-
numberingXml: text(this.entries, 'word/numbering.xml'),
|
|
111
|
-
stylesXml: text(this.entries, 'word/styles.xml')
|
|
112
|
-
}, { ...options, digestFn });
|
|
113
|
-
}
|
|
114
|
-
getRevisionToken() { return computePackageRevisionToken(this.entries); }
|
|
115
|
-
get revisionToken() { return this.getRevisionToken(); }
|
|
116
|
-
preflight(operations, author = getDefaultAuthor(), options = {}) { return preflightOperations(text(this.entries, 'word/document.xml'), operations, author || getDefaultAuthor(), { ...options, _existingCommentDetails: existingCommentDetails(this.entries) }); }
|
|
117
|
-
toBuffer() { return zipDocx(this.entries); }
|
|
118
|
-
async applyOperations(operations, options = {}) {
|
|
119
|
-
const failedApply = error => {
|
|
120
|
-
const results = [];
|
|
121
|
-
const receipts = [];
|
|
122
|
-
return {
|
|
123
|
-
status: 'error',
|
|
124
|
-
hasChanges: false,
|
|
125
|
-
written: false,
|
|
126
|
-
rolledBack: true,
|
|
127
|
-
results,
|
|
128
|
-
receipts,
|
|
129
|
-
artifactsChanged: [],
|
|
130
|
-
error: normalizeErrorWithRecovery(error),
|
|
131
|
-
retryPlan: createRetryPlan({
|
|
132
|
-
atomic: true,
|
|
133
|
-
rolledBack: true,
|
|
134
|
-
results,
|
|
135
|
-
receipts,
|
|
136
|
-
operationCount: Array.isArray(operations) ? operations.length : 0
|
|
137
|
-
}),
|
|
138
|
-
validation: { originalIssues: [], generatedIssues: [] },
|
|
139
|
-
buffer: Buffer.from(this.originalBuffer),
|
|
140
|
-
toBuffer: () => Buffer.from(this.originalBuffer)
|
|
141
|
-
};
|
|
142
|
-
};
|
|
143
|
-
if (options?.expectedRevision) {
|
|
144
|
-
const tokenValidation = validateRevisionToken(options.expectedRevision);
|
|
145
|
-
if (!tokenValidation.valid) {
|
|
146
|
-
return failedApply({
|
|
147
|
-
code: tokenValidation.error?.code || 'INVALID_REVISION_TOKEN',
|
|
148
|
-
message: tokenValidation.error?.message || 'Invalid revision token.'
|
|
149
|
-
});
|
|
150
|
-
}
|
|
151
|
-
if (options.expectedRevision.scope !== 'package') {
|
|
152
|
-
return failedApply({
|
|
153
|
-
code: 'REVISION_TOKEN_SCOPE_MISMATCH',
|
|
154
|
-
message: `Revision token scope mismatch: expected 'package', got '${options.expectedRevision.scope}'.`,
|
|
155
|
-
expectedScope: 'package',
|
|
156
|
-
actualScope: options.expectedRevision.scope
|
|
157
|
-
});
|
|
158
|
-
}
|
|
159
|
-
const currentToken = computePackageRevisionToken(this.entries);
|
|
160
|
-
if (!areRevisionTokensEqual(currentToken.value, options.expectedRevision.value)) {
|
|
161
|
-
return failedApply({
|
|
162
|
-
code: 'REVISION_MISMATCH',
|
|
163
|
-
message: `Document revision mismatch: expected '${options.expectedRevision.value}', current is '${currentToken.value}'.`,
|
|
164
|
-
expectedRevision: options.expectedRevision,
|
|
165
|
-
currentRevision: currentToken
|
|
166
|
-
});
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
const originalEntries = this.entries; const working = cloneEntries(originalEntries); const zip = new MemoryZip(working);
|
|
171
|
-
const documentXml = text(working, 'word/document.xml');
|
|
172
|
-
let originalIssues = [];
|
|
173
|
-
let operationResult = null;
|
|
174
|
-
try {
|
|
175
|
-
if (!documentXml) throw new Error('Missing word/document.xml.');
|
|
176
|
-
const baseline = validateRedlineOoxml(documentXml);
|
|
177
|
-
originalIssues = baseline.issues.map(issue => ({ source: 'word/document.xml', ...issue }));
|
|
178
|
-
try { await validateDocxPackage(new MemoryZip(cloneEntries(originalEntries))); }
|
|
179
|
-
catch (error) { originalIssues.push({ source: 'package', code: 'PACKAGE_VALIDATION', severity: 'error', message: error.message }); }
|
|
180
|
-
const context = {
|
|
181
|
-
numberingIdState: createDynamicNumberingIdState(text(working, 'word/numbering.xml') || undefined),
|
|
182
|
-
commentsXml: text(working, 'word/comments.xml'),
|
|
183
|
-
commentsExtendedXml: text(working, 'word/commentsExtended.xml')
|
|
184
|
-
};
|
|
185
|
-
const { expectedRevision: _pkgExpectedRevision, ...runnerOptions } = options;
|
|
186
|
-
const author = options.author || getDefaultAuthor();
|
|
187
|
-
const result = operationResult = await applyOperationsToDocumentXml(documentXml, operations, author, context, {
|
|
188
|
-
...runnerOptions, atomic: options.atomic === true, strictTargets: options.strictTargets !== false,
|
|
189
|
-
_existingCommentDetails: existingCommentDetails(working),
|
|
190
|
-
commentIdAllocator: nextCommentId(working)
|
|
191
|
-
});
|
|
192
|
-
if (result.rolledBack || result.status === 'error') {
|
|
193
|
-
return {
|
|
194
|
-
...result,
|
|
195
|
-
...rolledBackOperationPayload(result, Array.isArray(operations) ? operations.length : 0),
|
|
196
|
-
written: false,
|
|
197
|
-
artifactsChanged: [],
|
|
198
|
-
validation: { originalIssues, generatedIssues: [] },
|
|
199
|
-
buffer: this.originalBuffer,
|
|
200
|
-
toBuffer: () => Buffer.from(this.originalBuffer)
|
|
201
|
-
};
|
|
202
|
-
}
|
|
203
|
-
if (!result.hasChanges) return { ...result, status: result.status || 'ok', written: false, artifactsChanged: [], validation: { originalIssues, generatedIssues: [] }, buffer: Buffer.from(this.originalBuffer), toBuffer: () => Buffer.from(this.originalBuffer) };
|
|
204
|
-
working.set('word/document.xml', Buffer.from(result.documentXml));
|
|
205
|
-
await ensureNumberingArtifactsInZip(zip, result.numberingXmlParts, { mergeNumberingXml: mergeNumberingXmlBySchemaOrder });
|
|
206
|
-
const existingCommentsXml = text(working, 'word/comments.xml');
|
|
207
|
-
const commentsXmlForPackaging = result.commentsXml || existingCommentsXml;
|
|
208
|
-
await ensureCommentsArtifactsInZip(zip, commentsXmlForPackaging, {
|
|
209
|
-
replaceExisting: result.commentsXmlMode === 'replace' || (!result.commentsXml && !!existingCommentsXml)
|
|
210
|
-
});
|
|
211
|
-
const existingCommentsExtendedXml = text(working, 'word/commentsExtended.xml');
|
|
212
|
-
const commentsExtendedXmlForPackaging = result.commentsExtendedXml || existingCommentsExtendedXml;
|
|
213
|
-
await ensureCommentsExtendedArtifactsInZip(zip, commentsExtendedXmlForPackaging, {
|
|
214
|
-
replaceExisting: result.commentsExtendedXmlMode === 'replace' || (!result.commentsExtendedXml && !!existingCommentsExtendedXml)
|
|
215
|
-
});
|
|
216
|
-
if (options.validate !== false) {
|
|
217
|
-
const generated = validateRedlineOoxml(result.documentXml);
|
|
218
|
-
const outputIssues = generated.issues.map(issue => ({ source: 'word/document.xml', ...issue }));
|
|
219
|
-
try { await validateDocxPackage(zip); }
|
|
220
|
-
catch (error) { outputIssues.push({ source: 'package', code: 'PACKAGE_VALIDATION', severity: 'error', message: error.message }); }
|
|
221
|
-
const introduced = subtractValidationIssueMultiset(outputIssues, originalIssues);
|
|
222
|
-
const introducedErrors = validationErrors(introduced);
|
|
223
|
-
if (introducedErrors.length) {
|
|
224
|
-
const codes = [...new Set(introducedErrors.map(issue => issue.code))].join(', ');
|
|
225
|
-
throw Object.assign(
|
|
226
|
-
new Error(`Applied operations introduced invalid revision markup (${codes}); these are generated-output issues, not pre-existing input issues.`),
|
|
227
|
-
{ issues: introducedErrors }
|
|
228
|
-
);
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
this.entries = working;
|
|
232
|
-
const output = this.toBuffer();
|
|
233
|
-
this.originalBuffer = Buffer.from(output);
|
|
234
|
-
const artifactsChanged = [...working].filter(([name, data]) => !originalEntries.has(name) || !data.equals(originalEntries.get(name))).map(([name]) => name);
|
|
235
|
-
return { ...result, status: result.status || 'ok', written: true, artifactsChanged, validation: { originalIssues, generatedIssues: [] }, buffer: output, inspection: this.inspect(), toBuffer: () => Buffer.from(output) };
|
|
236
|
-
} catch (error) {
|
|
237
|
-
this.entries = originalEntries;
|
|
238
|
-
const generatedIssues = error.issues || [{ source: 'package', code: 'PACKAGE_OPERATION_FAILED', severity: 'error', message: error.message }];
|
|
239
|
-
const rollbackPayload = rolledBackOperationPayload(
|
|
240
|
-
operationResult,
|
|
241
|
-
Array.isArray(operations) ? operations.length : 0
|
|
242
|
-
);
|
|
243
|
-
return {
|
|
244
|
-
...rollbackPayload,
|
|
245
|
-
status: 'error',
|
|
246
|
-
hasChanges: false,
|
|
247
|
-
written: false,
|
|
248
|
-
artifactsChanged: [],
|
|
249
|
-
error: normalizeErrorWithRecovery({
|
|
250
|
-
code: 'PACKAGE_OPERATION_FAILED',
|
|
251
|
-
message: error.message,
|
|
252
|
-
stage: 'package',
|
|
253
|
-
issues: generatedIssues
|
|
254
|
-
}),
|
|
255
|
-
validation: { originalIssues, generatedIssues },
|
|
256
|
-
issues: generatedIssues,
|
|
257
|
-
buffer: Buffer.from(this.originalBuffer),
|
|
258
|
-
toBuffer: () => Buffer.from(this.originalBuffer)
|
|
259
|
-
};
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
async resolveRevisions(action, options = {}) {
|
|
264
|
-
const transform = action === 'accept' ? acceptTrackedChangesInOoxml : action === 'reject' ? rejectTrackedChangesInOoxml : null;
|
|
265
|
-
if (!transform) return packageFailure(this.originalBuffer, 'INVALID_ACTION', `Unknown revision action: ${action}`);
|
|
266
|
-
const source = Buffer.from(this.originalBuffer); const working = cloneEntries(this.entries); const zip = new MemoryZip(working);
|
|
267
|
-
const result = transform(text(working, 'word/document.xml'), { author: options.author, allAuthors: options.allAuthors === true });
|
|
268
|
-
if (result.status === 'error' || result.error) return packageFailure(source, result.error?.code || 'REVISION_OPERATION_FAILED', result.error?.message || 'Revision operation failed.');
|
|
269
|
-
if (!result.hasChanges) return { ...result, status: 'ok', written: false, artifactsChanged: [], buffer: source, toBuffer: () => Buffer.from(source) };
|
|
270
|
-
working.set('word/document.xml', Buffer.from(result.oxml));
|
|
271
|
-
try { if (options.validate !== false) await validateDocxPackage(zip); }
|
|
272
|
-
catch (error) { return packageFailure(source, 'PACKAGE_VALIDATION', error.message); }
|
|
273
|
-
this.entries = working; const output = this.toBuffer(); this.originalBuffer = Buffer.from(output);
|
|
274
|
-
return { ...result, status: 'ok', written: true, artifactsChanged: ['word/document.xml'], buffer: output, toBuffer: () => Buffer.from(output) };
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
async deleteComments(options = {}) {
|
|
278
|
-
const source = Buffer.from(this.originalBuffer); const working = cloneEntries(this.entries); const commentsXml = text(working, 'word/comments.xml');
|
|
279
|
-
if (!commentsXml) return { status: 'ok', hasChanges: false, written: false, commentsRemoved: 0, referencesRemoved: 0, artifactsChanged: [], buffer: source, toBuffer: () => Buffer.from(source) };
|
|
280
|
-
const parsed = parseOoxmlSafe(commentsXml, 'application/xml');
|
|
281
|
-
if (!parsed.doc || parsed.error) return packageFailure(source, 'PARSE_ERROR', parsed.error?.message || 'Could not parse comments.xml.');
|
|
282
|
-
const matches = comment => options.allAuthors === true || (comment.getAttribute('w:author') || comment.getAttribute('author')) === options.author;
|
|
283
|
-
const ids = new Set(Array.from(parsed.doc.getElementsByTagNameNS('*', 'comment')).filter(matches).map(node => node.getAttribute('w:id') || node.getAttribute('id')).filter(Boolean));
|
|
284
|
-
if (!ids.size) return { status: 'ok', hasChanges: false, written: false, commentsRemoved: 0, referencesRemoved: 0, artifactsChanged: [], buffer: source, toBuffer: () => Buffer.from(source) };
|
|
285
|
-
const commentsExtendedXml = text(working, 'word/commentsExtended.xml');
|
|
286
|
-
let extendedParsed = null;
|
|
287
|
-
const removedParaIds = new Set();
|
|
288
|
-
if (commentsExtendedXml) {
|
|
289
|
-
extendedParsed = parseOoxmlSafe(commentsExtendedXml, 'application/xml');
|
|
290
|
-
if (!extendedParsed.doc || extendedParsed.error) return packageFailure(source, 'PARSE_ERROR', extendedParsed.error?.message || 'Could not parse commentsExtended.xml.');
|
|
291
|
-
const idByParaId = new Map();
|
|
292
|
-
for (const comment of Array.from(parsed.doc.getElementsByTagNameNS('*', 'comment'))) {
|
|
293
|
-
const id = comment.getAttribute('w:id') || comment.getAttribute('id');
|
|
294
|
-
const paragraph = Array.from(comment.getElementsByTagNameNS('*', 'p'))[0];
|
|
295
|
-
const paraId = paragraph?.getAttribute('w14:paraId') || paragraph?.getAttribute('paraId');
|
|
296
|
-
if (paraId) idByParaId.set(paraId.toUpperCase(), id);
|
|
297
|
-
}
|
|
298
|
-
for (const [paraId, id] of idByParaId) if (ids.has(id)) removedParaIds.add(paraId);
|
|
299
|
-
let expanded = true;
|
|
300
|
-
while (expanded) {
|
|
301
|
-
expanded = false;
|
|
302
|
-
for (const entry of Array.from(extendedParsed.doc.getElementsByTagNameNS('*', 'commentEx'))) {
|
|
303
|
-
const paraId = (entry.getAttribute('w15:paraId') || entry.getAttribute('paraId') || '').toUpperCase();
|
|
304
|
-
const parentParaId = (entry.getAttribute('w15:paraIdParent') || entry.getAttribute('paraIdParent') || '').toUpperCase();
|
|
305
|
-
if (parentParaId && removedParaIds.has(parentParaId) && !removedParaIds.has(paraId)) {
|
|
306
|
-
removedParaIds.add(paraId); if (idByParaId.has(paraId)) ids.add(idByParaId.get(paraId)); expanded = true;
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
const commentsResult = deleteCommentsByAuthorInOoxml(commentsXml, { author: options.author, allAuthors: options.allAuthors === true });
|
|
312
|
-
const remainingParsed = parseOoxmlSafe(commentsResult.oxml, 'application/xml');
|
|
313
|
-
if (!remainingParsed.doc || remainingParsed.error) return packageFailure(source, 'PARSE_ERROR', remainingParsed.error?.message || 'Could not parse updated comments.xml.');
|
|
314
|
-
for (const comment of Array.from(remainingParsed.doc.getElementsByTagNameNS('*', 'comment'))) {
|
|
315
|
-
const id = comment.getAttribute('w:id') || comment.getAttribute('id');
|
|
316
|
-
if (ids.has(id)) comment.parentNode?.removeChild(comment);
|
|
317
|
-
}
|
|
318
|
-
const documentParsed = parseOoxmlSafe(text(working, 'word/document.xml'), 'application/xml');
|
|
319
|
-
if (!documentParsed.doc || documentParsed.error) return packageFailure(source, 'PARSE_ERROR', documentParsed.error?.message || 'Could not parse document.xml.');
|
|
320
|
-
let referencesRemoved = 0;
|
|
321
|
-
for (const name of ['commentRangeStart', 'commentRangeEnd', 'commentReference']) for (const node of Array.from(documentParsed.doc.getElementsByTagNameNS('*', name))) {
|
|
322
|
-
const id = node.getAttribute('w:id') || node.getAttribute('id'); if (!ids.has(id) || !node.parentNode) continue;
|
|
323
|
-
const parent = node.parentNode; parent.removeChild(node); referencesRemoved += 1;
|
|
324
|
-
if (name === 'commentReference' && parent.localName === 'r' && !Array.from(parent.childNodes || []).some(child => child.nodeType === 1 && child.localName !== 'rPr')) parent.parentNode?.removeChild(parent);
|
|
325
|
-
}
|
|
326
|
-
const serializer = createSerializer();
|
|
327
|
-
working.set('word/comments.xml', Buffer.from(serializer.serializeToString(remainingParsed.doc)));
|
|
328
|
-
if (extendedParsed?.doc) {
|
|
329
|
-
for (const entry of Array.from(extendedParsed.doc.getElementsByTagNameNS('*', 'commentEx'))) {
|
|
330
|
-
const paraId = (entry.getAttribute('w15:paraId') || entry.getAttribute('paraId') || '').toUpperCase();
|
|
331
|
-
if (removedParaIds.has(paraId)) entry.parentNode?.removeChild(entry);
|
|
332
|
-
}
|
|
333
|
-
working.set('word/commentsExtended.xml', Buffer.from(serializer.serializeToString(extendedParsed.doc)));
|
|
334
|
-
}
|
|
335
|
-
working.set('word/document.xml', Buffer.from(serializer.serializeToString(documentParsed.doc)));
|
|
336
|
-
try { if (options.validate !== false) await validateDocxPackage(new MemoryZip(working)); }
|
|
337
|
-
catch (error) { return packageFailure(source, 'PACKAGE_VALIDATION', error.message); }
|
|
338
|
-
this.entries = working; const output = this.toBuffer(); this.originalBuffer = Buffer.from(output);
|
|
339
|
-
return { status: 'ok', hasChanges: true, written: true, commentsRemoved: ids.size, referencesRemoved, artifactsChanged: ['word/document.xml', 'word/comments.xml', ...(extendedParsed?.doc ? ['word/commentsExtended.xml'] : [])], buffer: output, toBuffer: () => Buffer.from(output) };
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
function packageFailure(source, code, message) {
|
|
344
|
-
return {
|
|
345
|
-
status: 'error',
|
|
346
|
-
hasChanges: false,
|
|
347
|
-
written: false,
|
|
348
|
-
rolledBack: true,
|
|
349
|
-
error: normalizeErrorWithRecovery({ code, message }),
|
|
350
|
-
retryPlan: createRetryPlan({ atomic: true, rolledBack: true }),
|
|
351
|
-
artifactsChanged: [],
|
|
352
|
-
buffer: Buffer.from(source),
|
|
353
|
-
toBuffer: () => Buffer.from(source)
|
|
354
|
-
};
|
|
355
|
-
}
|
|
356
|
-
export function openDocx(input) { return new DocxDocument(input); }
|
|
8
|
+
export {
|
|
9
|
+
DocxDocument,
|
|
10
|
+
openDocx,
|
|
11
|
+
computePackageRevisionToken
|
|
12
|
+
} from '../document/docx-document.js';
|
package/node/index.d.ts
CHANGED
|
@@ -22,7 +22,7 @@ export class DocxDocument {
|
|
|
22
22
|
preflight(operations: DocumentOperation[], author?: string, options?: StandaloneRunnerOptions): OperationPreflightResult;
|
|
23
23
|
applyOperations(operations: DocumentOperation[], options?: DocxApplyOptions): Promise<DocxApplyResult>;
|
|
24
24
|
resolveRevisions(action: 'accept' | 'reject', options: { author?: string; allAuthors?: boolean; validate?: boolean }): Promise<DocxApplyResult>;
|
|
25
|
-
deleteComments(options: { author?: string; allAuthors?: boolean; validate?: boolean }): Promise<DocxApplyResult>;
|
|
25
|
+
deleteComments(options: { author?: string; allAuthors?: boolean; ids?: Array<string | number>; validate?: boolean }): Promise<DocxApplyResult>;
|
|
26
26
|
toBuffer(): Uint8Array;
|
|
27
27
|
}
|
|
28
28
|
export function computePackageRevisionToken(input: unknown): RevisionToken;
|
package/node/zip-archive.js
CHANGED
|
@@ -1,52 +1,42 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Node.js compatibility layer for ZIP archives in DOCX packages.
|
|
3
|
+
*
|
|
4
|
+
* Delegates compression and decompression to the universal pure-JS implementation
|
|
5
|
+
* in document/zip-archive.js while returning Node.js Buffers for backward compatibility.
|
|
6
|
+
*/
|
|
2
7
|
|
|
3
|
-
|
|
4
|
-
|
|
8
|
+
import {
|
|
9
|
+
unzipDocx as universalUnzipDocx,
|
|
10
|
+
zipDocx as universalZipDocx
|
|
11
|
+
} from '../document/zip-archive.js';
|
|
5
12
|
|
|
6
13
|
export function unzipDocx(input) {
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
for (
|
|
10
|
-
|
|
11
|
-
const count = source.readUInt16LE(eocd + 10); const centralOffset = source.readUInt32LE(eocd + 16);
|
|
12
|
-
if (count === 0xffff || centralOffset === 0xffffffff) throw new Error('Unsupported DOCX: ZIP64 archives are not supported.');
|
|
13
|
-
const entries = new Map(); let offset = centralOffset;
|
|
14
|
-
for (let index = 0; index < count; index++) {
|
|
15
|
-
if (source.readUInt32LE(offset) !== 0x02014b50) throw new Error('Invalid DOCX: central directory is corrupt.');
|
|
16
|
-
const flags = source.readUInt16LE(offset + 8); const method = source.readUInt16LE(offset + 10);
|
|
17
|
-
if (flags & 1) throw new Error('Unsupported DOCX: encrypted ZIP entries are not supported.');
|
|
18
|
-
const compressedSize = source.readUInt32LE(offset + 20); const nameLength = source.readUInt16LE(offset + 28);
|
|
19
|
-
const extraLength = source.readUInt16LE(offset + 30); const commentLength = source.readUInt16LE(offset + 32); const localOffset = source.readUInt32LE(offset + 42);
|
|
20
|
-
const name = source.toString('utf8', offset + 46, offset + 46 + nameLength);
|
|
21
|
-
if (source.readUInt32LE(localOffset) !== 0x04034b50) throw new Error('Invalid DOCX: local entry is corrupt.');
|
|
22
|
-
const localNameLength = source.readUInt16LE(localOffset + 26); const localExtraLength = source.readUInt16LE(localOffset + 28);
|
|
23
|
-
const start = localOffset + 30 + localNameLength + localExtraLength; const compressed = source.subarray(start, start + compressedSize);
|
|
24
|
-
if (method !== 0 && method !== 8) throw new Error(`Unsupported DOCX compression method: ${method}.`);
|
|
25
|
-
entries.set(name, method === 8 ? inflateRawSync(compressed) : Buffer.from(compressed));
|
|
26
|
-
offset += 46 + nameLength + extraLength + commentLength;
|
|
14
|
+
const rawEntries = universalUnzipDocx(input);
|
|
15
|
+
const entries = new Map();
|
|
16
|
+
for (const [name, payload] of rawEntries) {
|
|
17
|
+
entries.set(name, Buffer.from(payload.buffer, payload.byteOffset, payload.byteLength));
|
|
27
18
|
}
|
|
28
19
|
return entries;
|
|
29
20
|
}
|
|
30
21
|
|
|
31
22
|
export function zipDocx(entries) {
|
|
32
|
-
|
|
33
|
-
for (const [name, raw] of entries) {
|
|
34
|
-
const nameBytes = Buffer.from(name); const data = Buffer.from(raw); const compressed = deflateRawSync(data, { level: 9 });
|
|
35
|
-
const method = compressed.length < data.length ? 8 : 0; const payload = method === 8 ? compressed : data; const crc = crc32(data);
|
|
36
|
-
const lh = Buffer.alloc(30); lh.writeUInt32LE(0x04034b50); lh.writeUInt16LE(20,4); lh.writeUInt16LE(method,8); lh.writeUInt16LE(0x5c21,12); lh.writeUInt32LE(crc,14); lh.writeUInt32LE(payload.length,18); lh.writeUInt32LE(data.length,22); lh.writeUInt16LE(nameBytes.length,26);
|
|
37
|
-
local.push(lh, nameBytes, payload);
|
|
38
|
-
const ch = Buffer.alloc(46); ch.writeUInt32LE(0x02014b50); ch.writeUInt16LE(20,4); ch.writeUInt16LE(20,6); ch.writeUInt16LE(method,10); ch.writeUInt16LE(0x5c21,14); ch.writeUInt32LE(crc,16); ch.writeUInt32LE(payload.length,20); ch.writeUInt32LE(data.length,24); ch.writeUInt16LE(nameBytes.length,28); ch.writeUInt32LE(offset,42);
|
|
39
|
-
central.push(ch, nameBytes); offset += lh.length + nameBytes.length + payload.length;
|
|
40
|
-
}
|
|
41
|
-
const directory = Buffer.concat(central); const end = Buffer.alloc(22); end.writeUInt32LE(0x06054b50); end.writeUInt16LE(entries.size,8); end.writeUInt16LE(entries.size,10); end.writeUInt32LE(directory.length,12); end.writeUInt32LE(offset,16);
|
|
42
|
-
return Buffer.concat([...local, directory, end]);
|
|
23
|
+
return Buffer.from(universalZipDocx(entries));
|
|
43
24
|
}
|
|
44
25
|
|
|
45
26
|
export class MemoryZip {
|
|
46
|
-
constructor(entries) {
|
|
27
|
+
constructor(entries) {
|
|
28
|
+
this.entries = entries instanceof Map ? entries : new Map(Object.entries(entries || {}));
|
|
29
|
+
}
|
|
30
|
+
|
|
47
31
|
file(path, value) {
|
|
48
|
-
if (value !== undefined) {
|
|
49
|
-
|
|
50
|
-
|
|
32
|
+
if (value !== undefined) {
|
|
33
|
+
this.entries.set(path, Buffer.isBuffer(value) ? Buffer.from(value) : Buffer.from(String(value)));
|
|
34
|
+
return this;
|
|
35
|
+
}
|
|
36
|
+
const data = this.entries.get(path);
|
|
37
|
+
if (!data) return null;
|
|
38
|
+
return {
|
|
39
|
+
async: async type => type === 'string' ? data.toString('utf8') : Buffer.from(data)
|
|
40
|
+
};
|
|
51
41
|
}
|
|
52
42
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ansonlai/docx-redline-js",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.1",
|
|
4
4
|
"description": "Host-independent OOXML reconciliation engine for .docx manipulation with track changes",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -26,9 +26,16 @@
|
|
|
26
26
|
"import": "./node/index.js",
|
|
27
27
|
"default": "./node/index.js"
|
|
28
28
|
},
|
|
29
|
+
"./bundle": {
|
|
30
|
+
"types": "./index.d.ts",
|
|
31
|
+
"import": "./dist/docx-redline.bundle.js",
|
|
32
|
+
"require": "./dist/docx-redline.bundle.cjs",
|
|
33
|
+
"default": "./dist/docx-redline.bundle.js"
|
|
34
|
+
},
|
|
29
35
|
"./redline-operation-converter": "./orchestration/redline-operation-converter.js",
|
|
30
36
|
"./adapters/*": "./adapters/*",
|
|
31
37
|
"./core/*": "./core/*",
|
|
38
|
+
"./document/*": "./document/*",
|
|
32
39
|
"./engine/*": "./engine/*",
|
|
33
40
|
"./pipeline/*": "./pipeline/*",
|
|
34
41
|
"./services/*": "./services/*",
|
|
@@ -37,6 +44,7 @@
|
|
|
37
44
|
"files": [
|
|
38
45
|
"adapters/",
|
|
39
46
|
"core/",
|
|
47
|
+
"document/",
|
|
40
48
|
"engine/",
|
|
41
49
|
"pipeline/",
|
|
42
50
|
"services/",
|
|
@@ -70,15 +78,9 @@
|
|
|
70
78
|
"LICENSE"
|
|
71
79
|
],
|
|
72
80
|
"dependencies": {
|
|
73
|
-
"
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
"@xmldom/xmldom": ">=0.8.0"
|
|
77
|
-
},
|
|
78
|
-
"peerDependenciesMeta": {
|
|
79
|
-
"@xmldom/xmldom": {
|
|
80
|
-
"optional": true
|
|
81
|
-
}
|
|
81
|
+
"@xmldom/xmldom": "^0.9.0",
|
|
82
|
+
"diff-match-patch": "^1.0.5",
|
|
83
|
+
"fflate": "^0.8.3"
|
|
82
84
|
},
|
|
83
85
|
"devDependencies": {
|
|
84
86
|
"@xmldom/xmldom": "^0.9.0",
|
|
@@ -92,7 +94,7 @@
|
|
|
92
94
|
"scripts": {
|
|
93
95
|
"build": "node scripts/build.mjs",
|
|
94
96
|
"check:types": "tsc -p tsconfig.types.json && node scripts/check-types.mjs",
|
|
95
|
-
"lint": "eslint index.js adapters core engine pipeline services orchestration scripts examples tests",
|
|
97
|
+
"lint": "eslint index.js adapters core document engine pipeline services orchestration scripts examples tests",
|
|
96
98
|
"test:coverage": "c8 --reporter=text --reporter=json --reporter=json-summary --reports-dir=coverage node scripts/run-tests.mjs",
|
|
97
99
|
"coverage:gaps": "node scripts/report-coverage-gaps.mjs",
|
|
98
100
|
"benchmark:session": "node scripts/benchmark-operation-session.mjs",
|
|
@@ -106,6 +108,7 @@
|
|
|
106
108
|
"smoke:word": "powershell -File scripts/word-com-smoke.ps1",
|
|
107
109
|
"smoke:word:diff": "powershell -File scripts/word-com-differential.ps1",
|
|
108
110
|
"test:word": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/word-com-suite.ps1",
|
|
111
|
+
"test:word:package": "node scripts/generate-word-package-integrity-cases.mjs && powershell -NoProfile -ExecutionPolicy Bypass -File scripts/word-com-package-integrity.ps1",
|
|
109
112
|
"test:word:visual": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/word-com-visual-suite.ps1",
|
|
110
113
|
"test:corpus:word": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/word-com-corpus-suite.ps1",
|
|
111
114
|
"test:corpus:word:visual": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/word-com-corpus-visual-suite.ps1",
|
package/scripts/build.mjs
CHANGED
|
@@ -8,20 +8,21 @@ const entryPoint = resolve(repoRoot, 'index.js');
|
|
|
8
8
|
const distDir = resolve(repoRoot, 'dist');
|
|
9
9
|
const pkg = JSON.parse(readFileSync(resolve(repoRoot, 'package.json'), 'utf8'));
|
|
10
10
|
|
|
11
|
-
// ESM bundle with diff-match-patch inlined (for
|
|
11
|
+
// ESM bundle with diff-match-patch and fflate inlined (for bundlers / browser)
|
|
12
12
|
await build({
|
|
13
13
|
entryPoints: [entryPoint],
|
|
14
14
|
bundle: true,
|
|
15
15
|
format: 'esm',
|
|
16
16
|
outfile: resolve(distDir, 'docx-redline-js.esm.js'),
|
|
17
|
-
platform: 'neutral',
|
|
17
|
+
platform: 'neutral',
|
|
18
|
+
mainFields: ['module', 'main'],
|
|
18
19
|
target: 'es2020',
|
|
19
|
-
minify: false,
|
|
20
|
+
minify: false,
|
|
20
21
|
sourcemap: true,
|
|
21
22
|
banner: {
|
|
22
23
|
js: `// @ansonlai/docx-redline-js v${pkg.version} — https://github.com/AnsonLai/docx-redline-js`
|
|
23
24
|
},
|
|
24
|
-
external: ['@xmldom/xmldom']
|
|
25
|
+
external: ['@xmldom/xmldom']
|
|
25
26
|
});
|
|
26
27
|
|
|
27
28
|
// Minified version for production CDN use
|
|
@@ -31,10 +32,44 @@ await build({
|
|
|
31
32
|
format: 'esm',
|
|
32
33
|
outfile: resolve(distDir, 'docx-redline-js.esm.min.js'),
|
|
33
34
|
platform: 'neutral',
|
|
35
|
+
mainFields: ['module', 'main'],
|
|
34
36
|
target: 'es2020',
|
|
35
37
|
minify: true,
|
|
36
38
|
sourcemap: true,
|
|
37
39
|
external: ['@xmldom/xmldom']
|
|
38
40
|
});
|
|
39
41
|
|
|
40
|
-
|
|
42
|
+
// Standalone zero-dependency ESM bundle (includes @xmldom/xmldom, fflate, diff-match-patch)
|
|
43
|
+
await build({
|
|
44
|
+
entryPoints: [entryPoint],
|
|
45
|
+
bundle: true,
|
|
46
|
+
format: 'esm',
|
|
47
|
+
outfile: resolve(distDir, 'docx-redline.bundle.js'),
|
|
48
|
+
platform: 'neutral',
|
|
49
|
+
mainFields: ['module', 'main'],
|
|
50
|
+
target: 'es2020',
|
|
51
|
+
minify: false,
|
|
52
|
+
sourcemap: true,
|
|
53
|
+
banner: {
|
|
54
|
+
js: `// @ansonlai/docx-redline-js standalone bundle v${pkg.version} — https://github.com/AnsonLai/docx-redline-js`
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// Standalone zero-dependency CommonJS bundle (for n8n, isolated-vm, CJS sandboxes)
|
|
59
|
+
await build({
|
|
60
|
+
entryPoints: [entryPoint],
|
|
61
|
+
bundle: true,
|
|
62
|
+
format: 'cjs',
|
|
63
|
+
outfile: resolve(distDir, 'docx-redline.bundle.cjs'),
|
|
64
|
+
platform: 'neutral',
|
|
65
|
+
mainFields: ['module', 'main'],
|
|
66
|
+
target: 'es2020',
|
|
67
|
+
minify: false,
|
|
68
|
+
sourcemap: true,
|
|
69
|
+
banner: {
|
|
70
|
+
js: `// @ansonlai/docx-redline-js standalone CommonJS bundle v${pkg.version} — https://github.com/AnsonLai/docx-redline-js`
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
console.log('Build complete: dist/docx-redline-js.esm.js, dist/docx-redline-js.esm.min.js, dist/docx-redline.bundle.js, dist/docx-redline.bundle.cjs');
|
|
75
|
+
|