@doccloak/core 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.
Files changed (66) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +5 -0
  4. package/README.md +230 -0
  5. package/dist/doc.d.ts +13 -0
  6. package/dist/doc.js +683 -0
  7. package/dist/docx.d.ts +31 -0
  8. package/dist/docx.js +26 -0
  9. package/dist/dom/docx.d.ts +90 -0
  10. package/dist/dom/docx.js +472 -0
  11. package/dist/dom/index.d.ts +11 -0
  12. package/dist/dom/index.js +11 -0
  13. package/dist/dom/ocr.d.ts +112 -0
  14. package/dist/dom/ocr.js +224 -0
  15. package/dist/engine.d.ts +81 -0
  16. package/dist/engine.js +274 -0
  17. package/dist/env.d.ts +49 -0
  18. package/dist/env.js +53 -0
  19. package/dist/index.d.ts +20 -0
  20. package/dist/index.js +29 -0
  21. package/dist/model-loader.d.ts +55 -0
  22. package/dist/model-loader.js +253 -0
  23. package/dist/pipeline.d.ts +19 -0
  24. package/dist/pipeline.js +128 -0
  25. package/dist/providers/bardsai.d.ts +38 -0
  26. package/dist/providers/bardsai.js +393 -0
  27. package/dist/providers/gliner.d.ts +52 -0
  28. package/dist/providers/gliner.js +360 -0
  29. package/dist/regex/index.d.ts +17 -0
  30. package/dist/regex/index.js +18 -0
  31. package/dist/regex/loader.d.ts +44 -0
  32. package/dist/regex/loader.js +44 -0
  33. package/dist/regex/rules.data.d.ts +2 -0
  34. package/dist/regex/rules.data.js +2671 -0
  35. package/dist/regex/types.d.ts +27 -0
  36. package/dist/regex/types.js +1 -0
  37. package/dist/regex/validators.d.ts +17 -0
  38. package/dist/regex/validators.js +492 -0
  39. package/dist/session.d.ts +59 -0
  40. package/dist/session.js +153 -0
  41. package/dist/types.d.ts +42 -0
  42. package/dist/types.js +28 -0
  43. package/dist/worker-protocol.d.ts +119 -0
  44. package/dist/worker-protocol.js +285 -0
  45. package/package.json +75 -0
  46. package/rules/at.json +70 -0
  47. package/rules/be.json +70 -0
  48. package/rules/ch.json +66 -0
  49. package/rules/cn.json +155 -0
  50. package/rules/de.json +192 -0
  51. package/rules/dk.json +43 -0
  52. package/rules/es.json +158 -0
  53. package/rules/fi.json +43 -0
  54. package/rules/fr.json +178 -0
  55. package/rules/gb.json +194 -0
  56. package/rules/ie.json +48 -0
  57. package/rules/it.json +116 -0
  58. package/rules/jp.json +136 -0
  59. package/rules/nl.json +99 -0
  60. package/rules/no.json +132 -0
  61. package/rules/pl.json +210 -0
  62. package/rules/pt.json +113 -0
  63. package/rules/schema.json +91 -0
  64. package/rules/se.json +149 -0
  65. package/rules/universal.json +279 -0
  66. package/rules/us.json +218 -0
