@ansonlai/docx-redline-js 0.7.2 → 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 +15 -10
- package/ARCHITECTURE.md +30 -10
- package/CHANGELOG.md +58 -3
- 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 +2 -1
- package/docs/AGENT_KNOWLEDGE_BASE.md +412 -386
- package/docs/SKILL_AUTHORING.md +5 -2
- package/docs/TESTING.md +2 -1
- 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/docx-document.js +11 -355
- package/node/zip-archive.js +28 -38
- package/package.json +13 -11
- package/scripts/build.mjs +40 -5
- package/services/revision-token.js +11 -7
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/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.0",
|
|
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",
|
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
|
+
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
* revision tokens over package entries or document parts.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { sha256 } from '../core/sha256.js';
|
|
9
|
+
|
|
8
10
|
const textEncoder = new TextEncoder();
|
|
9
11
|
|
|
10
12
|
/**
|
|
@@ -145,6 +147,8 @@ export function buildRevisionTokenFraming({ scope, entries = [] }) {
|
|
|
145
147
|
/**
|
|
146
148
|
* Calculates a SHA-256 revision token asynchronously using Web Crypto or a custom digest function.
|
|
147
149
|
*
|
|
150
|
+
* Defaults to the built-in pure-JS SHA-256 implementation when Web Crypto is unavailable.
|
|
151
|
+
*
|
|
148
152
|
* @param {{ scope: string, entries: Array<any>, digestFn?: (bytes: Uint8Array) => Promise<string> }} options
|
|
149
153
|
* @returns {Promise<{ algorithm: 'sha256', version: number, scope: string, value: string, coveredParts: string[] }>}
|
|
150
154
|
*/
|
|
@@ -159,7 +163,7 @@ export async function computeRevisionToken({ scope, entries = [], digestFn = nul
|
|
|
159
163
|
const hashBytes = new Uint8Array(hashBuf);
|
|
160
164
|
hashHex = Array.from(hashBytes).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
161
165
|
} else {
|
|
162
|
-
|
|
166
|
+
hashHex = sha256(framing);
|
|
163
167
|
}
|
|
164
168
|
|
|
165
169
|
return {
|
|
@@ -172,17 +176,17 @@ export async function computeRevisionToken({ scope, entries = [], digestFn = nul
|
|
|
172
176
|
}
|
|
173
177
|
|
|
174
178
|
/**
|
|
175
|
-
* Calculates a SHA-256 revision token synchronously using a
|
|
179
|
+
* Calculates a SHA-256 revision token synchronously using pure-JS SHA-256 or a custom digest function.
|
|
176
180
|
*
|
|
177
|
-
* @param {{ scope: string, entries: Array<any>, digestFn
|
|
181
|
+
* @param {{ scope: string, entries: Array<any>, digestFn?: (bytes: Uint8Array) => string }} options
|
|
178
182
|
* @returns {{ algorithm: 'sha256', version: number, scope: string, value: string, coveredParts: string[] }}
|
|
179
183
|
*/
|
|
180
|
-
export function computeRevisionTokenSync({ scope, entries = [], digestFn }) {
|
|
181
|
-
if (typeof digestFn !== 'function') {
|
|
182
|
-
throw new TypeError('computeRevisionTokenSync requires a synchronous digestFn.');
|
|
184
|
+
export function computeRevisionTokenSync({ scope, entries = [], digestFn = null }) {
|
|
185
|
+
if (digestFn != null && typeof digestFn !== 'function') {
|
|
186
|
+
throw new TypeError('computeRevisionTokenSync requires a synchronous digestFn when specified.');
|
|
183
187
|
}
|
|
184
188
|
const { framing, coveredParts, version } = buildRevisionTokenFraming({ scope, entries });
|
|
185
|
-
const hashHex = digestFn(framing);
|
|
189
|
+
const hashHex = typeof digestFn === 'function' ? digestFn(framing) : sha256(framing);
|
|
186
190
|
return {
|
|
187
191
|
algorithm: 'sha256',
|
|
188
192
|
version,
|