@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.
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Universal ZIP archive reader and writer for DOCX containers.
3
+ *
4
+ * Implemented with cross-runtime standards (Uint8Array, DataView, TextEncoder/Decoder)
5
+ * and fflate for pure-JS DEFLATE compression / INFLATE decompression.
6
+ * Free of Node.js built-ins (node:zlib, Buffer).
7
+ */
8
+
9
+ import { deflateSync, inflateSync } from 'fflate';
10
+
11
+ const textEncoder = new TextEncoder();
12
+ const textDecoder = new TextDecoder('utf-8');
13
+
14
+ const CRC_TABLE = (() => {
15
+ const table = new Uint32Array(256);
16
+ for (let n = 0; n < 256; n++) {
17
+ let c = n;
18
+ for (let k = 0; k < 8; k++) {
19
+ c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
20
+ }
21
+ table[n] = c >>> 0;
22
+ }
23
+ return table;
24
+ })();
25
+
26
+ /**
27
+ * Computes standard CRC-32 checksum.
28
+ *
29
+ * @param {Uint8Array} bytes
30
+ * @returns {number}
31
+ */
32
+ export function crc32(bytes) {
33
+ let crc = 0xffffffff;
34
+ for (let i = 0; i < bytes.length; i++) {
35
+ crc = CRC_TABLE[(crc ^ bytes[i]) & 0xff] ^ (crc >>> 8);
36
+ }
37
+ return (crc ^ 0xffffffff) >>> 0;
38
+ }
39
+
40
+ /**
41
+ * Normalizes binary or string input into a Uint8Array.
42
+ *
43
+ * @param {Uint8Array|ArrayBuffer|ArrayBufferView|string} input
44
+ * @returns {Uint8Array}
45
+ */
46
+ export function toUint8Array(input) {
47
+ if (input instanceof Uint8Array) {
48
+ return input;
49
+ }
50
+ if (input instanceof ArrayBuffer) {
51
+ return new Uint8Array(input);
52
+ }
53
+ if (ArrayBuffer.isView(input)) {
54
+ return new Uint8Array(input.buffer, input.byteOffset, input.byteLength);
55
+ }
56
+ if (typeof input === 'string') {
57
+ return textEncoder.encode(input);
58
+ }
59
+ throw new TypeError('Invalid binary input: expected Uint8Array, Buffer, or ArrayBuffer.');
60
+ }
61
+
62
+ /**
63
+ * Concatenates multiple Uint8Arrays into a single Uint8Array.
64
+ *
65
+ * @param {Uint8Array[]} chunks
66
+ * @returns {Uint8Array}
67
+ */
68
+ function concatUint8Arrays(chunks) {
69
+ let totalLen = 0;
70
+ for (let i = 0; i < chunks.length; i++) {
71
+ totalLen += chunks[i].length;
72
+ }
73
+ const result = new Uint8Array(totalLen);
74
+ let offset = 0;
75
+ for (let i = 0; i < chunks.length; i++) {
76
+ result.set(chunks[i], offset);
77
+ offset += chunks[i].length;
78
+ }
79
+ return result;
80
+ }
81
+
82
+ /**
83
+ * Unzips a DOCX ZIP archive into a map of entry names to uncompressed byte arrays.
84
+ *
85
+ * @param {Uint8Array|ArrayBuffer|ArrayBufferView} input - ZIP archive bytes
86
+ * @returns {Map<string, Uint8Array>} Map of entry paths to uncompressed Uint8Array payloads
87
+ */
88
+ export function unzipDocx(input) {
89
+ const source = toUint8Array(input);
90
+ if (source.length < 22) {
91
+ throw new Error('Invalid DOCX: ZIP end record not found.');
92
+ }
93
+
94
+ const view = new DataView(source.buffer, source.byteOffset, source.byteLength);
95
+ let eocd = -1;
96
+ const minOffset = Math.max(0, source.length - 0xffff - 22);
97
+
98
+ for (let offset = source.length - 22; offset >= minOffset; offset--) {
99
+ if (view.getUint32(offset, true) === 0x06054b50) {
100
+ eocd = offset;
101
+ break;
102
+ }
103
+ }
104
+
105
+ if (eocd < 0) {
106
+ throw new Error('Invalid DOCX: ZIP end record not found.');
107
+ }
108
+
109
+ const count = view.getUint16(eocd + 10, true);
110
+ const centralOffset = view.getUint32(eocd + 16, true);
111
+
112
+ if (count === 0xffff || centralOffset === 0xffffffff) {
113
+ throw new Error('Unsupported DOCX: ZIP64 archives are not supported.');
114
+ }
115
+
116
+ const entries = new Map();
117
+ let offset = centralOffset;
118
+
119
+ for (let index = 0; index < count; index++) {
120
+ if (offset + 46 > source.length || view.getUint32(offset, true) !== 0x02014b50) {
121
+ throw new Error('Invalid DOCX: central directory is corrupt.');
122
+ }
123
+
124
+ const flags = view.getUint16(offset + 8, true);
125
+ const method = view.getUint16(offset + 10, true);
126
+
127
+ if (flags & 1) {
128
+ throw new Error('Unsupported DOCX: encrypted ZIP entries are not supported.');
129
+ }
130
+
131
+ const compressedSize = view.getUint32(offset + 20, true);
132
+ const nameLength = view.getUint16(offset + 28, true);
133
+ const extraLength = view.getUint16(offset + 30, true);
134
+ const commentLength = view.getUint16(offset + 32, true);
135
+ const localOffset = view.getUint32(offset + 42, true);
136
+
137
+ if (offset + 46 + nameLength > source.length) {
138
+ throw new Error('Invalid DOCX: central directory entry name extends beyond bounds.');
139
+ }
140
+
141
+ const name = textDecoder.decode(source.subarray(offset + 46, offset + 46 + nameLength));
142
+
143
+ if (localOffset + 30 > source.length || view.getUint32(localOffset, true) !== 0x04034b50) {
144
+ throw new Error('Invalid DOCX: local entry is corrupt.');
145
+ }
146
+
147
+ const localNameLength = view.getUint16(localOffset + 26, true);
148
+ const localExtraLength = view.getUint16(localOffset + 28, true);
149
+ const start = localOffset + 30 + localNameLength + localExtraLength;
150
+
151
+ if (start + compressedSize > source.length) {
152
+ throw new Error('Invalid DOCX: local entry data extends beyond archive bounds.');
153
+ }
154
+
155
+ const compressed = source.subarray(start, start + compressedSize);
156
+
157
+ if (method !== 0 && method !== 8) {
158
+ throw new Error(`Unsupported DOCX compression method: ${method}.`);
159
+ }
160
+
161
+ const payload = method === 8 ? inflateSync(compressed) : new Uint8Array(compressed);
162
+ entries.set(name, payload);
163
+ offset += 46 + nameLength + extraLength + commentLength;
164
+ }
165
+
166
+ return entries;
167
+ }
168
+
169
+ /**
170
+ * Zips uncompressed entries into a standard DOCX ZIP archive.
171
+ *
172
+ * @param {Map<string, Uint8Array|string>|Iterable<[string, any]>|Record<string, any>} entries
173
+ * @returns {Uint8Array} Binary ZIP archive bytes
174
+ */
175
+ export function zipDocx(entries) {
176
+ const entryList = entries instanceof Map
177
+ ? Array.from(entries.entries())
178
+ : (Symbol.iterator in Object(entries) ? Array.from(entries) : Object.entries(entries || {}));
179
+
180
+ const localChunks = [];
181
+ const centralChunks = [];
182
+ let offset = 0;
183
+
184
+ for (const [name, raw] of entryList) {
185
+ const nameBytes = textEncoder.encode(name);
186
+ const data = toUint8Array(raw);
187
+ const compressed = deflateSync(data, { level: 9 });
188
+ const method = compressed.length < data.length ? 8 : 0;
189
+ const payload = method === 8 ? compressed : data;
190
+ const crc = crc32(data);
191
+
192
+ // Local header: 30 bytes
193
+ const lh = new Uint8Array(30);
194
+ const lhView = new DataView(lh.buffer, lh.byteOffset, 30);
195
+ lhView.setUint32(0, 0x04034b50, true);
196
+ lhView.setUint16(4, 20, true);
197
+ lhView.setUint16(8, method, true);
198
+ lhView.setUint16(12, 0x5c21, true); // deterministic timestamp
199
+ lhView.setUint32(14, crc, true);
200
+ lhView.setUint32(18, payload.length, true);
201
+ lhView.setUint32(22, data.length, true);
202
+ lhView.setUint16(26, nameBytes.length, true);
203
+ lhView.setUint16(28, 0, true);
204
+
205
+ localChunks.push(lh, nameBytes, payload);
206
+
207
+ // Central directory header: 46 bytes
208
+ const ch = new Uint8Array(46);
209
+ const chView = new DataView(ch.buffer, ch.byteOffset, 46);
210
+ chView.setUint32(0, 0x02014b50, true);
211
+ chView.setUint16(4, 20, true);
212
+ chView.setUint16(6, 20, true);
213
+ chView.setUint16(10, method, true);
214
+ chView.setUint16(14, 0x5c21, true);
215
+ chView.setUint32(16, crc, true);
216
+ chView.setUint32(20, payload.length, true);
217
+ chView.setUint32(24, data.length, true);
218
+ chView.setUint16(28, nameBytes.length, true);
219
+ chView.setUint16(30, 0, true);
220
+ chView.setUint16(32, 0, true);
221
+ chView.setUint32(42, offset, true);
222
+
223
+ centralChunks.push(ch, nameBytes);
224
+ offset += lh.length + nameBytes.length + payload.length;
225
+ }
226
+
227
+ const directory = concatUint8Arrays(centralChunks);
228
+ const end = new Uint8Array(22);
229
+ const endView = new DataView(end.buffer, end.byteOffset, 22);
230
+ endView.setUint32(0, 0x06054b50, true);
231
+ endView.setUint16(8, entryList.length, true);
232
+ endView.setUint16(10, entryList.length, true);
233
+ endView.setUint32(12, directory.length, true);
234
+ endView.setUint32(16, offset, true);
235
+
236
+ return concatUint8Arrays([...localChunks, directory, end]);
237
+ }
238
+
239
+ /**
240
+ * Universal in-memory ZIP helper mimicking JSZip file() interface for internal consumers.
241
+ */
242
+ export class MemoryZip {
243
+ constructor(entries) {
244
+ this.entries = entries instanceof Map ? entries : new Map(Object.entries(entries || {}));
245
+ }
246
+
247
+ file(path, value) {
248
+ if (value !== undefined) {
249
+ this.entries.set(path, toUint8Array(value));
250
+ return this;
251
+ }
252
+ const data = this.entries.get(path);
253
+ if (!data) return null;
254
+ return {
255
+ async: async type => type === 'string' ? textDecoder.decode(data) : new Uint8Array(data)
256
+ };
257
+ }
258
+ }
package/index.d.ts CHANGED
@@ -12,7 +12,13 @@ export interface RevisionTextSegment {
12
12
  rejectedStart: number | null;
13
13
  }
