@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.
- package/AGENTS.md +15 -10
- package/ARCHITECTURE.md +30 -10
- package/CHANGELOG.md +539 -431
- package/README.md +331 -309
- package/adapters/xml-adapter.js +84 -56
- package/core/sha256.js +120 -0
- package/dist/docx-redline-js.esm.js +8982 -693
- package/dist/docx-redline-js.esm.js.map +4 -4
- package/dist/docx-redline-js.esm.min.js +102 -89
- package/dist/docx-redline-js.esm.min.js.map +4 -4
- package/dist/docx-redline.bundle.cjs +28305 -0
- package/dist/docx-redline.bundle.cjs.map +7 -0
- package/dist/docx-redline.bundle.js +28296 -0
- package/dist/docx-redline.bundle.js.map +7 -0
- package/docs/AGENT_FAST_START.md +2 -1
- package/docs/AGENT_KNOWLEDGE_BASE.md +480 -393
- package/docs/SKILL_AUTHORING.md +5 -2
- package/docs/TESTING.md +2 -1
- package/docs/schemas/document-operations.schema.json +29 -0
- package/document/docx-document.d.ts +39 -0
- package/document/docx-document.js +1010 -0
- package/document/zip-archive.js +258 -0
- package/engine/reconstruction-writer.js +18 -0
- package/index.d.ts +598 -531
- package/index.js +349 -340
- package/node/cli-help.js +5 -5
- package/node/cli.js +760 -754
- package/node/docx-document.js +11 -355
- package/node/index.d.ts +1 -1
- package/node/zip-archive.js +28 -38
- package/package.json +14 -11
- package/scripts/build.mjs +40 -5
- package/scripts/generate-word-comment-edge-case-fixture.ps1 +58 -0
- package/scripts/generate-word-comment-thread-fixture.ps1 +36 -0
- package/scripts/generate-word-header-footer-fixture.ps1 +32 -0
- package/scripts/generate-word-multi-section-fixture.ps1 +27 -0
- package/scripts/generate-word-package-integrity-cases.mjs +141 -0
- package/scripts/generate-word-thread-state-fixtures.ps1 +50 -0
- package/scripts/word-com-package-integrity.ps1 +98 -0
- package/services/batch-operation-orchestrator.js +262 -259
- package/services/comment-replies.js +96 -95
- package/services/comment-thread-parts.js +280 -0
- package/services/document-inspection.js +366 -365
- package/services/document-operation-applier.js +230 -202
- package/services/document-operation-contract.js +17 -2
- package/services/error-recovery.js +6 -0
- package/services/headers-footers.js +206 -0
- package/services/operation-batch-compiler.js +1 -0
- package/services/operation-preflight.js +231 -218
- package/services/package-parts.js +80 -0
- package/services/revision-token.js +11 -7
- package/services/standalone-docx-plumbing.js +631 -569
- 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 => {
|