@ansonlai/docx-redline-js 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.1",
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/",
@@ -47,6 +55,7 @@
47
55
  "!scripts/benchmark-agent-workflow.mjs",
48
56
  "!scripts/benchmark-agent-cli-discovery.mjs",
49
57
  "!scripts/benchmark-localized-turn-reduction.mjs",
58
+ "!scripts/benchmark-agent-surface-simplification.mjs",
50
59
  "!scripts/lib/agent-performance-cases.mjs",
51
60
  "docs/AGENT_FAST_START.md",
52
61
  "docs/AGENT_KNOWLEDGE_BASE.md",
@@ -58,6 +67,7 @@
58
67
  "docs/validation-reports/2026-09-12-agent-cli-discovery-baseline.md",
59
68
  "docs/validation-reports/2026-09-13-agent-cli-efficiency-rollout.md",
60
69
  "docs/validation-reports/2026-09-14-localized-patching-rollout.md",
70
+ "docs/validation-reports/2026-09-14-agent-surface-simplification.md",
61
71
  "index.js",
62
72
  "index.d.ts",
63
73
  "dist/",
@@ -68,15 +78,9 @@
68
78
  "LICENSE"
69
79
  ],
70
80
  "dependencies": {
71
- "diff-match-patch": "^1.0.5"
72
- },
73
- "peerDependencies": {
74
- "@xmldom/xmldom": ">=0.8.0"
75
- },
76
- "peerDependenciesMeta": {
77
- "@xmldom/xmldom": {
78
- "optional": true
79
- }
81
+ "@xmldom/xmldom": "^0.9.0",
82
+ "diff-match-patch": "^1.0.5",
83
+ "fflate": "^0.8.3"
80
84
  },
81
85
  "devDependencies": {
82
86
  "@xmldom/xmldom": "^0.9.0",
@@ -90,13 +94,14 @@
90
94
  "scripts": {
91
95
  "build": "node scripts/build.mjs",
92
96
  "check:types": "tsc -p tsconfig.types.json && node scripts/check-types.mjs",
93
- "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",
94
98
  "test:coverage": "c8 --reporter=text --reporter=json --reporter=json-summary --reports-dir=coverage node scripts/run-tests.mjs",
95
99
  "coverage:gaps": "node scripts/report-coverage-gaps.mjs",
96
100
  "benchmark:session": "node scripts/benchmark-operation-session.mjs",
97
101
  "benchmark:agent": "node scripts/benchmark-agent-workflow.mjs",
98
102
  "benchmark:agent-cli": "node scripts/benchmark-agent-cli-discovery.mjs",
99
103
  "benchmark:localized": "node scripts/benchmark-localized-turn-reduction.mjs",
104
+ "benchmark:agent-surface": "node scripts/benchmark-agent-surface-simplification.mjs",
100
105
  "benchmark:targeting": "node scripts/benchmark-targeting-hot-paths.mjs",
101
106
  "benchmark:tests": "node scripts/benchmark-test-runner.mjs",
102
107
  "profile:routes": "node scripts/profile-route-selection.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
+