14
14
 
15
- import type { InsertionAffinity } from './services/standalone-operation-runner.js';
15
+ import type {
16
+ InsertionAffinity,
17
+ DocumentOperation,
18
+ DocumentOperationBatchResult,
19
+ OperationPreflightResult,
20
+ StandaloneRunnerOptions
21
+ } from './services/standalone-operation-runner.js';
16
22
 
17
23
  export type {
18
24
  BatchOperationItemResult,
@@ -529,3 +535,41 @@ export function reconcileReceiptsAgainstOutput(
529
535
  },
530
536
  receipts: MutationReceipt[]
531
537
  ): { valid: boolean; error?: { code: string; message: string } };
538
+
539
+ export interface DocxApplyOptions extends StandaloneRunnerOptions {
540
+ author?: string;
541
+ validate?: boolean;
542
+ }
543
+
544
+ export interface DocxApplyResult extends DocumentOperationBatchResult {
545
+ written: boolean;
546
+ uint8Array: Uint8Array;
547
+ buffer: Uint8Array;
548
+ inspection?: DocumentInspectionResult;
549
+ artifactsChanged?: string[];
550
+ validation?: { originalIssues: unknown[]; generatedIssues: unknown[] };
551
+ issues?: unknown[];
552
+ toUint8Array(): Uint8Array;
553
+ toBuffer(): Uint8Array;
554
+ }
555
+
556
+ export class DocxDocument {
557
+ constructor(input: Uint8Array | ArrayBuffer | string);
558
+ readonly originalBytes: Uint8Array;
559
+ readonly originalBuffer: Uint8Array;
560
+ readonly entries: Map<string, Uint8Array>;
561
+ inspect(options?: DocumentInspectionOptions): DocumentInspectionResult;
562
+ getRevisionToken(): RevisionToken;
563
+ readonly revisionToken: RevisionToken;
564
+ preflight(operations: DocumentOperation[], author?: string, options?: StandaloneRunnerOptions): OperationPreflightResult;
565
+ applyOperations(operations: DocumentOperation[], options?: DocxApplyOptions): Promise<DocxApplyResult>;
566
+ resolveRevisions(action: 'accept' | 'reject', options?: { author?: string; allAuthors?: boolean; validate?: boolean }): Promise<DocxApplyResult>;
567
+ deleteComments(options?: { author?: string; allAuthors?: boolean; validate?: boolean }): Promise<DocxApplyResult>;
568
+ toUint8Array(): Uint8Array;
569
+ toBuffer(): Uint8Array;
570
+ }
571
+
572
+ export function computePackageRevisionToken(input: unknown): RevisionToken;
573
+
574
+ export function openDocx(input: Uint8Array | ArrayBuffer | string): DocxDocument;
575
+
package/index.js CHANGED
@@ -338,3 +338,11 @@ export {
338
338
  createEmptyReceipt,
339
339
  reconcileReceiptsAgainstOutput
340
340
  } from './services/receipt-collector.js';
341
+
342
+ // Universal Document Facade
343
+ export {
344
+ DocxDocument,
345
+ openDocx,
346
+ computePackageRevisionToken
347
+ } from './document/docx-document.js';
348
+