@doccloak/core 0.12.1 → 0.14.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 (79) hide show
  1. package/CHANGELOG.md +386 -0
  2. package/NOTICE +5 -0
  3. package/README.md +148 -9
  4. package/dist/dom/errors.d.ts +1 -1
  5. package/dist/dom/ocr.d.ts +33 -7
  6. package/dist/dom/ocr.js +98 -4
  7. package/dist/dom/office.d.ts +22 -6
  8. package/dist/dom/office.js +45 -11
  9. package/dist/engine.js +4 -5
  10. package/dist/env.d.ts +3 -3
  11. package/dist/pdf/cmap.d.ts +83 -0
  12. package/dist/pdf/cmap.js +659 -0
  13. package/dist/pdf/encodings.d.ts +49 -0
  14. package/dist/pdf/encodings.js +181 -0
  15. package/dist/pdf/extract.d.ts +108 -0
  16. package/dist/pdf/extract.js +1164 -0
  17. package/dist/pdf/fallback-fonts.d.ts +146 -0
  18. package/dist/pdf/fallback-fonts.js +312 -0
  19. package/dist/pdf/fit.d.ts +35 -0
  20. package/dist/pdf/fit.js +60 -0
  21. package/dist/pdf/fonts.d.ts +82 -0
  22. package/dist/pdf/fonts.js +465 -0
  23. package/dist/pdf/glyphlist.d.ts +37 -0
  24. package/dist/pdf/glyphlist.js +485 -0
  25. package/dist/pdf/index.d.ts +21 -0
  26. package/dist/pdf/index.js +7 -0
  27. package/dist/pdf/lexer.d.ts +61 -0
  28. package/dist/pdf/lexer.js +554 -0
  29. package/dist/pdf/objects.d.ts +57 -0
  30. package/dist/pdf/objects.js +217 -0
  31. package/dist/pdf/oracle.d.ts +149 -0
  32. package/dist/pdf/oracle.js +384 -0
  33. package/dist/pdf/raster.d.ts +119 -0
  34. package/dist/pdf/raster.js +405 -0
  35. package/dist/pdf/read.d.ts +36 -0
  36. package/dist/pdf/read.js +65 -0
  37. package/dist/pdf/serializer.d.ts +21 -0
  38. package/dist/pdf/serializer.js +106 -0
  39. package/dist/pdf/standard-metrics.d.ts +27 -0
  40. package/dist/pdf/standard-metrics.js +72 -0
  41. package/dist/pdf/surgery.d.ts +131 -0
  42. package/dist/pdf/surgery.js +847 -0
  43. package/dist/pdf/textstate.d.ts +88 -0
  44. package/dist/pdf/textstate.js +293 -0
  45. package/dist/pdf/types.d.ts +206 -0
  46. package/dist/pdf/types.js +11 -0
  47. package/dist/pdf/verify.d.ts +33 -0
  48. package/dist/pdf/verify.js +446 -0
  49. package/dist/pdf/write.d.ts +62 -0
  50. package/dist/pdf/write.js +750 -0
  51. package/dist/pipeline.d.ts +15 -0
  52. package/dist/pipeline.js +123 -8
  53. package/dist/providers/bardsai.d.ts +1 -1
  54. package/dist/providers/bardsai.js +5 -1
  55. package/dist/providers/gliner-base.d.ts +1 -1
  56. package/dist/providers/gliner-base.js +5 -1
  57. package/dist/providers/gliner.d.ts +1 -1
  58. package/dist/providers/gliner.js +5 -1
  59. package/dist/regex/rules.data.js +13 -8
  60. package/dist/types.d.ts +17 -2
  61. package/dist/types.js +17 -0
  62. package/dist/worker-protocol.d.ts +11 -0
  63. package/dist/worker-protocol.js +41 -8
  64. package/fonts/liberation/LICENSE +102 -0
  65. package/fonts/liberation/LiberationMono-Bold.ttf +0 -0
  66. package/fonts/liberation/LiberationMono-BoldItalic.ttf +0 -0
  67. package/fonts/liberation/LiberationMono-Italic.ttf +0 -0
  68. package/fonts/liberation/LiberationMono-Regular.ttf +0 -0
  69. package/fonts/liberation/LiberationSans-Bold.ttf +0 -0
  70. package/fonts/liberation/LiberationSans-BoldItalic.ttf +0 -0
  71. package/fonts/liberation/LiberationSans-Italic.ttf +0 -0
  72. package/fonts/liberation/LiberationSans-Regular.ttf +0 -0
  73. package/fonts/liberation/LiberationSerif-Bold.ttf +0 -0
  74. package/fonts/liberation/LiberationSerif-BoldItalic.ttf +0 -0
  75. package/fonts/liberation/LiberationSerif-Italic.ttf +0 -0
  76. package/fonts/liberation/LiberationSerif-Regular.ttf +0 -0
  77. package/package.json +11 -2
  78. package/rules/pl.json +7 -3
  79. package/rules/universal.json +6 -5