package/dist/doc.js ADDED
@@ -0,0 +1,683 @@
1
+ import CFB from 'cfb';
2
+ import { normalizeReplacements } from "./docx.js";
3
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
4
+ function toUint8Array(content) {
5
+ return content instanceof Uint8Array
6
+ ? content
7
+ : new Uint8Array(content);
8
+ }
9
+ function align512(n) {
10
+ return Math.ceil(n / 512) * 512;
11
+ }
12
+ // ─── Piece table parsing ─────────────────────────────────────────────────────
13
+ function parsePieces(tableStream, fcClx, lcbClx) {
14
+ const view = new DataView(tableStream.buffer, tableStream.byteOffset, tableStream.byteLength);
15
+ let offset = fcClx;
16
+ const end = fcClx + lcbClx;
17
+ // Skip Prc (type 0x01) entries
18
+ while (offset < end && view.getUint8(offset) !== 0x02) {
19
+ if (view.getUint8(offset) === 0x01) {
20
+ const cb = view.getInt16(offset + 1, true);
21
+ offset += 3 + cb;
22
+ }
23
+ else {
24
+ throw new Error('Invalid CLX');
25
+ }
26
+ }
27
+ if (offset >= end || view.getUint8(offset) !== 0x02) {
28
+ throw new Error('Missing piece table');
29
+ }
30
+ offset++; // type byte
31
+ const lcbPcd = view.getUint32(offset, true);
32
+ offset += 4;
33
+ const nPieces = (lcbPcd - 4) / 12;
34
+ if (nPieces !== Math.floor(nPieces) || nPieces <= 0) {
35
+ throw new Error('Bad piece table');
36
+ }
37
+ const cps = [];
38
+ for (let i = 0; i <= nPieces; i++) {
39
+ cps.push(view.getInt32(offset + i * 4, true));
40
+ }
41
+ const pcdOff = offset + (nPieces + 1) * 4;
42
+ const pieces = [];
43
+ for (let i = 0; i < nPieces; i++) {
44
+ const fc = view.getUint32(pcdOff + i * 8 + 2, true);
45
+ pieces.push({
46
+ cpStart: cps[i],
47
+ cpEnd: cps[i + 1],
48
+ fc,
49
+ isCompressed: (fc & 0x40000000) !== 0,
50
+ });
51
+ }
52
+ return pieces;
53
+ }
54
+ // ─── Build new piece entries from original pieces for a CP range ─────────────
55
+ function extractOriginalPieces(originalPieces, cpStart, cpEnd, newCpStart) {
56
+ const result = [];
57
+ for (const piece of originalPieces) {
58
+ const oStart = Math.max(cpStart, piece.cpStart);
59
+ const oEnd = Math.min(cpEnd, piece.cpEnd);
60
+ if (oStart >= oEnd)
61
+ continue;
62
+ const cpDelta = oStart - piece.cpStart;
63
+ // FC formula: (original_raw_fc & 0x3FFFFFFF) + 2*cpDelta, preserving compression flag
64
+ const rawBase = piece.fc & 0x3fffffff;
65
+ const fc = (rawBase + 2 * cpDelta) | (piece.isCompressed ? 0x40000000 : 0);
66
+ result.push({
67
+ cpStart: newCpStart + (oStart - cpStart),
68
+ cpEnd: newCpStart + (oEnd - cpStart),
69
+ fc,
70
+ isCompressed: piece.isCompressed,
71
+ });
72
+ }
73
+ return result;
74
+ }
75
+ // ─── Build CLX binary from pieces ────────────────────────────────────────────
76
+ function buildClx(pieces) {
77
+ const n = pieces.length;
78
+ const plcPcdSize = (n + 1) * 4 + n * 8;
79
+ const buf = new Uint8Array(1 + 4 + plcPcdSize);
80
+ const v = new DataView(buf.buffer);
81
+ buf[0] = 0x02; // Pcdt type
82
+ v.setUint32(1, plcPcdSize, true);
83
+ let off = 5;
84
+ // CPs
85
+ for (let i = 0; i <= n; i++) {
86
+ v.setInt32(off, i < n ? pieces[i].cpStart : pieces[n - 1].cpEnd, true);
87
+ off += 4;
88
+ }
89
+ // PCDs: 2 bytes flags + 4 bytes fc + 2 bytes prm
90
+ for (let i = 0; i < n; i++) {
91
+ v.setUint16(off, 0, true);
92
+ off += 2; // flags
93
+ v.setUint32(off, pieces[i].fc, true);
94
+ off += 4; // fc
95
+ v.setUint16(off, 0, true);
96
+ off += 2; // prm
97
+ }
98
+ return buf;
99
+ }
100
+ // ─── Build default FKP pages for replacement text ────────────────────────────
101
+ function buildDefaultChpxFkp(fcStart, fcEnd) {
102
+ const page = new Uint8Array(512);
103
+ const v = new DataView(page.buffer);
104
+ v.setUint32(0, fcStart, true);
105
+ v.setUint32(4, fcEnd, true);
106
+ page[8] = 0; // rgb[0] = 0 → no CHPX, default formatting
107
+ page[511] = 1; // crun = 1
108
+ return page;
109
+ }
110
+ function buildDefaultPapxFkp(fcStart, fcEnd) {
111
+ const page = new Uint8Array(512);
112
+ const v = new DataView(page.buffer);
113
+ v.setUint32(0, fcStart, true);
114
+ v.setUint32(4, fcEnd, true);
115
+ // rgbx[0]: bOffset=253 (byte pos 506), + 12 bytes PHE (zeros)
116
+ page[8] = 253;
117
+ // PAPX at byte 506: cb2=1 (2 bytes of data = istd only), istd=0 (Normal)
118
+ page[506] = 1;
119
+ page[507] = 0;
120
+ page[508] = 0;
121
+ page[511] = 1; // cpara = 1
122
+ return page;
123
+ }
124
+ // ─── Extend PlcBte (chpx or papx) with a new FKP page entry ─────────────────
125
+ function extendPlcBte(original, newFcStart, newFcEnd, newPageNum) {
126
+ const origLen = original.length;
127
+ const n = (origLen - 4) / 8; // number of original ranges
128
+ const ov = new DataView(original.buffer, original.byteOffset, original.byteLength);
129
+ // New: n+1 ranges → (n+2) FCs + (n+1) page numbers
130
+ const newLen = origLen + 8;
131
+ const buf = new Uint8Array(newLen);
132
+ const bv = new DataView(buf.buffer);
133
+ // Copy original FCs [0..n-1], replace sentinel [n] with newFcStart, add newFcEnd
134
+ for (let i = 0; i < n; i++) {
135
+ bv.setUint32(i * 4, ov.getUint32(i * 4, true), true);
136
+ }
137
+ bv.setUint32(n * 4, newFcStart, true);
138
+ bv.setUint32((n + 1) * 4, newFcEnd, true);
139
+ // Copy original page numbers, add new one
140
+ const origPgOff = (n + 1) * 4;
141
+ const newPgOff = (n + 2) * 4;
142
+ for (let i = 0; i < n; i++) {
143
+ bv.setUint32(newPgOff + i * 4, ov.getUint32(origPgOff + i * 4, true), true);
144
+ }
145
+ bv.setUint32(newPgOff + n * 4, newPageNum, true);
146
+ return buf;
147
+ }
148
+ // ─── Adjust CPs in a generic PLC structure ───────────────────────────────────
149
+ function adjustPlcCPs(original, entrySize, ccpText, sortedRepls, totalDelta) {
150
+ if (original.length === 0)
151
+ return original;
152
+ const n = (original.length - 4) / (4 + entrySize);
153
+ if (n !== Math.floor(n) || n <= 0)
154
+ return original;
155
+ const result = new Uint8Array(original);
156
+ const v = new DataView(result.buffer, result.byteOffset, result.byteLength);
157
+ for (let i = 0; i <= n; i++) {
158
+ const cp = v.getInt32(i * 4, true);
159
+ if (cp >= ccpText) {
160
+ v.setInt32(i * 4, cp + totalDelta, true);
161
+ }
162
+ else {
163
+ // Adjust by cumulative delta of replacements before this CP
164
+ let delta = 0;
165
+ for (const repl of sortedRepls) {
166
+ if (repl.start >= cp)
167
+ break;
168
+ if (repl.end <= cp) {
169
+ delta += repl.replacement.length - (repl.end - repl.start);
170
+ }
171
+ }
172
+ if (delta !== 0) {
173
+ v.setInt32(i * 4, cp + delta, true);
174
+ }
175
+ }
176
+ }
177
+ return result;
178
+ }
179
+ // ─── Read a PLC blob from the table stream ───────────────────────────────────
180
+ function readPlcBlob(tableStream, fc, lcb) {
181
+ if (lcb <= 0 || fc < 0)
182
+ return new Uint8Array(0);
183
+ return tableStream.slice(fc, fc + lcb);
184
+ }
185
+ // ─── Shared parsing ──────────────────────────────────────────────────────────
186
+ function parseDocStreams(buffer) {
187
+ const data = new Uint8Array(buffer);
188
+ const container = CFB.parse(data);
189
+ const wordDocEntry = CFB.find(container, '/WordDocument') ?? CFB.find(container, 'WordDocument');
190
+ if (!wordDocEntry?.content)
191
+ throw new Error('Invalid .doc file: missing WordDocument stream');
192
+ const wordDoc = toUint8Array(wordDocEntry.content);
193
+ const wordView = new DataView(wordDoc.buffer, wordDoc.byteOffset, wordDoc.byteLength);
194
+ const wIdent = wordView.getUint16(0, true);
195
+ if (wIdent !== 0xa5ec) {
196
+ throw new Error('Invalid .doc file: bad magic number');
197
+ }
198
+ const flags = wordView.getUint16(0x000a, true);
199
+ const whichTbl = (flags >> 9) & 1;
200
+ const tblName = whichTbl ? '1Table' : '0Table';
201
+ const tableEntry = CFB.find(container, '/' + tblName) ?? CFB.find(container, tblName);
202
+ if (!tableEntry?.content)
203
+ throw new Error(`Invalid .doc file: missing ${tblName} stream`);
204
+ const tableDoc = toUint8Array(tableEntry.content);
205
+ const ccpText = wordView.getInt32(0x004c, true);
206
+ const fcClx = wordView.getInt32(0x01a2, true);
207
+ const lcbClx = wordView.getInt32(0x01a6, true);
208
+ if (lcbClx <= 0 || fcClx < 0) {
209
+ throw new Error('Invalid .doc file: missing CLX data');
210
+ }
211
+ const pieces = parsePieces(tableDoc, fcClx, lcbClx);
212
+ return { container, wordDocEntry, tableEntry, wordDoc, wordView, tableDoc, ccpText, pieces };
213
+ }
214
+ /**
215
+ * Decode every piece in the piece table (main text and all subdocuments:
216
+ * footnotes, headers/footers, comments, endnotes, text boxes) and normalize
217
+ * Word's structural control characters, keeping a map from each normalized
218
+ * character back to its CP so replacements can be located exactly.
219
+ */
220
+ function extractDocContent(parsed) {
221
+ const { wordDoc, wordView, pieces } = parsed;
222
+ // Pass 1: decode raw characters with their CPs
223
+ const chars = [];
224
+ const cps = [];
225
+ for (const piece of pieces) {
226
+ const charCount = piece.cpEnd - piece.cpStart;
227
+ if (charCount <= 0)
228
+ continue;
229
+ const rawOffset = piece.fc & 0x3fffffff;
230
+ if (piece.isCompressed) {
231
+ const byteOffset = rawOffset / 2;
232
+ for (let j = 0; j < charCount; j++) {
233
+ const bytePos = byteOffset + j;
234
+ if (bytePos >= wordDoc.length)
235
+ break;
236
+ const ch = cp1252ToChar(wordDoc[bytePos]);
237
+ if (ch) {
238
+ chars.push(ch);
239
+ cps.push(piece.cpStart + j);
240
+ }
241
+ }
242
+ }
243
+ else {
244
+ const byteOffset = rawOffset;
245
+ for (let j = 0; j < charCount; j++) {
246
+ const pos = byteOffset + j * 2;
247
+ if (pos + 1 >= wordDoc.length)
248
+ break;
249
+ chars.push(String.fromCharCode(wordView.getUint16(pos, true)));
250
+ cps.push(piece.cpStart + j);
251
+ }
252
+ }
253
+ }
254
+ // Pass 2: normalize control characters. Word's binary format uses them as
255
+ // structural markers (cell mark, line break, page break, field chars).
256
+ // Every kept character records the CP it represents.
257
+ let text = '';
258
+ const cpMap = [];
259
+ for (let i = 0; i < chars.length; i++) {
260
+ const ch = chars[i];
261
+ const code = ch.charCodeAt(0);
262
+ if (ch === '\r') {
263
+ text += '\n';
264
+ cpMap.push(cps[i]);
265
+ if (i + 1 < chars.length && chars[i + 1] === '\n')
266
+ i++; // collapse \r\n
267
+ }
268
+ else if (code === 0x07) {
269
+ text += '\t';
270
+ cpMap.push(cps[i]);
271
+ }
272
+ else if (code === 0x0b || code === 0x0c) {
273
+ text += '\n';
274
+ cpMap.push(cps[i]);
275
+ }
276
+ else if (code <= 0x06 || code === 0x08 || (code >= 0x0e && code <= 0x1f)) {
277
+ // structural marker: dropped from the normalized text
278
+ }
279
+ else {
280
+ text += ch;
281
+ cpMap.push(cps[i]);
282
+ }
283
+ }
284
+ return { text, cpMap };
285
+ }
286
+ // ─── Main: write anonymized .doc ─────────────────────────────────────────────
287
+ export async function writeAnonymizedDoc(buffer, replacements) {
288
+ const parsed = parseDocStreams(buffer);
289
+ const { container, wordDocEntry, tableEntry, wordDoc: origWordDoc, wordView: wv, tableDoc: origTable, ccpText, pieces } = parsed;
290
+ // Author/title/company metadata lives outside the text streams; scrub it always
291
+ scrubOleMetadataStreams(container);
292
+ if (replacements.length === 0) {
293
+ // Still scrub the associated-strings table (author, template paths) in place
294
+ scrubSttbfAssoc(origTable, wv);
295
+ wordDocEntry.content = origWordDoc;
296
+ tableEntry.content = origTable;
297
+ const bytes = CFB.write(container, { type: 'array' });
298
+ return new Blob([new Uint8Array(bytes)], { type: 'application/msword' });
299
+ }
300
+ // ── Read remaining FIB pointers ──
301
+ const fcPlcfBteChpx = wv.getInt32(0x00fa, true);
302
+ const lcbPlcfBteChpx = wv.getInt32(0x00fe, true);
303
+ const fcPlcfBtePapx = wv.getInt32(0x0102, true);
304
+ const lcbPlcfBtePapx = wv.getInt32(0x0106, true);
305
+ const fcPlcfSed = wv.getInt32(0x00ca, true);
306
+ const lcbPlcfSed = wv.getInt32(0x00ce, true);
307
+ // ── Map replacement offsets (normalized text space) to CP space ──
308
+ // The offsets the caller passes are positions in the text produced by
309
+ // readDocText. Normalization drops/collapses control characters, so those
310
+ // offsets shift against CPs; apply the recorded map to land exactly.
311
+ const { text, cpMap } = extractDocContent(parsed);
312
+ const cpReplacements = [];
313
+ for (const repl of normalizeReplacements(replacements)) {
314
+ if (repl.start < 0 || repl.end > text.length || repl.start >= repl.end) {
315
+ // Offsets that do not match the extracted text indicate a caller bug.
316
+ // Fail closed: never export a file whose redaction we cannot place.
317
+ throw new Error('Replacement offsets do not match the document text');
318
+ }
319
+ cpReplacements.push({
320
+ start: cpMap[repl.start],
321
+ end: cpMap[repl.end - 1] + 1,
322
+ replacement: repl.replacement,
323
+ });
324
+ }
325
+ // Main-text replacements go through the piece table (labeled placeholders).
326
+ // Replacements in subdocuments (footnotes, headers, comments) are redacted
327
+ // in place with length-preserving overwrites: shifting CPs there would
328
+ // require rewriting every subdocument PLC, so we destroy the characters
329
+ // instead of substituting placeholders.
330
+ const sorted = cpReplacements.filter((r) => r.end <= ccpText);
331
+ const subDocReplacements = cpReplacements.filter((r) => r.end > ccpText);
332
+ // ── Build new pieces ──
333
+ const appendStart = align512(origWordDoc.length);
334
+ let appendPos = appendStart;
335
+ const newPieces = [];
336
+ let newCp = 0;
337
+ let prevEnd = 0;
338
+ const appendedBytes = [];
339
+ for (const repl of sorted) {
340
+ // Unchanged text before replacement
341
+ if (repl.start > prevEnd) {
342
+ const unchanged = extractOriginalPieces(pieces, prevEnd, repl.start, newCp);
343
+ newPieces.push(...unchanged);
344
+ newCp += repl.start - prevEnd;
345
+ }
346
+ // Replacement text (appended as UTF-16LE, no compression flag)
347
+ const replText = repl.replacement;
348
+ if (replText.length > 0) {
349
+ newPieces.push({
350
+ cpStart: newCp,
351
+ cpEnd: newCp + replText.length,
352
+ fc: appendPos, // Unicode: fc = byte offset directly
353
+ isCompressed: false,
354
+ });
355
+ for (let i = 0; i < replText.length; i++) {
356
+ const code = replText.charCodeAt(i);
357
+ appendedBytes.push(code & 0xff, (code >> 8) & 0xff);
358
+ }
359
+ appendPos += replText.length * 2;
360
+ newCp += replText.length;
361
+ }
362
+ prevEnd = repl.end;
363
+ }
364
+ // Remaining unchanged main text
365
+ if (prevEnd < ccpText) {
366
+ const unchanged = extractOriginalPieces(pieces, prevEnd, ccpText, newCp);
367
+ newPieces.push(...unchanged);
368
+ newCp += ccpText - prevEnd;
369
+ }
370
+ const newCcpText = newCp;
371
+ const totalDelta = newCcpText - ccpText;
372
+ // Non-main-text pieces (footnotes, headers, etc.) - keep original FCs, shift CPs
373
+ for (const piece of pieces) {
374
+ if (piece.cpEnd <= ccpText)
375
+ continue;
376
+ const clampStart = Math.max(piece.cpStart, ccpText);
377
+ const cpDelta = clampStart - piece.cpStart;
378
+ const rawBase = piece.fc & 0x3fffffff;
379
+ const fc = (rawBase + 2 * cpDelta) | (piece.isCompressed ? 0x40000000 : 0);
380
+ newPieces.push({
381
+ cpStart: clampStart + totalDelta,
382
+ cpEnd: piece.cpEnd + totalDelta,
383
+ fc,
384
+ isCompressed: piece.isCompressed,
385
+ });
386
+ }
387
+ // ── Build new CLX ──
388
+ const newClx = buildClx(newPieces);
389
+ // ── Build extended WordDocument stream ──
390
+ const hasAppendedText = appendedBytes.length > 0;
391
+ const appendedData = new Uint8Array(appendedBytes);
392
+ const fkpStart = align512(appendStart + appendedData.length);
393
+ const chpxPageOff = fkpStart;
394
+ const papxPageOff = fkpStart + 512;
395
+ const newWordDocLen = hasAppendedText ? papxPageOff + 512 : origWordDoc.length;
396
+ const newWordDoc = new Uint8Array(newWordDocLen);
397
+ newWordDoc.set(origWordDoc);
398
+ if (hasAppendedText) {
399
+ newWordDoc.set(appendedData, appendStart);
400
+ newWordDoc.set(buildDefaultChpxFkp(appendStart, appendStart + appendedData.length), chpxPageOff);
401
+ newWordDoc.set(buildDefaultPapxFkp(appendStart, appendStart + appendedData.length), papxPageOff);
402
+ }
403
+ // ── Destroy replaced characters in the copied stream ──
404
+ // The new piece table stops referencing replaced ranges, but the bytes were
405
+ // copied above and would remain recoverable (data remanence). Overwrite them.
406
+ for (const repl of sorted) {
407
+ overwritePieceBytes(newWordDoc, pieces, repl.start, repl.end, 0x20);
408
+ }
409
+ // Subdocument ranges stay referenced by the piece table, so the overwrite
410
+ // itself is the redaction: the characters render as 'X'.
411
+ for (const repl of subDocReplacements) {
412
+ overwritePieceBytes(newWordDoc, pieces, repl.start, repl.end, 0x58);
413
+ }
414
+ // ── Build extended PlcBteChpx / PlcBtePapx ──
415
+ const origPlcChpx = readPlcBlob(origTable, fcPlcfBteChpx, lcbPlcfBteChpx);
416
+ const origPlcPapx = readPlcBlob(origTable, fcPlcfBtePapx, lcbPlcfBtePapx);
417
+ let newPlcChpx;
418
+ let newPlcPapx;
419
+ if (hasAppendedText) {
420
+ const chpxPageNum = chpxPageOff / 512;
421
+ const papxPageNum = papxPageOff / 512;
422
+ newPlcChpx = extendPlcBte(origPlcChpx, appendStart, appendStart + appendedData.length, chpxPageNum);
423
+ newPlcPapx = extendPlcBte(origPlcPapx, appendStart, appendStart + appendedData.length, papxPageNum);
424
+ }
425
+ else {
426
+ newPlcChpx = origPlcChpx;
427
+ newPlcPapx = origPlcPapx;
428
+ }
429
+ // ── Adjust PlcfSed CPs ──
430
+ let newPlcfSed = readPlcBlob(origTable, fcPlcfSed, lcbPlcfSed);
431
+ if (newPlcfSed.length > 0 && totalDelta !== 0) {
432
+ newPlcfSed = adjustPlcCPs(newPlcfSed, 12, ccpText, sorted, totalDelta);
433
+ }
434
+ // ── Build new Table stream ──
435
+ // Append modified structures at the end; update FIB pointers
436
+ const newTableLen = origTable.length + newClx.length + newPlcChpx.length + newPlcPapx.length + newPlcfSed.length;
437
+ const newTable = new Uint8Array(newTableLen);
438
+ newTable.set(origTable);
439
+ let tblOff = origTable.length;
440
+ const newFcClx = tblOff;
441
+ newTable.set(newClx, tblOff);
442
+ tblOff += newClx.length;
443
+ const newFcPlcChpx = tblOff;
444
+ newTable.set(newPlcChpx, tblOff);
445
+ tblOff += newPlcChpx.length;
446
+ const newFcPlcPapx = tblOff;
447
+ newTable.set(newPlcPapx, tblOff);
448
+ tblOff += newPlcPapx.length;
449
+ const newFcPlcfSed = tblOff;
450
+ newTable.set(newPlcfSed, tblOff);
451
+ tblOff += newPlcfSed.length;
452
+ // ── Update FIB in the new WordDocument stream ──
453
+ const fv = new DataView(newWordDoc.buffer, newWordDoc.byteOffset, newWordDoc.byteLength);
454
+ fv.setInt32(0x004c, newCcpText, true);
455
+ fv.setInt32(0x01a2, newFcClx, true);
456
+ fv.setInt32(0x01a6, newClx.length, true);
457
+ fv.setInt32(0x00fa, newFcPlcChpx, true);
458
+ fv.setInt32(0x00fe, newPlcChpx.length, true);
459
+ fv.setInt32(0x0102, newFcPlcPapx, true);
460
+ fv.setInt32(0x0106, newPlcPapx.length, true);
461
+ if (newPlcfSed.length > 0) {
462
+ fv.setInt32(0x00ca, newFcPlcfSed, true);
463
+ fv.setInt32(0x00ce, newPlcfSed.length, true);
464
+ }
465
+ // ── Scrub associated strings (author, last-saved-by, template/data paths) ──
466
+ scrubSttbfAssoc(newTable, fv);
467
+ // ── Write back to OLE2 container ──
468
+ // CFB.write sizes streams from entry.size, not content length; keep in sync
469
+ wordDocEntry.content = newWordDoc;
470
+ wordDocEntry.size = newWordDoc.length;
471
+ tableEntry.content = newTable;
472
+ tableEntry.size = newTable.length;
473
+ const output = CFB.write(container, { type: 'array' });
474
+ return new Blob([new Uint8Array(output)], { type: 'application/msword' });
475
+ }
476
+ // ─── Text extraction (read-only) ────────────────────────────────────────────
477
+ /**
478
+ * Extract plain text from a legacy .doc (OLE2/Compound File Binary) file.
479
+ * Parses the FIB and piece table to correctly extract text from both
480
+ * compressed (CP1252) and Unicode pieces. The result covers the whole
481
+ * document: main text plus footnotes, headers/footers, and comments, so PII
482
+ * in those regions is visible to detection.
483
+ */
484
+ export function readDocText(buffer) {
485
+ return extractDocContent(parseDocStreams(buffer)).text;
486
+ }
487
+ // ─── In-place byte redaction ─────────────────────────────────────────────────
488
+ /**
489
+ * Overwrite the bytes backing a CP range with a fill character, following the
490
+ * piece table (handles both CP1252 and UTF-16 pieces). Used to destroy
491
+ * original text that would otherwise remain recoverable in the output stream.
492
+ */
493
+ function overwritePieceBytes(target, pieces, cpStart, cpEnd, fillChar) {
494
+ for (const piece of pieces) {
495
+ const overlapStart = Math.max(cpStart, piece.cpStart);
496
+ const overlapEnd = Math.min(cpEnd, piece.cpEnd);
497
+ if (overlapStart >= overlapEnd)
498
+ continue;
499
+ const rawOffset = piece.fc & 0x3fffffff;
500
+ const cpDelta = overlapStart - piece.cpStart;
501
+ const count = overlapEnd - overlapStart;
502
+ if (piece.isCompressed) {
503
+ const byteStart = rawOffset / 2 + cpDelta;
504
+ for (let j = 0; j < count; j++) {
505
+ if (byteStart + j < target.length)
506
+ target[byteStart + j] = fillChar;
507
+ }
508
+ }
509
+ else {
510
+ const byteStart = rawOffset + 2 * cpDelta;
511
+ for (let j = 0; j < count; j++) {
512
+ const pos = byteStart + j * 2;
513
+ if (pos + 1 < target.length) {
514
+ target[pos] = fillChar;
515
+ target[pos + 1] = 0;
516
+ }
517
+ }
518
+ }
519
+ }
520
+ }
521
+ // ─── Metadata scrubbing ──────────────────────────────────────────────────────
522
+ const FC_STTBF_ASSOC = 0x019a;
523
+ const LCB_STTBF_ASSOC = 0x019e;
524
+ /**
525
+ * Scrub the associated-strings table (SttbfAssoc) in the Table stream. It
526
+ * holds the document author, last-saved-by name, attached template path and
527
+ * mail-merge data source path. Strings are space-filled in place so no
528
+ * structure moves; if the table cannot be parsed, it is blanked wholesale and
529
+ * disconnected via the FIB.
530
+ */
531
+ function scrubSttbfAssoc(table, fibView) {
532
+ const fc = fibView.getInt32(FC_STTBF_ASSOC, true);
533
+ const lcb = fibView.getInt32(LCB_STTBF_ASSOC, true);
534
+ if (lcb <= 0 || fc < 0 || fc + lcb > table.length)
535
+ return;
536
+ try {
537
+ const v = new DataView(table.buffer, table.byteOffset + fc, lcb);
538
+ let offset = 0;
539
+ const extended = v.getUint16(0, true) === 0xffff;
540
+ if (extended)
541
+ offset = 2;
542
+ const count = v.getUint16(offset, true);
543
+ const cbExtra = v.getUint16(offset + 2, true);
544
+ offset += 4;
545
+ if (count > 4096)
546
+ throw new Error('bad sttbf');
547
+ for (let i = 0; i < count; i++) {
548
+ let cch;
549
+ if (extended) {
550
+ cch = v.getUint16(offset, true);
551
+ offset += 2;
552
+ for (let j = 0; j < cch; j++) {
553
+ v.setUint16(offset + j * 2, 0x0020, true);
554
+ }
555
+ offset += cch * 2 + cbExtra;
556
+ }
557
+ else {
558
+ cch = v.getUint8(offset);
559
+ offset += 1;
560
+ for (let j = 0; j < cch; j++) {
561
+ v.setUint8(offset + j, 0x20);
562
+ }
563
+ offset += cch + cbExtra;
564
+ }
565
+ if (offset > lcb)
566
+ throw new Error('bad sttbf');
567
+ }
568
+ }
569
+ catch {
570
+ // Unknown layout: blank the whole range and drop the FIB reference
571
+ table.fill(0, fc, fc + lcb);
572
+ fibView.setInt32(LCB_STTBF_ASSOC, 0, true);
573
+ }
574
+ }
575
+ const OLE_META_STREAMS = ['SummaryInformation', 'DocumentSummaryInformation'];
576
+ /**
577
+ * Scrub the OLE property-set streams (author, title, company, manager, ...).
578
+ * String values are space-filled in place, preserving the structure exactly.
579
+ * If a stream cannot be parsed, it is deleted from the container instead of
580
+ * being passed through with unknown content.
581
+ */
582
+ function scrubOleMetadataStreams(container) {
583
+ for (const name of OLE_META_STREAMS) {
584
+ let path = '/' + name;
585
+ let entry = CFB.find(container, path);
586
+ if (!entry) {
587
+ path = name;
588
+ entry = CFB.find(container, path);
589
+ }
590
+ if (!entry?.content)
591
+ continue;
592
+ const bytes = toUint8Array(entry.content);
593
+ try {
594
+ scrubPropertySetStrings(bytes);
595
+ entry.content = bytes;
596
+ entry.size = bytes.length;
597
+ }
598
+ catch {
599
+ CFB.utils.cfb_del(container, path);
600
+ }
601
+ }
602
+ }
603
+ const VT_LPSTR = 0x001e;
604
+ const VT_LPWSTR = 0x001f;
605
+ const VT_VECTOR = 0x1000;
606
+ /** Space-fill every string-typed property value in an OLE property set stream. */
607
+ function scrubPropertySetStrings(bytes) {
608
+ const v = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
609
+ if (v.getUint16(0, true) !== 0xfffe)
610
+ throw new Error('bad property set');
611
+ const sectionCount = v.getUint32(24, true);
612
+ if (sectionCount < 1 || sectionCount > 4)
613
+ throw new Error('bad section count');
614
+ for (let s = 0; s < sectionCount; s++) {
615
+ const sectionOffset = v.getUint32(28 + s * 20 + 16, true);
616
+ const propCount = v.getUint32(sectionOffset + 4, true);
617
+ if (propCount > 4096)
618
+ throw new Error('bad property count');
619
+ for (let p = 0; p < propCount; p++) {
620
+ const propId = v.getUint32(sectionOffset + 8 + p * 8, true);
621
+ const propOffset = v.getUint32(sectionOffset + 8 + p * 8 + 4, true);
622
+ if (propId === 0 || propId === 1)
623
+ continue; // dictionary / codepage
624
+ scrubPropertyValue(bytes, v, sectionOffset + propOffset);
625
+ }
626
+ }
627
+ }
628
+ function scrubPropertyValue(bytes, v, offset) {
629
+ const type = v.getUint32(offset, true) & 0xffff;
630
+ if (type === VT_LPSTR || type === VT_LPWSTR) {
631
+ scrubPropertyString(bytes, v, offset + 4, type === VT_LPWSTR);
632
+ }
633
+ else if (type === (VT_VECTOR | VT_LPSTR) || type === (VT_VECTOR | VT_LPWSTR)) {
634
+ const count = v.getUint32(offset + 4, true);
635
+ if (count > 4096)
636
+ throw new Error('bad vector');
637
+ let cursor = offset + 8;
638
+ for (let i = 0; i < count; i++) {
639
+ cursor = scrubPropertyString(bytes, v, cursor, type === (VT_VECTOR | VT_LPWSTR));
640
+ }
641
+ }
642
+ }
643
+ /**
644
+ * Space-fill one length-prefixed property string, keeping the terminator.
645
+ * Returns the offset just past the string (padded to 4 bytes, as vector
646
+ * elements are).
647
+ */
648
+ function scrubPropertyString(bytes, v, offset, wide) {
649
+ const cch = v.getUint32(offset, true);
650
+ const dataStart = offset + 4;
651
+ const byteLength = wide ? cch * 2 : cch;
652
+ if (cch > 0x100000 || dataStart + byteLength > bytes.length)
653
+ throw new Error('bad string');
654
+ if (wide) {
655
+ for (let i = 0; i + 1 < cch; i++) {
656
+ bytes[dataStart + i * 2] = 0x20;
657
+ bytes[dataStart + i * 2 + 1] = 0;
658
+ }
659
+ }
660
+ else {
661
+ for (let i = 0; i + 1 < cch; i++) {
662
+ bytes[dataStart + i] = 0x20;
663
+ }
664
+ }
665
+ return dataStart + Math.ceil(byteLength / 4) * 4;
666
+ }
667
+ // ─── CP1252 decoding ─────────────────────────────────────────────────────────
668
+ const CP1252_MAP = {
669
+ 0x80: 0x20ac, 0x82: 0x201a, 0x83: 0x0192, 0x84: 0x201e,
670
+ 0x85: 0x2026, 0x86: 0x2020, 0x87: 0x2021, 0x88: 0x02c6,
671
+ 0x89: 0x2030, 0x8a: 0x0160, 0x8b: 0x2039, 0x8c: 0x0152,
672
+ 0x8e: 0x017d, 0x91: 0x2018, 0x92: 0x2019, 0x93: 0x201c,
673
+ 0x94: 0x201d, 0x95: 0x2022, 0x96: 0x2013, 0x97: 0x2014,
674
+ 0x98: 0x02dc, 0x99: 0x2122, 0x9a: 0x0161, 0x9b: 0x203a,
675
+ 0x9c: 0x0153, 0x9e: 0x017e, 0x9f: 0x0178,
676
+ };
677
+ function cp1252ToChar(byte) {
678
+ if (byte >= 0x80 && byte <= 0x9f) {
679
+ const mapped = CP1252_MAP[byte];
680
+ return mapped ? String.fromCharCode(mapped) : '';
681
+ }
682
+ return String.fromCharCode(byte);
683
+ }