@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.
@@ -1,356 +1,12 @@
1
- import { DOMParser, XMLSerializer } from '@xmldom/xmldom';
2
- import { configureXmlProvider } from '../adapters/xml-adapter.js';
3
- import { getDefaultAuthor } from '../adapters/config.js';
4
- import { inspectDocumentParts } from '../services/document-inspection.js';
5
- import { applyOperationsToDocumentXml, preflightOperations } from '../services/standalone-operation-runner.js';
6
- import { createDynamicNumberingIdState, mergeNumberingXmlBySchemaOrder } from '../services/numbering-helpers.js';
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
- function rolledBackOperationPayload(operationResult, operationCount) {
22
- const rollbackReceipt = receipt => {
23
- if (!receipt || typeof receipt !== 'object') return receipt;
24
- if (receipt.attemptedDisposition !== 'applied' && receipt.committed !== true) return { ...receipt };
25
- return { ...receipt, committed: false, finalDisposition: 'rolled_back' };
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';
@@ -1,52 +1,42 @@
1
- import { deflateRawSync, inflateRawSync } from 'node:zlib';
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
- const CRC_TABLE = (() => { const table = new Uint32Array(256); for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; table[n] = c >>> 0; } return table; })();
4
- function crc32(buffer) { let crc = 0xffffffff; for (const byte of buffer) crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8); return (crc ^ 0xffffffff) >>> 0; }
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 source = Buffer.from(input);
8
- let eocd = -1;
9
- for (let offset = source.length - 22; offset >= Math.max(0, source.length - 0xffff - 22); offset--) if (source.readUInt32LE(offset) === 0x06054b50) { eocd = offset; break; }
10
- if (eocd < 0) throw new Error('Invalid DOCX: ZIP end record not found.');
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
- const local = []; const central = []; let offset = 0;
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) { this.entries = 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) { this.entries.set(path, Buffer.isBuffer(value) ? Buffer.from(value) : Buffer.from(String(value))); return this; }
49
- const data = this.entries.get(path); if (!data) return null;
50
- return { async: async type => type === 'string' ? data.toString('utf8') : Buffer.from(data) };
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.7.2",
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
- "diff-match-patch": "^1.0.5"
74
- },
75
- "peerDependencies": {
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 CDN/browser <script type="module">)
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', // no Node builtins assumed
17
+ platform: 'neutral',
18
+ mainFields: ['module', 'main'],
18
19
  target: 'es2020',
19
- minify: false, // keep readable for debugging
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'] // never bundle the Node-only XML parser
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
- console.log('Build complete: dist/docx-redline-js.esm.js, dist/docx-redline-js.esm.min.js');
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
- throw new Error('No crypto provider available for SHA-256 revision token computation.');
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 provided digest function.
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: (bytes: Uint8Array) => string }} options
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,