@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.
@@ -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
+
package/node/cli-help.js CHANGED
@@ -134,7 +134,7 @@ export const CLI_COMMAND_HELP = Object.freeze({
134
134
  option('targetId', '--target-id <paragraphId>', 'Strong inline target using a paragraphId from fresh inspection.'),
135
135
  option('restore', '--restore', 'Restore one rejected-view paragraph identified by --target-id; omit --modified for verbatim restoration.'),
136
136
  option('modified', '--modified <text>', 'Complete desired accepted-view target content.'),
137
- option('find', '--find <exact text>', 'Exact case-sensitive source span for one localized replacement.'),
137
+ option('find', '--find <exact text>', 'Case-sensitive source span; exact match first, then ASCII-space/NBSP equivalence.'),
138
138
  option('replace', '--replace <text>', 'Replacement text for --find; an empty string deletes the span.'),
139
139
  option('occurrence', '--occurrence <N>', 'Positive 1-based --find occurrence within the uniquely resolved target paragraph.'),
140
140
  option('search', '--search <text>', 'Case-insensitive context anchor for speculative find/replace; prefer a longer, fairly unique phrase because all matches contribute scope.'),
package/node/cli.js CHANGED
@@ -7,6 +7,7 @@ import { validateRedlineOoxml } from '../core/redline-validation.js';
7
7
  import { configureLogger } from '../adapters/logger.js';
8
8
  import { isExistingRevisionsPolicy } from '../services/document-operation-contract.js';
9
9
  import { normalizeErrorWithRecovery } from '../services/error-recovery.js';
10
+ import { localizedReplacementOffsets } from '../services/localized-replacement-compiler.js';
10
11
  import { buildCliHelp, CLI_COMMANDS, commandOptionKeys } from './cli-help.js';
11
12
 
12
13
  const suffixes = { apply: 'redlined', accept: 'accepted', reject: 'rejected', 'delete-comments': 'comments-removed' };
@@ -31,6 +32,7 @@ const CLI_CAPABILITIES = [
31
32
  'localized-replacements-v1',
32
33
  'speculative-search-apply-v1',
33
34
  'localized-change-summary-v1',
35
+ 'localized-space-equivalence-v1',
34
36
  'restore-shortcuts-v1',
35
37
  'deduplicated-cli-receipts',
36
38
  'compact-cli-json-v1'
@@ -242,21 +244,27 @@ function resolveSpeculativePatchTarget(document, flags) {
242
244
  }
243
245
 
244
246
  const find = String(flags.find);
245
- const matches = eligible.filter(paragraph => paragraph.exactText.includes(find));
247
+ const patchCandidates = eligible.map(paragraph => ({
248
+ paragraph,
249
+ match: localizedReplacementOffsets(paragraph.exactText, find)
250
+ })).filter(candidate => candidate.match.offsets.length > 0);
251
+ const exactCandidates = patchCandidates.filter(candidate => candidate.match.matchMode === 'exact');
252
+ const matches = (exactCandidates.length > 0 ? exactCandidates : patchCandidates)
253
+ .map(candidate => candidate.paragraph);
246
254
  const context = {
247
255
  ...(search ? { search, range: range.text, anchorMatchCount: anchors.length } : {})
248
256
  };
249
257
  if (matches.length === 0) {
250
258
  throw speculativeError('PATCH_SOURCE_NOT_FOUND', search
251
- ? `No paragraph in the contextual range contains the exact patch source: "${find}".`
252
- : `No paragraph contains the exact patch source: "${find}".`, {
259
+ ? `No paragraph in the contextual range contains the patch source: "${find}".`
260
+ : `No paragraph contains the patch source: "${find}".`, {
253
261
  context,
254
262
  ...(anchors.length ? { candidates: anchors.map(speculativeCandidate) } : {}),
255
263
  recovery: { action: 'reinspect', requiresReinspection: true, sameArgumentsSafe: false }
256
264
  });
257
265
  }
258
266
  if (matches.length > 1) {
259
- throw speculativeError('AMBIGUOUS_TARGET', `The exact patch source matched ${matches.length} eligible paragraphs.`, {
267
+ throw speculativeError('AMBIGUOUS_TARGET', `The patch source matched ${matches.length} eligible paragraphs.`, {
260
268
  context,
261
269
  candidates: matches.map(speculativeCandidate),
262
270
  recovery: { action: 'choose_candidate', requiresReinspection: false, sameArgumentsSafe: false }