@@ -0,0 +1,750 @@
1
+ /**
2
+ * @doccloak/core/pdf - writer (T213).
3
+ *
4
+ * writeAnonymizedPdfWithReport takes an extraction plus flat-text
5
+ * replacements and produces a redacted PDF:
6
+ *
7
+ * 1. re-extract the input bytes (deterministic) and confirm the flat text is
8
+ * unchanged, so offsets are trustworthy (StaleAnalysisError otherwise);
9
+ * 2. layer zero: every other occurrence of a session value in the text
10
+ * becomes a replacement too (as the docx writer scrubs all XML parts);
11
+ * 3. plan placeholders (font choice, fit) and rewrite the content streams;
12
+ * 4. scrub ToUnicode CMaps of edited fonts down to the codes still used,
13
+ * strip ActualText/Alt properties, drop annotations and page actions;
14
+ * 5. rebuild the output as a FRESH document (pdf-lib copyPages): only objects
15
+ * reachable from the kept pages survive; Info, XMP, outlines, forms,
16
+ * attachments, JavaScript, named destinations, structure tree and the
17
+ * incremental-update history of the input never reach the output;
18
+ * 6. pages that cannot be edited safely are rendered by pdf.js and replaced
19
+ * by an image with the redacted regions blacked out in the pixels; scan
20
+ * pages whose OCR text layer holds a redacted value are rendered at the
21
+ * scan's resolution with each value covered and its placeholder painted in;
22
+ * 7. verify the output with both extractors and a byte scan; refuse on any trace.
23
+ */
24
+ import { PDFDict, PDFDocument, PDFName, PDFRawStream, PDFString, concatTransformationMatrix, drawObject, popGraphicsState, pushGraphicsState } from '@cantoo/pdf-lib';
25
+ import { PDFRef } from '@cantoo/pdf-lib';
26
+ import { UnsupportedDocumentError } from "../dom/errors.js";
27
+ import { normalizeReplacements } from "../docx.js";
28
+ import { extractPdf } from "./extract.js";
29
+ import { FallbackFontEmbedder, FallbackFontProvider, faceFor } from "./fallback-fonts.js";
30
+ import { getDict, getStream, refOf, resolve } from "./objects.js";
31
+ import { findOccurrences, openWithOracle, searchIndex } from "./oracle.js";
32
+ import { rasterizePage, rasterizeScanPage } from "./raster.js";
33
+ import { serializeToUnicode } from "./cmap.js";
34
+ import { planEdits, rewriteStream, segmentsFor } from "./surgery.js";
35
+ import { PdfVerifyError, verifyPdf } from "./verify.js";
36
+ import { lexContent } from "./lexer.js";
37
+ /** Scan pages render at the scan's own resolution, up to this. */
38
+ const MAX_SCAN_DPI = 300;
39
+ /** Pixel budget of one rendered scan page (a 300 dpi A3 page fits; larger pages render coarser). */
40
+ const MAX_SCAN_PIXELS = 40000000;
41
+ /** Same normalised date the docx writer stamps. */
42
+ export const NORMALISED_PDF_DATE = new Date('2000-01-01T00:00:00Z');
43
+ export class StalePdfExtractionError extends Error {
44
+ constructor() {
45
+ super('The PDF bytes no longer produce the analysed text; re-run readPdf on this file.');
46
+ this.name = 'StalePdfExtractionError';
47
+ Object.setPrototypeOf(this, new.target.prototype);
48
+ }
49
+ }
50
+ export async function writeAnonymizedPdfWithReport(extraction, replacements, valueReplacements = [], options = {}) {
51
+ const warnings = [];
52
+ const assets = options.assets ?? extraction.assets ?? {};
53
+ validateReplacements(extraction.plainText, replacements, valueReplacements);
54
+ if (extraction.unredactable.length > 0 && !options.allowUnredactable) {
55
+ throw new UnsupportedDocumentError('unredactable-parts', `PDF holds parts DocCloak cannot redact: ${extraction.unredactable.map((u) => u.part).join(', ')}`, extraction.unredactable.map((u) => u.part));
56
+ }
57
+ for (const u of extraction.unredactable)
58
+ warnings.push(`${u.label} (exported as is)`);
59
+ // Steps 1-6 run in their own scope so the glyph model of the input (three copies of every
60
+ // glyph would otherwise live until the verifier is done) can be collected before verification.
61
+ const produced = await (async () => {
62
+ // 1. Fresh, deterministic re-extraction (the input bytes are the source of truth).
63
+ const oracle = await openWithOracle(extraction.bytes, assets, extraction.password);
64
+ let fresh;
65
+ try {
66
+ fresh = await extractPdf(extraction.bytes, { oracle, password: extraction.password, maxBytes: Number.MAX_SAFE_INTEGER, maxPages: Number.MAX_SAFE_INTEGER });
67
+ }
68
+ catch (err) {
69
+ await oracle.close();
70
+ throw err;
71
+ }
72
+ if (fresh.plainText !== extraction.plainText) {
73
+ await oracle.close();
74
+ throw new StalePdfExtractionError();
75
+ }
76
+ const model = fresh.model;
77
+ try {
78
+ // 2. Layer zero: every occurrence of every value.
79
+ const all = normalizeReplacements(withLayerZero(fresh.plainText, replacements, valueReplacements));
80
+ const needles = new Set();
81
+ for (const r of all)
82
+ needles.add(fresh.plainText.slice(r.start, r.end));
83
+ for (const v of valueReplacements)
84
+ if (v.value.trim().length >= 2)
85
+ needles.add(v.value);
86
+ // 3. Segments and plan.
87
+ const segments = segmentsFor(fresh.runs, all);
88
+ const provider = new FallbackFontProvider(assets);
89
+ const outDoc = await PDFDocument.create({ updateMetadata: false });
90
+ const embedder = new FallbackFontEmbedder(outDoc, provider, 'DCFb');
91
+ const usedResourceNames = collectResourceNames(model);
92
+ const resolver = async (font, text) => {
93
+ const own = font.encode(text);
94
+ if (own) {
95
+ return { bytes: own.bytes, widthPerEm: own.width, resourceName: '', originalFont: true, codes: own.codes };
96
+ }
97
+ const face = faceFor(font.style);
98
+ const embedded = await embedder.ensure(face);
99
+ if (!embedded.canShow(text)) {
100
+ warnings.push(`placeholder "${text}" holds characters the replacement font cannot show (for example CJK or emoji); they are written as '?'. Use Latin letters and digits in placeholders`);
101
+ text = [...text].map((ch) => (embedded.canShow(ch) ? ch : '?')).join('');
102
+ }
103
+ const bytes = embedded.encode(text);
104
+ const widthPerEm = embedded.measure(text, 1) * 1000;
105
+ const resourceName = uniqueResourceName(embedded.resourceName, usedResourceNames);
106
+ return { bytes, widthPerEm, resourceName, originalFont: false, codes: [] };
107
+ };
108
+ const plan = await planEdits(model, fresh.runs, segments, all, resolver, options.fit);
109
+ warnings.push(...plan.warnings);
110
+ // Which pages must be rasterized: unsafe pages that carry an edit.
111
+ const editedPages = new Set();
112
+ for (const s of segments)
113
+ editedPages.add(s.run.page);
114
+ const rasterPages = new Set();
115
+ for (const page of model.pages) {
116
+ if (!editedPages.has(page.index))
117
+ continue;
118
+ if (page.issues.length > 0 || page.oracleText)
119
+ rasterPages.add(page.index);
120
+ }
121
+ // Scan pages: an edit in an OCR text layer would leave the value in the picture under it, so the
122
+ // page is rendered and the value painted over where the layer says it is.
123
+ const scanPages = new Set();
124
+ for (const s of segments)
125
+ if (s.run.ocr && !rasterPages.has(s.run.page))
126
+ scanPages.add(s.run.page);
127
+ const paintedPages = new Set([...rasterPages, ...scanPages]);
128
+ // Text in a pattern cell or soft-mask group cannot be blacked out in a raster (the cell is tiled
129
+ // over its fill area, the group sits behind a mask): such a page must be edited in place or not at all.
130
+ const hostile = [...paintedPages].filter((p) => model.pages[p]?.hostile);
131
+ if (hostile.length > 0) {
132
+ throw new UnsupportedDocumentError('unredactable-parts', `pages ${hostile.map((p) => p + 1).join(', ')} hold content a viewer cannot render safely (rasterization refused)`, hostile.map((p) => `page ${p + 1}`));
133
+ }
134
+ const rasterWithAux = [...paintedPages].filter((p) => segments.some((sg) => sg.run.page === p && sg.run.space));
135
+ if (rasterWithAux.length > 0) {
136
+ throw new UnsupportedDocumentError('unredactable-parts', `pages ${rasterWithAux.map((p) => p + 1).join(', ')} hold redacted text inside a pattern or soft mask and cannot be rasterized safely`, rasterWithAux.map((p) => `page ${p + 1}`));
137
+ }
138
+ if (rasterPages.size > 0 && options.rasterizeUnsafePages === false) {
139
+ throw new UnsupportedDocumentError('unredactable-parts', `pages ${[...rasterPages].map((p) => p + 1).join(', ')} cannot be redacted without rasterization`, [...rasterPages].map((p) => `page ${p + 1}`));
140
+ }
141
+ // 4. Rewrite streams (skip streams that only belong to raster pages).
142
+ const rewrittenFor = new Map(); // page -> resource names to add
143
+ const usedCodes = new Map();
144
+ const removedSequences = [];
145
+ const editedFonts = new Set();
146
+ const runsByStreamOp = indexRuns(fresh.runs);
147
+ const streamsToRewrite = new Set();
148
+ for (const key of plan.byStream.keys())
149
+ streamsToRewrite.add(key);
150
+ for (const [key, stream] of model.streams) {
151
+ if (streamNeedsScrub(stream) || [...plan.shiftedRuns.keys()].some((k) => k.startsWith(`${key}#`)))
152
+ streamsToRewrite.add(key);
153
+ }
154
+ for (const key of streamsToRewrite) {
155
+ const stream = model.streams.get(key);
156
+ if (!stream)
157
+ continue;
158
+ if ([...stream.pages].every((p) => paintedPages.has(p)))
159
+ continue;
160
+ const segs = (plan.byStream.get(key) ?? []).filter((s) => !paintedPages.has(s.run.page));
161
+ const result = rewriteStream(model, stream, runsByStreamOp.get(key) ?? new Map(), segs, plan.shiftedRuns);
162
+ for (const [k, codes] of result.usedCodes)
163
+ mergeCodes(usedCodes, k, codes);
164
+ removedSequences.push(...result.removedSequences);
165
+ for (const f of result.editedFonts)
166
+ editedFonts.add(f);
167
+ replaceStreamBytes(model, stream, result.bytes);
168
+ const names = plan.resourcesNeeded.get(key);
169
+ if (names)
170
+ rewrittenFor.set(key, names);
171
+ }
172
+ // Codes of untouched streams still count as used.
173
+ for (const [key, stream] of model.streams) {
174
+ if (streamsToRewrite.has(key))
175
+ continue;
176
+ for (const run of runsByStreamOp.get(key)?.values() ?? [])
177
+ mergeCodes(usedCodes, run.fontKey, run.glyphs.map((g) => g.code));
178
+ void stream;
179
+ }
180
+ scrubToUnicode(model, editedFonts, usedCodes, warnings);
181
+ scrubSourceSideChannels(model);
182
+ // 5. Rebuild into the fresh document.
183
+ const keptIndexes = model.pages.map((p) => p.index).filter((i) => !paintedPages.has(i));
184
+ const copied = keptIndexes.length > 0 ? await outDoc.copyPages(model.doc, keptIndexes) : [];
185
+ const copiedByIndex = new Map();
186
+ keptIndexes.forEach((idx, k) => copiedByIndex.set(idx, copied[k]));
187
+ const rasterized = [];
188
+ const scanned = [];
189
+ const sortedReplacements = [...all].sort((a, b) => a.start - b.start || a.end - b.end); // segmentsFor's order
190
+ for (const page of model.pages) {
191
+ if (scanPages.has(page.index)) {
192
+ const marks = paintMarksForPage(segments, page.index, sortedReplacements);
193
+ let raster;
194
+ try {
195
+ raster = await rasterizeScanPage(oracle, page.index, marks, { dpi: scanDpi(page, options.rasterDpi), canvasFactory: options.canvasFactory });
196
+ }
197
+ catch (err) {
198
+ throw new UnsupportedDocumentError('unredactable-parts', `page ${page.index + 1} cannot be rendered: ${err.message ?? String(err)}`, [`page ${page.index + 1}`]);
199
+ }
200
+ // Raw pixels in a Flate image (gray for a gray scan): lossless, and far smaller than an RGBA PNG.
201
+ const image = outDoc.context.register(outDoc.context.flateStream(raster.pixels, {
202
+ Type: 'XObject',
203
+ Subtype: 'Image',
204
+ Width: raster.widthPx,
205
+ Height: raster.heightPx,
206
+ ColorSpace: raster.channels === 1 ? 'DeviceGray' : 'DeviceRGB',
207
+ BitsPerComponent: 8,
208
+ }));
209
+ const newPage = outDoc.addPage([raster.widthPt, raster.heightPt]);
210
+ const name = newPage.node.newXObject('Scan', image);
211
+ newPage.pushOperators(pushGraphicsState(), concatTransformationMatrix(raster.widthPt, 0, 0, raster.heightPt, 0, 0), drawObject(name), popGraphicsState());
212
+ scanned.push(page.index);
213
+ warnings.push(`page ${page.index + 1}: scan with an OCR text layer; the values are painted over in the picture with their placeholders and the page is exported as an image (its text layer is gone)`);
214
+ continue;
215
+ }
216
+ if (rasterPages.has(page.index)) {
217
+ const boxes = boxesForPage(segments, page.index, fresh.runs, page, [...needles]);
218
+ const dpi = options.rasterDpi ?? 150;
219
+ let raster;
220
+ try {
221
+ raster = await rasterizePage(oracle, page.index, boxes, { dpi, canvasFactory: options.canvasFactory });
222
+ }
223
+ catch (err) {
224
+ // pdf.js (or the canvas) could not render the page: nothing safe can be exported for it.
225
+ throw new UnsupportedDocumentError('unredactable-parts', `page ${page.index + 1} cannot be rasterized: ${err.message ?? String(err)}`, [`page ${page.index + 1}`]);
226
+ }
227
+ const img = await outDoc.embedPng(raster.png);
228
+ const newPage = outDoc.addPage([raster.widthPt, raster.heightPt]);
229
+ newPage.drawImage(img, { x: 0, y: 0, width: raster.widthPt, height: raster.heightPt });
230
+ rasterized.push(page.index);
231
+ warnings.push(`page ${page.index + 1}: rasterized (${page.issues[0] ?? 'unsafe to edit'}); its text layer is gone`);
232
+ continue;
233
+ }
234
+ const cp = copiedByIndex.get(page.index);
235
+ if (!cp)
236
+ continue;
237
+ outDoc.addPage(cp);
238
+ // Fallback font resources for this page's streams.
239
+ for (const key of page.streams) {
240
+ const names = rewrittenFor.get(key);
241
+ if (!names)
242
+ continue;
243
+ const stream = model.streams.get(key);
244
+ if (!stream)
245
+ continue;
246
+ for (const name of names) {
247
+ const font = embedder.fonts().find((f) => name === f.resourceName || name.startsWith(`${f.resourceName}x`));
248
+ if (!font)
249
+ continue;
250
+ addFontResource(outDoc, cp.node, stream, name, font.pdfFont.ref, model);
251
+ }
252
+ }
253
+ }
254
+ // Optional-content (layer) configuration is not carried over: pdf-lib's page copier would duplicate
255
+ // the OCG dictionaries, leaving the configuration pointing at orphans. Hidden layers become visible,
256
+ // which is the safe direction for a redaction (nothing stays hidden); reported as 'optional-content'.
257
+ scrubOutputObjects(outDoc);
258
+ stampMetadata(outDoc);
259
+ const bytes = await outDoc.save({ useObjectStreams: false, updateFieldAppearances: false, addDefaultPage: false });
260
+ return { bytes, needles: [...needles], removedSequences, rasterized, scanned, shiftedLines: plan.shiftedLines, removed: fresh.removed, placeholders: [...new Set(all.map((r) => r.replacement))] };
261
+ }
262
+ finally {
263
+ await oracle.close();
264
+ }
265
+ })();
266
+ // 7. Verify.
267
+ if (!options.skipVerify) {
268
+ const outOracle = await openWithOracle(produced.bytes, assets);
269
+ let findings;
270
+ try {
271
+ findings = await verifyPdf({ bytes: produced.bytes, needles: produced.needles, removedSequences: produced.removedSequences, oracle: outOracle, placeholders: produced.placeholders });
272
+ }
273
+ finally {
274
+ await outOracle.close();
275
+ }
276
+ if (findings.length > 0)
277
+ throw new PdfVerifyError(findings);
278
+ }
279
+ return {
280
+ blob: new Blob([produced.bytes], { type: 'application/pdf' }),
281
+ warnings: [...new Set(warnings)],
282
+ removed: produced.removed,
283
+ rasterizedPages: produced.rasterized,
284
+ scanPages: produced.scanned,
285
+ shiftedLines: produced.shiftedLines,
286
+ };
287
+ }
288
+ /* ------------------------------------------------------------------ */
289
+ function withLayerZero(text, replacements, values) {
290
+ const out = [...replacements];
291
+ const mark = new Uint8Array(text.length + 1);
292
+ for (const r of replacements)
293
+ mark.fill(1, Math.max(0, r.start), Math.min(text.length, r.end));
294
+ const covered = (a, b) => {
295
+ for (let i = a; i < b; i++)
296
+ if (mark[i])
297
+ return true;
298
+ return false;
299
+ };
300
+ // Every other occurrence of a value (found the way the verifier looks for it: whitespace, hyphens
301
+ // and ligatures ignored, case folded, at token boundaries) becomes a replacement too, so a value
302
+ // wrapped over a line end, spaced differently by the layout or set with a ligature never survives.
303
+ const index = searchIndex(text);
304
+ // Longest value first: "Zielinski Kowalski" must win over "Zielinski", or the surname would stay.
305
+ const ordered = [...values].sort((a, b) => b.value.length - a.value.length);
306
+ for (const v of ordered) {
307
+ for (const [start, end] of findOccurrences(index, v.value)) {
308
+ if (covered(start, end))
309
+ continue;
310
+ out.push({ start, end, replacement: v.replacement });
311
+ mark.fill(1, start, end);
312
+ }
313
+ }
314
+ return out;
315
+ }
316
+ /**
317
+ * Offsets that do not belong to this text are a stale or foreign analysis: refuse rather than
318
+ * clamp (a range clamped to the whole document would redact everything, silently).
319
+ */
320
+ function validateReplacements(text, replacements, values) {
321
+ for (const r of replacements) {
322
+ if (typeof r.replacement !== 'string')
323
+ throw new TypeError('replacement text must be a string');
324
+ if (!Number.isInteger(r.start) || !Number.isInteger(r.end) || r.start < 0 || r.end > text.length || r.start > r.end) {
325
+ throw new StalePdfExtractionError();
326
+ }
327
+ }
328
+ for (const v of values) {
329
+ if (typeof v.value !== 'string' || typeof v.replacement !== 'string')
330
+ throw new TypeError('value replacements must be strings');
331
+ }
332
+ }
333
+ function indexRuns(runs) {
334
+ const out = new Map();
335
+ for (const run of runs) {
336
+ if (run.oracle)
337
+ continue;
338
+ let m = out.get(run.streamKey);
339
+ if (!m) {
340
+ m = new Map();
341
+ out.set(run.streamKey, m);
342
+ }
343
+ if (!m.has(run.opIndex))
344
+ m.set(run.opIndex, run);
345
+ }
346
+ return out;
347
+ }
348
+ function mergeCodes(target, key, codes) {
349
+ let set = target.get(key);
350
+ if (!set) {
351
+ set = new Set();
352
+ target.set(key, set);
353
+ }
354
+ for (const c of codes)
355
+ set.add(c);
356
+ }
357
+ function streamNeedsScrub(stream) {
358
+ return stream.ops.some((op) => op.op === 'BDC' && op.operands[1]?.kind === 'dict'
359
+ && ['ActualText', 'Alt', 'E'].some((k) => op.operands[1].entries.has(k)));
360
+ }
361
+ function collectResourceNames(model) {
362
+ const names = new Set();
363
+ for (const stream of model.streams.values()) {
364
+ const fonts = getDict(model.ctx, stream.resources, 'Font');
365
+ if (fonts)
366
+ for (const [k] of fonts.entries())
367
+ names.add(k.decodeText());
368
+ }
369
+ return names;
370
+ }
371
+ function uniqueResourceName(base, used) {
372
+ if (!used.has(base))
373
+ return base;
374
+ let i = 2;
375
+ while (used.has(`${base}x${i}`))
376
+ i++;
377
+ return `${base}x${i}`;
378
+ }
379
+ /** Replace a stream's bytes in the source document (page contents or form XObject). */
380
+ function replaceStreamBytes(model, stream, bytes) {
381
+ const { ctx } = model;
382
+ if (stream.kind === 'page') {
383
+ const page = model.pages[stream.page];
384
+ const ref = ctx.register(ctx.flateStream(bytes));
385
+ page.dict.set(PDFName.of('Contents'), ref);
386
+ return;
387
+ }
388
+ if (stream.ref && stream.stream) {
389
+ const dict = {};
390
+ for (const [k, v] of stream.stream.dict.entries()) {
391
+ const key = k.decodeText();
392
+ if (key === 'Length' || key === 'Filter' || key === 'DecodeParms')
393
+ continue;
394
+ dict[key] = v;
395
+ }
396
+ const fresh = ctx.flateStream(bytes, dict);
397
+ ctx.assign(stream.ref, fresh);
398
+ stream.stream = fresh;
399
+ }
400
+ }
401
+ /** Rewrite the ToUnicode CMap of every edited font so only codes still shown remain. */
402
+ function scrubToUnicode(model, editedFonts, usedCodes, warnings) {
403
+ const { ctx } = model;
404
+ for (const key of editedFonts) {
405
+ const font = model.fonts.get(key);
406
+ if (!font)
407
+ continue;
408
+ const tuRef = refOf(font.dict, 'ToUnicode');
409
+ const tu = getStream(ctx, font.dict, 'ToUnicode');
410
+ if (!tu)
411
+ continue;
412
+ if (!font.toUnicode?.unicodeMap) {
413
+ // Unparsable map on an edited font: drop it rather than ship a CMap we could not read.
414
+ font.dict.delete(PDFName.of('ToUnicode'));
415
+ warnings.push(`font ${font.baseFont}: unreadable ToUnicode map removed`);
416
+ continue;
417
+ }
418
+ const used = usedCodes.get(key) ?? new Set();
419
+ const entries = [];
420
+ for (const [code, unicode] of font.toUnicode.unicodeMap) {
421
+ if (!used.has(code))
422
+ continue;
423
+ entries.push({ code, length: font.codeLength(code), unicode });
424
+ }
425
+ const bytes = serializeToUnicode(entries, { name: 'DocCloak-ToUnicode', codespace: font.toUnicode.codespace });
426
+ // Always a new object: several font dicts may share one ToUnicode stream, and each edited font
427
+ // keeps a different code set.
428
+ font.dict.set(PDFName.of('ToUnicode'), ctx.register(ctx.flateStream(bytes)));
429
+ void tuRef;
430
+ }
431
+ }
432
+ /** Remove per-page side channels and named marked-content properties in the source before copying. */
433
+ function scrubSourceSideChannels(model) {
434
+ const { ctx } = model;
435
+ const pageKeys = ['Annots', 'AA', 'Metadata', 'Thumb', 'StructParents', 'PieceInfo', 'B', 'Dur', 'Trans', 'PZ', 'SeparationInfo', 'VP', 'AF', 'OutputIntents'];
436
+ for (const page of model.pages) {
437
+ for (const k of pageKeys)
438
+ page.dict.delete(PDFName.of(k));
439
+ }
440
+ const seen = new Set();
441
+ for (const stream of model.streams.values()) {
442
+ const props = getDict(ctx, stream.resources, 'Properties');
443
+ if (!props)
444
+ continue;
445
+ for (const [, v] of props.entries()) {
446
+ const d = resolve(ctx, v);
447
+ if (d && typeof d.delete === 'function' && !seen.has(d)) {
448
+ seen.add(d);
449
+ for (const k of ['ActualText', 'Alt', 'E'])
450
+ d.delete(PDFName.of(k));
451
+ }
452
+ }
453
+ // Resource-level XObjects/Fonts keep their dicts; ToUnicode handled above.
454
+ }
455
+ }
456
+ /** Add a fallback font under `name` to the resources of the (copied) stream: page resources or the form's own. */
457
+ function addFontResource(outDoc, pageNode, stream, name, ref, model) {
458
+ const ctx = outDoc.context;
459
+ if (stream.kind === 'page' || !stream.ownResources) {
460
+ // A form without /Resources of its own resolves names through the page's resources (pdf.js and
461
+ // Acrobat both fall back to the parent), so that is where its fallback font must live.
462
+ pageNode.setFontDictionary(PDFName.of(name), ref);
463
+ return;
464
+ }
465
+ // Form XObject: every copied stream with the same bytes (duplicated forms share the edit).
466
+ for (const target of findCopiedXObjects(outDoc, pageNode, stream, model)) {
467
+ let res = getDict(ctx, target, 'Resources');
468
+ if (!res) {
469
+ res = ctx.obj({});
470
+ target.set(PDFName.of('Resources'), res);
471
+ }
472
+ let fonts = getDict(ctx, res, 'Font');
473
+ if (!fonts) {
474
+ fonts = ctx.obj({});
475
+ res.set(PDFName.of('Font'), fonts);
476
+ }
477
+ fonts.set(PDFName.of(name), ref);
478
+ }
479
+ }
480
+ function findCopiedXObjects(outDoc, pageNode, stream, model) {
481
+ // Match by BBox + Matrix + decoded length is fragile; instead walk every XObject reachable from the copied
482
+ // page and compare the (already rewritten) stream bytes with the source stream's bytes.
483
+ const ctx = outDoc.context;
484
+ const wanted = stream.stream instanceof PDFRawStream ? stream.stream.contents : undefined;
485
+ const wantedLen = wanted?.length ?? -1;
486
+ const visited = new Set();
487
+ const queue = [];
488
+ const found = [];
489
+ const pageRes = getDict(ctx, pageNode, 'Resources');
490
+ if (pageRes)
491
+ queue.push(pageRes);
492
+ while (queue.length) {
493
+ const res = queue.shift();
494
+ if (visited.has(res))
495
+ continue;
496
+ visited.add(res);
497
+ const candidates = [];
498
+ const xobjs = getDict(ctx, res, 'XObject');
499
+ if (xobjs)
500
+ for (const [, v] of xobjs.entries())
501
+ candidates.push(v);
502
+ const patterns = getDict(ctx, res, 'Pattern');
503
+ if (patterns)
504
+ for (const [, v] of patterns.entries())
505
+ candidates.push(v);
506
+ const states = getDict(ctx, res, 'ExtGState');
507
+ if (states) {
508
+ for (const [, v] of states.entries()) {
509
+ const d = resolve(ctx, v);
510
+ const mask = d instanceof PDFDict ? getDict(ctx, d, 'SMask') : undefined;
511
+ const g = mask?.get(PDFName.of('G'));
512
+ if (g)
513
+ candidates.push(g);
514
+ }
515
+ }
516
+ for (const v of candidates) {
517
+ const s = resolve(ctx, v);
518
+ if (!(s instanceof PDFRawStream))
519
+ continue;
520
+ if (s.contents.length === wantedLen && wanted && sameBytes(s.contents, wanted)) {
521
+ if (!found.includes(s.dict))
522
+ found.push(s.dict);
523
+ continue;
524
+ }
525
+ const inner = getDict(ctx, s.dict, 'Resources');
526
+ if (inner)
527
+ queue.push(inner);
528
+ }
529
+ }
530
+ void model;
531
+ return found;
532
+ }
533
+ function sameBytes(a, b) {
534
+ if (a.length !== b.length)
535
+ return false;
536
+ for (let i = 0; i < a.length; i++)
537
+ if (a[i] !== b[i])
538
+ return false;
539
+ return true;
540
+ }
541
+ /**
542
+ * Side channels that pdf-lib's page copier carries along with the objects a page references:
543
+ * XMP streams on XObjects, fonts or anything else (/Metadata), optional-content group names
544
+ * (/Name of an OCG or OCMD), font descriptor strings (/FontFamily, /CharSet) and /PieceInfo.
545
+ * None of them is needed to render the page; all of them can carry text.
546
+ */
547
+ function scrubOutputObjects(outDoc) {
548
+ const ctx = outDoc.context;
549
+ for (const [, obj] of ctx.enumerateIndirectObjects()) {
550
+ const dict = obj instanceof PDFRawStream ? obj.dict : obj instanceof PDFDict ? obj : null;
551
+ if (!dict)
552
+ continue;
553
+ scrubDict(ctx, dict, new Set());
554
+ }
555
+ }
556
+ function scrubDict(ctx, dict, seen) {
557
+ if (seen.has(dict))
558
+ return;
559
+ seen.add(dict);
560
+ const type = dict.get(PDFName.of('Type'));
561
+ const typeName = type instanceof PDFName ? type.decodeText() : '';
562
+ // pdf-lib writes every indirect object of the context, reachable or not: an object dropped
563
+ // here must also leave the context, or its bytes would still ship.
564
+ for (const key of ['Metadata', 'PieceInfo']) {
565
+ const v = dict.get(PDFName.of(key));
566
+ if (v === undefined)
567
+ continue;
568
+ dict.delete(PDFName.of(key));
569
+ if (v instanceof PDFRef)
570
+ ctx.delete(v);
571
+ }
572
+ if (typeName === 'OCG' || typeName === 'OCMD')
573
+ dict.set(PDFName.of('Name'), PDFString.of('Layer'));
574
+ if (typeName === 'FontDescriptor') {
575
+ dict.delete(PDFName.of('FontFamily'));
576
+ dict.delete(PDFName.of('CharSet'));
577
+ }
578
+ for (const [, v] of dict.entries()) {
579
+ const inner = v instanceof PDFDict ? v : undefined; // direct dictionaries only; indirect ones are enumerated
580
+ if (inner)
581
+ scrubDict(ctx, inner, seen);
582
+ }
583
+ }
584
+ function stampMetadata(outDoc) {
585
+ outDoc.setTitle('');
586
+ outDoc.setAuthor('');
587
+ outDoc.setSubject('');
588
+ outDoc.setKeywords([]);
589
+ outDoc.setProducer('DocCloak');
590
+ outDoc.setCreator('DocCloak');
591
+ outDoc.setCreationDate(NORMALISED_PDF_DATE);
592
+ outDoc.setModificationDate(NORMALISED_PDF_DATE);
593
+ }
594
+ /** Resolution for a scan page: the scan's own (at least `base`, at most 300 dpi), within the pixel budget. */
595
+ function scanDpi(page, base = 150) {
596
+ const dpi = Math.max(base, Math.min(MAX_SCAN_DPI, Math.round(page.imageDpi)));
597
+ const squareInches = Math.max(1e-6, (page.info.width / 72) * (page.info.height / 72));
598
+ return Math.max(36, Math.min(dpi, Math.floor(Math.sqrt(MAX_SCAN_PIXELS / squareInches))));
599
+ }
600
+ /**
601
+ * Where each redacted value sits on a scan page, for painting: one mark per line a value
602
+ * occupies (the pieces of a value on one line, split across operators, join into one), the
603
+ * placeholder on the value's first line and a blank cover on the lines it wraps onto.
604
+ * Whitespace glyphs at either end of a piece are left out.
605
+ */
606
+ export function paintMarksForPage(segments, page, replacements) {
607
+ const marks = [];
608
+ let lastIndex = -1;
609
+ for (const s of segments) {
610
+ const run = s.run;
611
+ if (run.page !== page || run.space)
612
+ continue;
613
+ let from = s.from;
614
+ let to = s.to;
615
+ while (from < to && run.glyphs[from].unicode.trim() === '')
616
+ from++;
617
+ while (to > from && run.glyphs[to - 1].unicode.trim() === '')
618
+ to--;
619
+ if (from >= to)
620
+ continue;
621
+ const exLen = Math.hypot(run.emX[0], run.emX[1]) || 1;
622
+ const eyLen = Math.hypot(run.emY[0], run.emY[1]) || 1;
623
+ const ux = run.emX[0] / exLen;
624
+ const uy = run.emX[1] / exLen;
625
+ const vx = run.emY[0] / eyLen;
626
+ const vy = run.emY[1] / eyLen;
627
+ const scale = exLen / (run.local.fontSize * run.local.hscale || 1);
628
+ const first = run.glyphs[from];
629
+ let lo = 0;
630
+ let hi = 0;
631
+ let size = 0;
632
+ for (let i = from; i < to; i++) {
633
+ const g = run.glyphs[i];
634
+ const along = (g.x - first.x) * ux + (g.y - first.y) * uy;
635
+ lo = Math.min(lo, along);
636
+ hi = Math.max(hi, along + g.advance * scale);
637
+ size = Math.max(size, g.fontSize);
638
+ }
639
+ const x = first.x + ux * lo;
640
+ const y = first.y + uy * lo;
641
+ const prev = marks[marks.length - 1];
642
+ if (prev && s.replacementIndex === lastIndex) {
643
+ // Same value: join when this piece continues the previous one's line.
644
+ const across = (x - prev.x) * prev.vx + (y - prev.y) * prev.vy;
645
+ const along = (x - prev.x) * prev.ux + (y - prev.y) * prev.uy;
646
+ const lineSize = Math.max(prev.size, size);
647
+ if (Math.abs(across) < 0.4 * lineSize && along > -0.5 * lineSize && along < prev.length + 2 * lineSize) {
648
+ prev.length = Math.max(prev.length, along + (hi - lo));
649
+ prev.size = lineSize;
650
+ continue;
651
+ }
652
+ }
653
+ const label = s.replacementIndex === lastIndex ? '' : replacements[s.replacementIndex]?.replacement ?? '';
654
+ marks.push({ x, y, ux, uy, vx, vy, length: hi - lo, size, label });
655
+ lastIndex = s.replacementIndex;
656
+ }
657
+ return marks;
658
+ }
659
+ /**
660
+ * Axis-aligned user-space boxes of every covered glyph on a page (for rasterization). On a page
661
+ * whose text came from pdf.js, our own decoder's glyphs that spell a value are blacked out as
662
+ * well (pdf.js item widths can be wrong for fonts it misreads), and each pdf.js item box is
663
+ * padded by half an em along the baseline.
664
+ */
665
+ export function boxesForPage(segments, page, runs, record, needles = []) {
666
+ void runs;
667
+ const boxes = [];
668
+ const covered = [];
669
+ for (const s of segments) {
670
+ if (s.run.page !== page || s.run.space)
671
+ continue;
672
+ // Oracle runs (raster-only pages) only know the item's total width, not per-glyph widths: black
673
+ // out one glyph more on each side (plus the half-em pad below) so proportional-width errors
674
+ // cannot leave a fragment; a vertical item is covered whole.
675
+ if (s.run.oracle && s.run.vertical)
676
+ covered.push({ run: s.run, from: 0, to: s.run.glyphs.length });
677
+ else if (s.run.oracle)
678
+ covered.push({ run: s.run, from: Math.max(0, s.from - 1), to: Math.min(s.run.glyphs.length, s.to + 1) });
679
+ else
680
+ covered.push({ run: s.run, from: s.from, to: s.to });
681
+ }
682
+ if (record?.ownRuns && record.ownText !== undefined && needles.length > 0) {
683
+ const index = searchIndex(record.ownText);
684
+ for (const needle of needles) {
685
+ for (const [start, end] of findOccurrences(index, needle)) {
686
+ for (const run of record.ownRuns) {
687
+ if (run.space || run.textEnd <= start || run.textStart >= end)
688
+ continue;
689
+ let from = -1;
690
+ let to = -1;
691
+ run.textOffsets.forEach((o, gi) => {
692
+ const len = run.glyphs[gi].unicode.length;
693
+ if (len > 0 && o < end && o + len > start) {
694
+ if (from < 0)
695
+ from = gi;
696
+ to = gi + 1;
697
+ }
698
+ });
699
+ if (from >= 0)
700
+ covered.push({ run, from, to });
701
+ }
702
+ }
703
+ }
704
+ }
705
+ for (const { run, from, to } of covered) {
706
+ if (run.oracle && run.vertical) {
707
+ // pdf.js vertical item: origin (e, f), width along (a, b), height along (c, d); cover the
708
+ // whole rectangle in both directions of each axis (the origin corner is not specified).
709
+ const g = run.glyphs[0];
710
+ const w = run.local.advances[0] === undefined ? g.advance : run.itemWidth ?? g.advance;
711
+ const h = run.glyphs.length * g.advance;
712
+ const ax = run.emY[0], ay = run.emY[1];
713
+ const al = Math.hypot(ax, ay) || 1;
714
+ const bx = run.emX[0], by = run.emX[1];
715
+ const bl = Math.hypot(bx, by) || 1;
716
+ const xs = [g.x - (ax / al) * w - (bx / bl) * h, g.x + (ax / al) * w + (bx / bl) * h];
717
+ const ys = [g.y - (ay / al) * w - (by / bl) * h, g.y + (ay / al) * w + (by / bl) * h];
718
+ boxes.push({ x0: Math.min(...xs) - 1, y0: Math.min(...ys) - 1, x1: Math.max(...xs) + 1, y1: Math.max(...ys) + 1 });
719
+ continue;
720
+ }
721
+ const exLen = Math.hypot(run.emX[0], run.emX[1]) || 1;
722
+ const ux = run.emX[0] / exLen;
723
+ const uy = run.emX[1] / exLen;
724
+ const eyLen = Math.hypot(run.emY[0], run.emY[1]) || 1;
725
+ const vx = run.emY[0] / eyLen;
726
+ const vy = run.emY[1] / eyLen;
727
+ for (let i = from; i < to; i++) {
728
+ const g = run.glyphs[i];
729
+ const w = run.oracle ? g.advance : g.advance * (exLen / (run.local.fontSize * run.local.hscale || 1));
730
+ const asc = 0.9 * g.fontSize;
731
+ const desc = 0.3 * g.fontSize;
732
+ const pad = run.oracle ? 0.5 * g.fontSize : 0.5;
733
+ const corners = [
734
+ [g.x - ux * pad - vx * desc, g.y - uy * pad - vy * desc],
735
+ [g.x + ux * (w + pad) - vx * desc, g.y + uy * (w + pad) - vy * desc],
736
+ [g.x - ux * pad + vx * asc, g.y - uy * pad + vy * asc],
737
+ [g.x + ux * (w + pad) + vx * asc, g.y + uy * (w + pad) + vy * asc],
738
+ ];
739
+ boxes.push({
740
+ x0: Math.min(...corners.map((c) => c[0])),
741
+ y0: Math.min(...corners.map((c) => c[1])),
742
+ x1: Math.max(...corners.map((c) => c[0])),
743
+ y1: Math.max(...corners.map((c) => c[1])),
744
+ });
745
+ }
746
+ }
747
+ return boxes;
748
+ }
749
+ // Re-exported for tests that build synthetic streams.
750
+ export { lexContent };