@ansonlai/docx-redline-js 0.7.2 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/AGENTS.md +15 -10
  2. package/ARCHITECTURE.md +30 -10
  3. package/CHANGELOG.md +539 -431
  4. package/README.md +331 -309
  5. package/adapters/xml-adapter.js +84 -56
  6. package/core/sha256.js +120 -0
  7. package/dist/docx-redline-js.esm.js +8982 -693
  8. package/dist/docx-redline-js.esm.js.map +4 -4
  9. package/dist/docx-redline-js.esm.min.js +102 -89
  10. package/dist/docx-redline-js.esm.min.js.map +4 -4
  11. package/dist/docx-redline.bundle.cjs +28305 -0
  12. package/dist/docx-redline.bundle.cjs.map +7 -0
  13. package/dist/docx-redline.bundle.js +28296 -0
  14. package/dist/docx-redline.bundle.js.map +7 -0
  15. package/docs/AGENT_FAST_START.md +2 -1
  16. package/docs/AGENT_KNOWLEDGE_BASE.md +480 -393
  17. package/docs/SKILL_AUTHORING.md +5 -2
  18. package/docs/TESTING.md +2 -1
  19. package/docs/schemas/document-operations.schema.json +29 -0
  20. package/document/docx-document.d.ts +39 -0
  21. package/document/docx-document.js +1010 -0
  22. package/document/zip-archive.js +258 -0
  23. package/engine/reconstruction-writer.js +18 -0
  24. package/index.d.ts +598 -531
  25. package/index.js +349 -340
  26. package/node/cli-help.js +5 -5
  27. package/node/cli.js +760 -754
  28. package/node/docx-document.js +11 -355
  29. package/node/index.d.ts +1 -1
  30. package/node/zip-archive.js +28 -38
  31. package/package.json +14 -11
  32. package/scripts/build.mjs +40 -5
  33. package/scripts/generate-word-comment-edge-case-fixture.ps1 +58 -0
  34. package/scripts/generate-word-comment-thread-fixture.ps1 +36 -0
  35. package/scripts/generate-word-header-footer-fixture.ps1 +32 -0
  36. package/scripts/generate-word-multi-section-fixture.ps1 +27 -0
  37. package/scripts/generate-word-package-integrity-cases.mjs +141 -0
  38. package/scripts/generate-word-thread-state-fixtures.ps1 +50 -0
  39. package/scripts/word-com-package-integrity.ps1 +98 -0
  40. package/services/batch-operation-orchestrator.js +262 -259
  41. package/services/comment-replies.js +96 -95
  42. package/services/comment-thread-parts.js +280 -0
  43. package/services/document-inspection.js +366 -365
  44. package/services/document-operation-applier.js +230 -202
  45. package/services/document-operation-contract.js +17 -2
  46. package/services/error-recovery.js +6 -0
  47. package/services/headers-footers.js +206 -0
  48. package/services/operation-batch-compiler.js +1 -0
  49. package/services/operation-preflight.js +231 -218
  50. package/services/package-parts.js +80 -0
  51. package/services/revision-token.js +11 -7
  52. package/services/standalone-docx-plumbing.js +631 -569
  53. package/services/standalone-operation-runner.d.ts +19 -3
@@ -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
+ }
@@ -164,6 +164,24 @@ export function applyReconstructionDiffs(xmlDoc, diffs, context, serializer, aut
164
164
  }
165
165
  }
166
166
 
167
+ // Comment markers are emitted when a diff part starts at their offset. Markers at the very end of the
168
+ // paragraph text (commentRangeEnd and the reference run of a comment on the last words) are only reached
169
+ // when the final diff part is an insert; after an unchanged or deleted tail nothing starts there, so they
170
+ // were dropped and the comment lost its end anchor. Flush whatever is left, in original order.
171
+ for (const offset of Array.from(sentinelMapByStart.keys()).sort((a, b) => a - b)) {
172
+ for (const sentinel of sentinelMapByStart.get(offset)) {
173
+ if (!sentinel.isCommentMarker || emittedCommentMarkers.has(sentinel.node)) continue;
174
+ emittedCommentMarkers.add(sentinel.node);
175
+ if (isWordElement(sentinel.node, 'commentReference')) {
176
+ const run = createWordElement(xmlDoc, 'w:r');
177
+ run.appendChild(sentinel.node.cloneNode(true));
178
+ currentParagraph.appendChild(run);
179
+ } else {
180
+ currentParagraph.appendChild(sentinel.node.cloneNode(true));
181
+ }
182
+ }
183
+ }
184
+
167
185
  if (generateRedlines) {
168
186
  containerFragments.forEach(fragment => {
169
187
  Array.from(fragment.childNodes).forEach(node => {