@patrickdk77/pdf-defuse 0.0.0-stage → 0.1.2

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/dist/walker.js ADDED
@@ -0,0 +1,2914 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.Walker = exports.RUN_OPTIONS = exports.NestedIoError = exports.NestingLimitError = void 0;
37
+ exports.stopsTree = stopsTree;
38
+ const node_crypto_1 = require("node:crypto");
39
+ const promises_1 = require("node:stream/promises");
40
+ const zlib = __importStar(require("node:zlib"));
41
+ const document_1 = require("./document");
42
+ const filters_1 = require("./filters");
43
+ const findings_1 = require("./findings");
44
+ const io_1 = require("./io");
45
+ const objects_1 = require("./objects");
46
+ const sniff_1 = require("./sniff");
47
+ const types_1 = require("./types");
48
+ const uri_1 = require("./uri");
49
+ const writer_1 = require("./writer");
50
+ const ACTION_TYPES = new Set([
51
+ 'GoTo',
52
+ 'GoToR',
53
+ 'GoToE',
54
+ 'GoToDp',
55
+ 'Launch',
56
+ 'Thread',
57
+ 'URI',
58
+ 'Sound',
59
+ 'Movie',
60
+ 'Hide',
61
+ 'Named',
62
+ 'SubmitForm',
63
+ 'ResetForm',
64
+ 'ImportData',
65
+ 'JavaScript',
66
+ 'SetOCGState',
67
+ 'Rendition',
68
+ 'Trans',
69
+ 'GoTo3DView',
70
+ 'RichMediaExecute',
71
+ ]);
72
+ const ANNOT_ALLOWED = new Set([
73
+ 'Link',
74
+ 'Widget',
75
+ 'Text',
76
+ 'Popup',
77
+ 'FreeText',
78
+ 'Line',
79
+ 'Square',
80
+ 'Circle',
81
+ 'Polygon',
82
+ 'PolyLine',
83
+ 'Highlight',
84
+ 'Underline',
85
+ 'Squiggly',
86
+ 'StrikeOut',
87
+ 'Stamp',
88
+ 'Caret',
89
+ 'Ink',
90
+ 'Redact',
91
+ 'Watermark',
92
+ ]);
93
+ const MEDIA_ANNOTS = { Movie: types_1.PdfDetail.Movie, Sound: types_1.PdfDetail.Sound, Screen: types_1.PdfDetail.Screen, RichMedia: types_1.PdfDetail.RichMedia, '3D': types_1.PdfDetail.ThreeD };
94
+ const ANNOT_SUBTYPES = new Set([...ANNOT_ALLOWED, ...Object.keys(MEDIA_ANNOTS), 'FileAttachment', 'PrinterMark', 'TrapNet', 'Projection']);
95
+ const NAV_NAMED = new Set(['NextPage', 'PrevPage', 'FirstPage', 'LastPage']);
96
+ const ACTION_DETAIL = {
97
+ Launch: [types_1.PdfCategory.Action, types_1.PdfDetail.Launch],
98
+ GoToR: [types_1.PdfCategory.Action, types_1.PdfDetail.RemoteGoto],
99
+ GoToE: [types_1.PdfCategory.Action, types_1.PdfDetail.EmbeddedGoto],
100
+ SubmitForm: [types_1.PdfCategory.Action, types_1.PdfDetail.SubmitForm],
101
+ ImportData: [types_1.PdfCategory.Action, types_1.PdfDetail.ImportData],
102
+ Hide: [types_1.PdfCategory.Action, types_1.PdfDetail.Hide],
103
+ SetOCGState: [types_1.PdfCategory.Action, types_1.PdfDetail.SetLayerState],
104
+ Movie: [types_1.PdfCategory.Media, types_1.PdfDetail.Movie],
105
+ Sound: [types_1.PdfCategory.Media, types_1.PdfDetail.Sound],
106
+ RichMediaExecute: [types_1.PdfCategory.Media, types_1.PdfDetail.RichMedia],
107
+ GoTo3DView: [types_1.PdfCategory.Media, types_1.PdfDetail.ThreeD],
108
+ };
109
+ const TRIGGERS = {
110
+ catalog: {
111
+ WC: ['document-will-close', types_1.PdfDetail.Document],
112
+ WS: ['document-will-save', types_1.PdfDetail.Document],
113
+ DS: ['document-did-save', types_1.PdfDetail.Document],
114
+ WP: ['document-will-print', types_1.PdfDetail.Document],
115
+ DP: ['document-did-print', types_1.PdfDetail.Document],
116
+ },
117
+ page: { O: ['page-open', types_1.PdfDetail.Page], C: ['page-close', types_1.PdfDetail.Page] },
118
+ annot: {
119
+ E: ['mouse-enter', types_1.PdfDetail.Annotation],
120
+ X: ['mouse-exit', types_1.PdfDetail.Annotation],
121
+ D: ['mouse-down', types_1.PdfDetail.Annotation],
122
+ U: ['mouse-up', types_1.PdfDetail.Annotation],
123
+ Fo: ['focus', types_1.PdfDetail.Annotation],
124
+ Bl: ['blur', types_1.PdfDetail.Annotation],
125
+ PO: ['page-open', types_1.PdfDetail.Annotation],
126
+ PC: ['page-close', types_1.PdfDetail.Annotation],
127
+ PV: ['page-visible', types_1.PdfDetail.Annotation],
128
+ PI: ['page-invisible', types_1.PdfDetail.Annotation],
129
+ },
130
+ field: { K: ['field-keystroke', types_1.PdfDetail.Field], F: ['field-format', types_1.PdfDetail.Field], V: ['field-validate', types_1.PdfDetail.Field], C: ['field-calculate', types_1.PdfDetail.Field] },
131
+ };
132
+ /** Keys that only mean something in the catalog's names dictionary; anywhere else they are removed. */
133
+ const NAME_TREE_KEYS = new Set(['JavaScript', 'EmbeddedFiles', 'AlternatePresentations', 'Renditions']);
134
+ /** Page attributes the page-tree check reads, which a page inherits up its /Parent chain. */
135
+ const INHERITED = ['MediaBox', 'CropBox', 'Annots'];
136
+ function triggerFor(owner, key) {
137
+ if (owner === 'catalog')
138
+ return TRIGGERS.catalog[key] ?? [key, types_1.PdfDetail.Document];
139
+ if (owner === 'page')
140
+ return TRIGGERS.page[key] ?? [key, types_1.PdfDetail.Page];
141
+ if (owner === 'field' || owner === 'widget')
142
+ return TRIGGERS.field[key] ?? TRIGGERS.annot[key] ?? [key, types_1.PdfDetail.Field];
143
+ return TRIGGERS.annot[key] ?? [key, types_1.PdfDetail.Annotation];
144
+ }
145
+ /** Classifies an object by its content alone. `s` is its /S, resolved when it is a reference, as readers resolve it. */
146
+ function classify(obj, s) {
147
+ if (obj instanceof objects_1.PdfStream) {
148
+ const t = obj.dict.name('Type');
149
+ if (t === 'ObjStm' || t === 'XRef')
150
+ return 'structural';
151
+ if (t === 'EmbeddedFile')
152
+ return 'efStream';
153
+ return 'generic';
154
+ }
155
+ if (!(obj instanceof objects_1.PdfDict))
156
+ return 'generic';
157
+ const d = obj;
158
+ const type = d.name('Type');
159
+ const sub = d.name('Subtype');
160
+ // Annotations first: an annotation that also carries /S must still get the annotation rules.
161
+ if (type === 'Annot' || (sub !== undefined && ANNOT_SUBTYPES.has(sub) && d.has('Rect')))
162
+ return 'annot';
163
+ const action = s ?? d.name('S');
164
+ if (type === 'Action' || (action !== undefined && ACTION_TYPES.has(action)))
165
+ return 'action';
166
+ if (type === 'Catalog')
167
+ return 'catalog';
168
+ if (type === 'Pages')
169
+ return 'pagesNode';
170
+ if (type === 'Page')
171
+ return 'page';
172
+ if (type === 'Outlines')
173
+ return 'outlineRoot';
174
+ if (type === 'Filespec' || d.has('EF'))
175
+ return 'filespec';
176
+ if (d.has('Title') && !type && (d.has('Parent') || d.has('Next') || d.has('Prev') || d.has('First')))
177
+ return 'outline';
178
+ if (d.has('Fields') && ['XFA', 'DR', 'DA', 'CO', 'SigFlags', 'NeedAppearances'].some(k => d.has(k)))
179
+ return 'acroform';
180
+ if (d.has('FT') || (d.has('T') && !sub && (d.has('Kids') || d.has('Parent'))))
181
+ return 'field';
182
+ return 'generic';
183
+ }
184
+ /**
185
+ * Whether `output` lacks a key, an array entry or a reference that `input` holds. A missing value counts as empty, so
186
+ * an empty dictionary or array, or a null, that goes is no loss. A rewritten string and a reference written inline are
187
+ * not losses either. A stream's /Length is left out, since the writer always writes its own.
188
+ */
189
+ function lost(input, output) {
190
+ if (input === undefined || input === null)
191
+ return false;
192
+ if (Array.isArray(input)) {
193
+ const xs = input.filter(x => x !== null);
194
+ const ys = Array.isArray(output) ? output.filter(y => y !== null) : [];
195
+ return ys.length < xs.length || xs.some((x, i) => lost(x, ys[i]));
196
+ }
197
+ const d = (0, objects_1.dictOf)(input);
198
+ if (!d)
199
+ return output === undefined || output === null;
200
+ const od = (0, objects_1.dictOf)(output);
201
+ for (const [k, v] of d.entries())
202
+ if (!(input instanceof objects_1.PdfStream && k === 'Length') && lost(v, od?.get(k)))
203
+ return true;
204
+ return false;
205
+ }
206
+ const text = (v) => (v instanceof objects_1.PdfString ? (0, objects_1.decodeTextString)(v.bytes) : undefined);
207
+ const short = (s, n = 60) => (s.length > n ? `${s.slice(0, n - 3)}...` : s);
208
+ /**
209
+ * What readers take a /URI value to be. A string is UTF-8 when it decodes as UTF-8 and Latin-1 otherwise, as in
210
+ * pdf.js. A name does not belong there, but pdf.js reads it as "/" plus the name and PDFium as the name alone.
211
+ */
212
+ function uriReadings(u) {
213
+ if (u instanceof objects_1.PdfName)
214
+ return [`/${u.name}`, u.name];
215
+ if (!(u instanceof objects_1.PdfString))
216
+ return [''];
217
+ try {
218
+ return [new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(u.bytes)];
219
+ }
220
+ catch {
221
+ return [Buffer.from(u.bytes).toString('latin1')];
222
+ }
223
+ }
224
+ /** Thrown when a contained file plugin's nested run hits the nesting limit. The code makes every package pass it up. */
225
+ class NestingLimitError extends Error {
226
+ code = 'DEFUSE_LIMIT';
227
+ limit = 'nesting';
228
+ }
229
+ exports.NestingLimitError = NestingLimitError;
230
+ /** An I/O error inside an attached PDF's own run, carried up so the upload's run fails on it as on its own. */
231
+ class NestedIoError extends Error {
232
+ code = 'DEFUSE_IO';
233
+ constructor(cause) {
234
+ super(String(cause?.message ?? cause), { cause });
235
+ }
236
+ }
237
+ exports.NestedIoError = NestedIoError;
238
+ /**
239
+ * The code of an error that stops the whole tree of files, from any package: DEFUSE_LIMIT for a time or nesting
240
+ * limit, DEFUSE_IO for an I/O error. Packages cannot share error classes, so the code alone marks one.
241
+ */
242
+ function stopsTree(e) {
243
+ const code = typeof e === 'object' && e !== null ? e.code : undefined;
244
+ return code === 'DEFUSE_LIMIT' || code === 'DEFUSE_IO' ? code : undefined;
245
+ }
246
+ /** The key under which the walker hands its run's options to a plugin. No other package can name it. */
247
+ exports.RUN_OPTIONS = Symbol('pdf-defuse run options');
248
+ /** Buffers small writes and tracks the output offset. */
249
+ class Out {
250
+ sink;
251
+ offset = 0;
252
+ parts = [];
253
+ pending = 0;
254
+ constructor(sink) {
255
+ this.sink = sink;
256
+ }
257
+ async write(data) {
258
+ const b = typeof data === 'string' ? Buffer.from(data, 'latin1') : Buffer.from(data.buffer, data.byteOffset, data.byteLength);
259
+ this.offset += b.length;
260
+ if (b.length >= 65536) {
261
+ await this.flush();
262
+ await this.sink.write(b);
263
+ return;
264
+ }
265
+ this.parts.push(b);
266
+ this.pending += b.length;
267
+ if (this.pending >= 65536)
268
+ await this.flush();
269
+ }
270
+ async flush() {
271
+ if (!this.pending)
272
+ return;
273
+ const b = Buffer.concat(this.parts, this.pending);
274
+ this.parts = [];
275
+ this.pending = 0;
276
+ await this.sink.write(b);
277
+ }
278
+ }
279
+ class Walker {
280
+ doc;
281
+ cfg;
282
+ findings = [];
283
+ /** A time or nesting limit a plugin threw, which a nested run passes up instead of reporting. */
284
+ passedOn;
285
+ pages = 0;
286
+ /** Per page, the total length of its content streams, for verifying the output. */
287
+ pageContent = [];
288
+ /** Scripts and files kept by plugins, for verifying the output. */
289
+ kept = { scripts: new Map(), files: new Map() };
290
+ /** Actions with a script down their chains, once fully read, which a further slot asks about again. */
291
+ scriptActions = new Set();
292
+ /**
293
+ * Where the walk first removed something, found by comparing what it would write with what it read. The engine
294
+ * never passes an upload through unchanged once this is set.
295
+ */
296
+ removedAt;
297
+ /**
298
+ * Set when a signature field the walk reached holds a signature over every byte of the file but its own /Contents,
299
+ * so nothing was added after signing.
300
+ */
301
+ signed = false;
302
+ phase = 'A';
303
+ perKey = new Map();
304
+ aggregates = new Map();
305
+ pageInfo = new Map();
306
+ /** Pages written directly in /Kids, by the owner and path the walk reaches them at. */
307
+ inlinePages = new Map();
308
+ /** Objects first reached as file specifications, which stay file specifications whatever their content says. */
309
+ fileRole = new Set();
310
+ /** Objects listed in /Annots and kept there though they are not annotations, which get the annotation rules. */
311
+ annotRole = new Set();
312
+ /** Objects that only removed content refers to, which are not unreferenced. */
313
+ reached = new Set();
314
+ /** Pages in document order: an object number, or the dictionary of a page written directly in /Kids. */
315
+ pageOrder = [];
316
+ /** Objects whose role comes from the referrer that defines it, whatever else reaches them first. */
317
+ rootNum;
318
+ namesNum;
319
+ infoNum;
320
+ /** Slots that lose their reference to a shared object that other slots keep. Keyed by owner and path. */
321
+ refusedSlots = new Set();
322
+ /** Bumped each time a script is asked again for another slot and would not be kept there. */
323
+ refusals = 0;
324
+ /** The refusals that reported nothing themselves: past the work budget, or for a different rewrite. */
325
+ quiet = 0;
326
+ deciding = new Set();
327
+ /**
328
+ * Actions and additional-actions dictionaries with no script anywhere down their chains, once fully read. Only a
329
+ * script can be decided differently for another slot, so these are never asked about again.
330
+ */
331
+ scriptFree = new Set();
332
+ /** Work allowed for deciding shared scripts again, in units: 1024 per slot, 64 per step down a chain, 1 per 16 decoded bytes. */
333
+ againBudget = 16 * 1024 * 1024;
334
+ /** A script's findings from visits of removed content, held until the walk ends so a later real visit that keeps it can correct them. */
335
+ shadowScripts = new Map();
336
+ /** Information dictionaries and XMP streams removed for the caller. */
337
+ strippedMeta = new Set();
338
+ /** Bytes of scrubbed attachments held in memory until the write. */
339
+ retained = 0;
340
+ /** A second name for a contained file whose content was already decided, by stream number and name. */
341
+ nameDecisions = new Map();
342
+ /** Per annotation, the first page that shows it, and the smallest page box among the pages that show it. */
343
+ annotPage = new Map();
344
+ /**
345
+ * Per annotations array, keyed by the owner and path the walk reaches it at: the first page that shows it, and the
346
+ * smallest page box among the pages that show it, whether as their own /Annots or inherited.
347
+ */
348
+ annotLists = new Map();
349
+ altText = new Map();
350
+ special = new Map();
351
+ order = [];
352
+ visited = new Set();
353
+ shadowVisited = new Set();
354
+ dropped = new Set();
355
+ /** `slot` is the owner and path of an ordinary key that queued the reference. */
356
+ queue = [];
357
+ qhead = 0;
358
+ scriptDecisions = new Map();
359
+ /**
360
+ * `types` is set when the content went through the type check, so a further name can be checked against it:
361
+ * `name` is the name the content was decided under, and `plugin` the plugin that decided it. `size` is known once
362
+ * the whole file was decoded, which happens only when a plugin or the output check reads it. `decoded` is the
363
+ * content decoded again for the further names, once a plugin read it under one, until the walk ends.
364
+ */
365
+ fileDecisions = new Map();
366
+ jsReplace = new Map();
367
+ jsTree;
368
+ efTree;
369
+ keptDocScripts = [];
370
+ /** What a link's label check found, by chain object and a hash of the labels: a finding's data, or null. */
371
+ labelChecks = new Map();
372
+ /** Per web link action object, the hosts a label is compared with and the address each comes from. */
373
+ chainSites = new Map();
374
+ keptCalcFields = new Set();
375
+ /** Set once a field's calculation script is removed, so the calculation order is narrowed. */
376
+ calcRemoved = false;
377
+ /** The fields the calculation order lists, by object number. */
378
+ coFields = new Set();
379
+ coPresent = false;
380
+ treeNodes = new Set();
381
+ uriBase;
382
+ newNum = new Map();
383
+ synthJs = 0;
384
+ synthEf = 0;
385
+ constructor(doc, cfg) {
386
+ this.doc = doc;
387
+ this.cfg = cfg;
388
+ }
389
+ get options() {
390
+ return this.cfg.options;
391
+ }
392
+ // ---------- findings ----------
393
+ emit(category, detail, location, data) {
394
+ const f = this.cfg.factory.make(category, detail, location, data);
395
+ this.push(f);
396
+ return f;
397
+ }
398
+ /**
399
+ * Records a finding whose action is final. Past 200 of one kind, further ones only raise a count, on a finding
400
+ * that carries the strictest action among those it counts.
401
+ */
402
+ push(f) {
403
+ if (this.phase === 'B')
404
+ return;
405
+ const k = `${f.category}/${f.detail}`;
406
+ const n = (this.perKey.get(k) ?? 0) + 1;
407
+ this.perKey.set(k, n);
408
+ if (n <= 200)
409
+ this.findings.push(f);
410
+ else {
411
+ let agg = this.aggregates.get(`more:${k}`);
412
+ if (!agg) {
413
+ // The first overflow's data fills the description's placeholders. A kind from another package keeps its own.
414
+ const data = { ...f.data, count: 0 };
415
+ agg = (0, findings_1.findingSpec)(f.category, f.detail)
416
+ ? this.cfg.factory.make(f.category, f.detail, 'additional occurrences', data)
417
+ : { category: f.category, detail: f.detail, description: f.description, action: f.action, location: 'additional occurrences', data, weight: f.weight };
418
+ agg.action = f.action;
419
+ this.aggregates.set(`more:${k}`, agg);
420
+ this.findings.push(agg);
421
+ }
422
+ else if (f.action === 'reject' || (f.action === 'strip' && agg.action === 'info'))
423
+ agg.action = f.action;
424
+ const data = agg.data;
425
+ if (!data)
426
+ throw new Error(`Aggregate finding ${k} has no data`);
427
+ data.count = Number(data.count) + 1;
428
+ }
429
+ }
430
+ /**
431
+ * Reports content that goes whatever the overrides say, such as a slot refused past the work budget. Its action is
432
+ * strip, or reject when the caller asked for that.
433
+ */
434
+ emitRemoval(category, detail, location, data) {
435
+ const f = this.cfg.factory.make(category, detail, location, data);
436
+ if (f.action === 'info')
437
+ f.action = 'strip';
438
+ this.push(f);
439
+ return f;
440
+ }
441
+ /**
442
+ * Adds the findings a plugin reported from inside a contained file, labeled with the file's name and place.
443
+ * A reject inside the file means the file goes, not the upload, so it is recorded here as strip.
444
+ */
445
+ pushContained(found, location, name) {
446
+ const label = name || 'unnamed file';
447
+ for (const f of found ?? []) {
448
+ this.push({
449
+ ...f,
450
+ action: f.action === 'reject' ? 'strip' : f.action,
451
+ location: f.location ? `${location} > ${f.location}` : location,
452
+ data: f.data && { ...f.data },
453
+ attachment: f.attachment ? `${label} > ${f.attachment}` : label,
454
+ });
455
+ }
456
+ }
457
+ /**
458
+ * Throws on an error a plugin threw that stops the whole tree. An I/O error fails this run as one of its own would,
459
+ * so its cause goes up.
460
+ */
461
+ passOn(e) {
462
+ const code = stopsTree(e);
463
+ if (code === 'DEFUSE_IO') {
464
+ const cause = e.cause ?? e;
465
+ this.cfg.temp.ioError ??= cause;
466
+ throw cause;
467
+ }
468
+ if (code === 'DEFUSE_LIMIT') {
469
+ this.passedOn = e;
470
+ throw e;
471
+ }
472
+ }
473
+ aggregate(category, detail, extra) {
474
+ const k = `${category}/${detail}`;
475
+ let f = this.aggregates.get(k);
476
+ if (this.phase === 'B')
477
+ return f ?? this.cfg.factory.make(category, detail);
478
+ if (!f) {
479
+ f = this.cfg.factory.make(category, detail, undefined, { count: 0 });
480
+ this.aggregates.set(k, f);
481
+ this.findings.push(f);
482
+ }
483
+ const data = f.data;
484
+ if (!data)
485
+ throw new Error(`Aggregate finding ${k} has no data`);
486
+ data.count = Number(data.count) + 1;
487
+ extra?.(data);
488
+ return f;
489
+ }
490
+ // ---------- phase A ----------
491
+ async analyze() {
492
+ this.phase = 'A';
493
+ const doc = this.doc;
494
+ const root = doc.trailer.get('Root');
495
+ this.rootNum = root instanceof objects_1.PdfRef ? root.num : undefined;
496
+ await this.walkPageTree(root);
497
+ // The catalog and the pages hold their roles first; the information and names dictionaries come next.
498
+ const info = doc.trailer.get('Info');
499
+ if (info instanceof objects_1.PdfRef && info.num !== this.rootNum && !this.pageInfo.has(info.num))
500
+ this.infoNum = info.num;
501
+ const catalog = await doc.resolve(root);
502
+ if (catalog instanceof objects_1.PdfDict) {
503
+ const names = catalog.get('Names');
504
+ if (names instanceof objects_1.PdfRef && names.num !== this.rootNum && names.num !== this.infoNum && !this.pageInfo.has(names.num))
505
+ this.namesNum = names.num;
506
+ const uri = await doc.resolve(catalog.get('URI'));
507
+ if (uri instanceof objects_1.PdfDict)
508
+ this.uriBase = text(await doc.resolve(uri.get('Base')));
509
+ if (this.annotPage.size)
510
+ await this.buildAltText(catalog.get('StructTreeRoot'));
511
+ await this.prepassDocumentScripts(catalog);
512
+ }
513
+ this.enqueue(root, { location: 'document catalog' });
514
+ if (info instanceof objects_1.PdfRef) {
515
+ if (this.options.stripMetadata) {
516
+ this.emit(types_1.PdfCategory.Metadata, types_1.PdfDetail.Stripped, 'document information');
517
+ // Removed wherever it is referenced, and not counted as unreferenced.
518
+ if (this.infoNum !== undefined)
519
+ this.strippedMeta.add(this.infoNum);
520
+ }
521
+ else
522
+ this.enqueue(info, { location: 'document information', special: 'info' });
523
+ }
524
+ await this.drain();
525
+ await this.postWalk();
526
+ }
527
+ enqueue(ref, ctx, slot) {
528
+ if (this.phase !== 'A')
529
+ return;
530
+ if (this.visited.has(ref.num))
531
+ return;
532
+ if (ctx.shadow && this.shadowVisited.has(ref.num))
533
+ return;
534
+ this.queue.push({ ref, ctx, slot });
535
+ }
536
+ async drain() {
537
+ while (this.qhead < this.queue.length) {
538
+ const item = this.queue[this.qhead];
539
+ this.queue[this.qhead++] = undefined;
540
+ if (this.qhead > 65536 && this.qhead * 2 > this.queue.length) {
541
+ this.queue = this.queue.slice(this.qhead);
542
+ this.qhead = 0;
543
+ }
544
+ if (!item)
545
+ throw new Error('An empty slot in the walk queue');
546
+ // Another slot can decide the object while this one waits in the queue.
547
+ const again = this.visited.has(item.ref.num);
548
+ await this.processRef(item.ref, item.ctx);
549
+ if (again && item.slot !== undefined)
550
+ await this.reachAgain(item.ref, item.slot, item.ctx);
551
+ }
552
+ }
553
+ /**
554
+ * A slot that reaches an action through a key the action rules do not cover, such as the /A of a dictionary of no
555
+ * known kind. Readers run the action from there, so a script down its chain, decided from another slot, is asked
556
+ * about again for this one.
557
+ */
558
+ async reachAgain(ref, slot, ctx) {
559
+ if (ctx.shadow || this.dropped.has(ref.num) || !this.scriptActions.has(ref.num))
560
+ return;
561
+ const target = await this.doc.getObject(ref);
562
+ if (target instanceof objects_1.PdfDict)
563
+ await this.decideForSlot(ref.num, slot, ctx, () => this.handleAction(target, ctx, `obj:${ref.num}`, '', false));
564
+ }
565
+ /** An action's type as readers read it, resolving an /S that is a reference. */
566
+ async actionType(v) {
567
+ const s = v instanceof objects_1.PdfDict ? v.get('S') : undefined;
568
+ const r = s instanceof objects_1.PdfRef ? await this.doc.resolve(s) : s;
569
+ return r instanceof objects_1.PdfName ? r.name : undefined;
570
+ }
571
+ /** Follows references, as far as 32, to a value and the number of the object that holds it. */
572
+ async lastRef(v) {
573
+ let value = v;
574
+ let num;
575
+ for (let i = 0; i < 32 && value instanceof objects_1.PdfRef; i++) {
576
+ num = value.num;
577
+ value = await this.doc.getObject(value);
578
+ }
579
+ return { value, num };
580
+ }
581
+ /** Visits one indirect object: findings, keep or drop, children. */
582
+ async processRef(ref, ctx) {
583
+ if (this.visited.has(ref.num))
584
+ return;
585
+ if (ctx.shadow) {
586
+ if (this.shadowVisited.has(ref.num))
587
+ return;
588
+ this.shadowVisited.add(ref.num);
589
+ }
590
+ else
591
+ this.visited.add(ref.num);
592
+ this.doc.checkTime();
593
+ const obj = await this.doc.getObject(ref);
594
+ if (obj === null && !this.doc.xref.has(ref.num)) {
595
+ if (!ctx.shadow)
596
+ this.visited.delete(ref.num);
597
+ return;
598
+ }
599
+ // A first visit made while another slot is being decided again is a visit in its own right.
600
+ const outer = this.deciding;
601
+ const refusals = this.refusals;
602
+ const quiet = this.quiet;
603
+ if (outer.size)
604
+ this.deciding = new Set();
605
+ try {
606
+ const out = await this.transformTop(obj, ctx, ref.num);
607
+ if (ctx.shadow)
608
+ return;
609
+ if (lost(obj, out))
610
+ this.removedAt ??= ctx.location;
611
+ if (out === undefined) {
612
+ this.dropped.add(ref.num);
613
+ return;
614
+ }
615
+ if (ctx.special)
616
+ this.special.set(ref.num, ctx.special);
617
+ this.order.push(ref.num);
618
+ }
619
+ finally {
620
+ if (outer.size) {
621
+ this.deciding = outer;
622
+ this.refusals = refusals;
623
+ this.quiet = quiet;
624
+ }
625
+ }
626
+ }
627
+ /**
628
+ * Marks what removed content refers to, so it does not count as unreferenced. Nothing is reported or decided: what
629
+ * only removed content reaches is not written either way, and a real visit still reads it.
630
+ */
631
+ async reach(v) {
632
+ if (this.phase !== 'A')
633
+ return;
634
+ const stack = [v];
635
+ while (stack.length) {
636
+ const x = stack.pop();
637
+ if (x instanceof objects_1.PdfRef) {
638
+ if (this.visited.has(x.num) || this.shadowVisited.has(x.num) || this.reached.has(x.num))
639
+ continue;
640
+ this.reached.add(x.num);
641
+ this.doc.checkTime();
642
+ stack.push(await this.doc.getObject(x));
643
+ }
644
+ else if (Array.isArray(x)) {
645
+ for (const y of x)
646
+ stack.push(y);
647
+ }
648
+ else {
649
+ const d = (0, objects_1.dictOf)(x);
650
+ if (d)
651
+ for (const [, y] of d.entries())
652
+ stack.push(y);
653
+ }
654
+ }
655
+ }
656
+ /** A rectangle as pdf.js reads one: four numbers, the array and each number resolved one level, with an area. */
657
+ async box(v) {
658
+ const a = v instanceof objects_1.PdfRef ? await this.doc.getObject(v) : v;
659
+ if (!Array.isArray(a) || a.length !== 4)
660
+ return null;
661
+ const n = [];
662
+ for (const x of a) {
663
+ const r = x instanceof objects_1.PdfRef ? await this.doc.getObject(x) : x;
664
+ if (typeof r !== 'number')
665
+ return null;
666
+ n.push(r);
667
+ }
668
+ const b = [Math.min(n[0], n[2]), Math.min(n[1], n[3]), Math.max(n[0], n[2]), Math.max(n[1], n[3])];
669
+ return b[2] > b[0] && b[3] > b[1] ? b : null;
670
+ }
671
+ /**
672
+ * Reads the page tree in document order as pdf.js does, and records which page every annotation sits on. What a
673
+ * page inherits comes up its /Parent chain, as readers look it up.
674
+ */
675
+ async walkPageTree(root) {
676
+ const catalog = await this.doc.resolve(root);
677
+ if (!(catalog instanceof objects_1.PdfDict))
678
+ return;
679
+ const seen = new Set();
680
+ const seenInline = new Set();
681
+ // Many nodes can name one /Kids array object. It is expanded once, so the stack stays linear in the file.
682
+ const expanded = new Set();
683
+ // `slot` is the owner and path the walk will reach an inline node at, since its object can be parsed again.
684
+ const stack = [{ v: catalog.get('Pages') }];
685
+ // A page written directly in /Kids is a page too, as pdf.js and qpdf count it.
686
+ const out = [];
687
+ let nodes = 0;
688
+ while (stack.length && nodes++ < 5_000_000) {
689
+ this.doc.checkTime();
690
+ const top = stack.pop();
691
+ if (!top)
692
+ throw new Error('The page tree stack ran out');
693
+ const { v, slot } = top;
694
+ let node;
695
+ if (v instanceof objects_1.PdfRef) {
696
+ if (seen.has(v.num))
697
+ continue;
698
+ seen.add(v.num);
699
+ node = await this.doc.getObject(v);
700
+ }
701
+ else if (v instanceof objects_1.PdfDict) {
702
+ if (seenInline.has(v))
703
+ continue;
704
+ seenInline.add(v);
705
+ node = v;
706
+ }
707
+ else
708
+ continue;
709
+ if (!(node instanceof objects_1.PdfDict))
710
+ continue;
711
+ // pdf.js resolves /Type and /Kids one level. A referenced node typed /Page is a page, any other node with a
712
+ // /Kids array holds pages, and a node without /Kids is a page whatever its type.
713
+ const t = node.get('Type');
714
+ const type = t instanceof objects_1.PdfRef ? await this.doc.getObject(t) : t;
715
+ const typedPage = type instanceof objects_1.PdfName && type.name === 'Page';
716
+ const k = node.get('Kids');
717
+ const kids = k instanceof objects_1.PdfRef ? await this.doc.getObject(k) : k;
718
+ if (Array.isArray(kids) && !(typedPage && v instanceof objects_1.PdfRef)) {
719
+ if (k instanceof objects_1.PdfRef) {
720
+ if (expanded.has(k.num))
721
+ continue;
722
+ expanded.add(k.num);
723
+ }
724
+ const base = k instanceof objects_1.PdfRef ? `obj:${k.num}` : `${v instanceof objects_1.PdfRef ? `obj:${v.num}` : slot}/Kids`;
725
+ for (let i = kids.length - 1; i >= 0; i--)
726
+ stack.push({ v: kids[i], slot: `${base}[${i}]` });
727
+ }
728
+ else if (typedPage || !node.has('Kids'))
729
+ out.push({ page: v instanceof objects_1.PdfRef ? v : node, slot });
730
+ }
731
+ // Per page-tree node reached up a /Parent chain, where it inherits each attribute from.
732
+ const via = new Map();
733
+ // How to read each annotations array again once every page that shows it is known.
734
+ const lists = new Map();
735
+ for (let index = 0; index < out.length; index++) {
736
+ this.doc.checkTime();
737
+ const { page, slot } = out[index];
738
+ const dict = page instanceof objects_1.PdfRef ? await this.doc.getObject(page) : page;
739
+ const at = page instanceof objects_1.PdfRef ? `obj:${page.num}` : (slot ?? '');
740
+ // Up the chain to a node already known, then back down it, so each node is read once. An inline /Parent counts
741
+ // as a node too. A chain that loops ends where it repeats, as in pdf.js, so a node on the loop inherits from
742
+ // the nearest holder going round it.
743
+ const chain = [];
744
+ const onChain = new Map();
745
+ let held = {};
746
+ let loopAt = -1;
747
+ let p = dict instanceof objects_1.PdfDict ? dict.get('Parent') : undefined;
748
+ let pAt = `${at}/Parent`;
749
+ while (p instanceof objects_1.PdfRef || p instanceof objects_1.PdfDict) {
750
+ this.doc.checkTime();
751
+ let pd = p;
752
+ if (p instanceof objects_1.PdfRef) {
753
+ const known = via.get(p.num);
754
+ if (known) {
755
+ held = known;
756
+ break;
757
+ }
758
+ loopAt = onChain.get(p.num) ?? -1;
759
+ if (loopAt >= 0)
760
+ break;
761
+ onChain.set(p.num, chain.length);
762
+ pd = await this.doc.getObject(p);
763
+ pAt = `obj:${p.num}`;
764
+ }
765
+ if (!(pd instanceof objects_1.PdfDict))
766
+ break;
767
+ chain.push({ num: p instanceof objects_1.PdfRef ? p.num : undefined, dict: pd, at: pAt });
768
+ p = pd.get('Parent');
769
+ pAt = `${pAt}/Parent`;
770
+ }
771
+ if (loopAt >= 0) {
772
+ const loop = chain.slice(loopAt);
773
+ const found = loop.map(() => ({}));
774
+ for (const key of INHERITED) {
775
+ let h;
776
+ for (let i = 2 * loop.length - 1; i >= 0; i--) {
777
+ const n = loop[i % loop.length];
778
+ if (n.dict.has(key))
779
+ h = n.num === undefined ? { dict: n.dict, at: n.at } : { num: n.num, at: n.at };
780
+ if (i < loop.length && h)
781
+ found[i][key] = h;
782
+ }
783
+ }
784
+ loop.forEach((n, i) => {
785
+ if (n.num !== undefined)
786
+ via.set(n.num, found[i]);
787
+ });
788
+ held = found[0];
789
+ chain.length = loopAt;
790
+ }
791
+ for (let i = chain.length - 1; i >= 0; i--) {
792
+ const n = chain[i];
793
+ const h = { ...held };
794
+ for (const key of INHERITED)
795
+ if (n.dict.has(key))
796
+ h[key] = n.num === undefined ? { dict: n.dict, at: n.at } : { num: n.num, at: n.at };
797
+ if (n.num !== undefined)
798
+ via.set(n.num, h);
799
+ held = h;
800
+ }
801
+ const own = dict instanceof objects_1.PdfDict ? dict : undefined;
802
+ const read = async (attr) => {
803
+ if (own?.has(attr))
804
+ return { v: own.get(attr), at, from: page instanceof objects_1.PdfRef ? page.num : own };
805
+ const h = held[attr];
806
+ if (!h)
807
+ return undefined;
808
+ const from = h.dict ?? h.num;
809
+ if (from === undefined)
810
+ return undefined;
811
+ const d = typeof from === 'number' ? await this.doc.getObject(new objects_1.PdfRef(from, this.doc.genOf(from))) : from;
812
+ return { v: (0, objects_1.dictOf)(d)?.get(attr), at: h.at, from };
813
+ };
814
+ // pdf.js shows a page with no usable media box at US Letter size, and one with no usable crop box at its media box.
815
+ const media = (await this.box((await read('MediaBox'))?.v)) ?? [0, 0, 612, 792];
816
+ const crop = (await this.box((await read('CropBox'))?.v)) ?? media;
817
+ // Links are measured against what a viewer shows: the crop box, clipped to the media box.
818
+ const x1 = Math.max(media[0], crop[0]);
819
+ const y1 = Math.max(media[1], crop[1]);
820
+ const x2 = Math.min(media[2], crop[2]);
821
+ const y2 = Math.min(media[3], crop[3]);
822
+ const box = x2 > x1 && y2 > y1 ? [x1, y1, x2, y2] : media;
823
+ if (page instanceof objects_1.PdfRef) {
824
+ this.pageInfo.set(page.num, { index, box });
825
+ this.pageOrder.push(page.num);
826
+ }
827
+ else {
828
+ this.pageOrder.push(page);
829
+ if (slot)
830
+ this.inlinePages.set(slot, { index, box });
831
+ }
832
+ // Many pages can share one /Annots array, or inherit one. Each array is keyed by the owner and path the walk
833
+ // reaches it at: the last reference that leads to it, or the dictionary that holds it.
834
+ const annots = await read('Annots');
835
+ if (!annots)
836
+ continue;
837
+ const { value: list, num: last } = await this.lastRef(annots.v);
838
+ if (!Array.isArray(list))
839
+ continue;
840
+ const key = last === undefined ? `${annots.at}/Annots` : `obj:${last}`;
841
+ const known = this.annotLists.get(key);
842
+ if (!known) {
843
+ this.annotLists.set(key, { index, box });
844
+ lists.set(key, last ?? annots.from);
845
+ }
846
+ else if ((box[2] - box[0]) * (box[3] - box[1]) < (known.box[2] - known.box[0]) * (known.box[3] - known.box[1]))
847
+ known.box = box;
848
+ }
849
+ // Each array once, now that the smallest page showing it is known. An annotation in several arrays takes the
850
+ // smallest box among them.
851
+ for (const [key, from] of lists) {
852
+ this.doc.checkTime();
853
+ const list = this.annotLists.get(key);
854
+ const d = typeof from === 'number' ? await this.doc.getObject(new objects_1.PdfRef(from, this.doc.genOf(from))) : from;
855
+ const annots = Array.isArray(d) ? d : (0, objects_1.dictOf)(d)?.get('Annots');
856
+ if (!list || !Array.isArray(annots))
857
+ continue;
858
+ const area = (list.box[2] - list.box[0]) * (list.box[3] - list.box[1]);
859
+ annots.forEach((a, k) => {
860
+ if (!(a instanceof objects_1.PdfRef))
861
+ return;
862
+ const placed = this.annotPage.get(a.num);
863
+ if (!placed)
864
+ this.annotPage.set(a.num, { index: list.index, k, box: list.box });
865
+ else if (area < (placed.box[2] - placed.box[0]) * (placed.box[3] - placed.box[1]))
866
+ placed.box = list.box;
867
+ });
868
+ }
869
+ this.pages = out.length;
870
+ }
871
+ /** Maps annotations to the alt text of the structure element that holds them. */
872
+ async buildAltText(rootValue) {
873
+ const seen = new Set();
874
+ const seenInline = new Set();
875
+ const stack = [{ v: rootValue }];
876
+ let nodes = 0;
877
+ while (stack.length && nodes++ < 2_000_000) {
878
+ this.doc.checkTime();
879
+ const top = stack.pop();
880
+ if (!top)
881
+ throw new Error('The structure tree stack ran out');
882
+ const { v, alt } = top;
883
+ let node;
884
+ if (v instanceof objects_1.PdfRef) {
885
+ if (seen.has(v.num))
886
+ continue;
887
+ seen.add(v.num);
888
+ node = await this.doc.getObject(v);
889
+ }
890
+ else
891
+ node = v;
892
+ if (Array.isArray(node)) {
893
+ for (const x of node)
894
+ stack.push({ v: x, alt });
895
+ continue;
896
+ }
897
+ if (!(node instanceof objects_1.PdfDict))
898
+ continue;
899
+ if (!(v instanceof objects_1.PdfRef)) {
900
+ if (seenInline.has(node))
901
+ continue;
902
+ seenInline.add(node);
903
+ }
904
+ if (node.name('Type') === 'OBJR' || (node.has('Obj') && !node.has('S'))) {
905
+ const obj = node.get('Obj');
906
+ if (obj instanceof objects_1.PdfRef && alt)
907
+ this.altText.set(obj.num, alt);
908
+ continue;
909
+ }
910
+ const own = text(await this.doc.resolve(node.get('Alt'))) ?? text(await this.doc.resolve(node.get('ActualText')));
911
+ stack.push({ v: node.get('K'), alt: own ?? alt });
912
+ }
913
+ }
914
+ /**
915
+ * Collects name-tree entries with any key form: strings, names or references to them. pdf.js reads an entry whatever
916
+ * its key, so any other key is reported and read as an empty string, and its entry is collected all the same.
917
+ */
918
+ async collectNameTree(root) {
919
+ const out = [];
920
+ const seen = new Set();
921
+ const seenInline = new Set();
922
+ // Many nodes can name one /Kids or /Names array object. Each is read once, so the work stays linear in the file;
923
+ // the entries of a /Names array read twice would be the same entries again.
924
+ const expanded = new Set();
925
+ const stack = root === undefined ? [] : [root];
926
+ let nodes = 0;
927
+ while (stack.length) {
928
+ if (++nodes > 1_000_000) {
929
+ this.emit(types_1.PdfCategory.Corrupted, types_1.PdfDetail.MalformedObject, 'name tree', { reason: 'too many nodes' });
930
+ break;
931
+ }
932
+ this.doc.checkTime();
933
+ const v = stack.pop();
934
+ if (v === undefined)
935
+ throw new Error('The name tree stack ran out');
936
+ if (v instanceof objects_1.PdfRef) {
937
+ if (seen.has(v.num))
938
+ continue;
939
+ seen.add(v.num);
940
+ this.treeNodes.add(v.num);
941
+ }
942
+ const node = await this.doc.resolve(v);
943
+ if (!(node instanceof objects_1.PdfDict))
944
+ continue;
945
+ if (!(v instanceof objects_1.PdfRef)) {
946
+ if (seenInline.has(node))
947
+ continue;
948
+ seenInline.add(node);
949
+ }
950
+ const { value: names, num: namesNum } = await this.lastRef(node.get('Names'));
951
+ if (namesNum !== undefined && expanded.has(namesNum))
952
+ continue;
953
+ if (namesNum !== undefined)
954
+ expanded.add(namesNum);
955
+ if (Array.isArray(names)) {
956
+ for (let i = 0; i + 1 < names.length; i += 2) {
957
+ let key = names[i];
958
+ if (key instanceof objects_1.PdfRef)
959
+ key = await this.doc.resolve(key);
960
+ const bytes = key instanceof objects_1.PdfString ? key.bytes : key instanceof objects_1.PdfName ? Buffer.from(key.name, 'latin1') : undefined;
961
+ if (!bytes)
962
+ this.emit(types_1.PdfCategory.Corrupted, types_1.PdfDetail.MalformedObject, 'name tree', { reason: 'a key that is not a string' });
963
+ out.push([new objects_1.PdfString(bytes ?? new Uint8Array(0)), names[i + 1]]);
964
+ }
965
+ }
966
+ const { value: kids, num: kidsNum } = await this.lastRef(node.get('Kids'));
967
+ if (kidsNum !== undefined && expanded.has(kidsNum))
968
+ continue;
969
+ if (kidsNum !== undefined)
970
+ expanded.add(kidsNum);
971
+ if (Array.isArray(kids))
972
+ for (let i = kids.length - 1; i >= 0; i--)
973
+ stack.push(kids[i]);
974
+ }
975
+ return out;
976
+ }
977
+ async prepassDocumentScripts(catalog) {
978
+ const names = await this.doc.resolve(catalog.get('Names'));
979
+ if (!(names instanceof objects_1.PdfDict) || !names.has('JavaScript'))
980
+ return;
981
+ const entries = await this.collectNameTree(names.get('JavaScript'));
982
+ this.jsTree = { entries: [] };
983
+ for (const [idx, [name, value]] of entries.entries()) {
984
+ const label = (0, objects_1.decodeTextString)(name.bytes);
985
+ // Keyed by position: two entries can share a name, and a name can contain anything.
986
+ const slot = `jsname#${idx}`;
987
+ const ctx = { trigger: 'document', jsDetail: types_1.PdfDetail.Document, location: `document script "${short(label)}"`, docScriptName: label };
988
+ const target = value instanceof objects_1.PdfRef ? await this.doc.getObject(value) : value;
989
+ const isScript = (await this.actionType(target)) === 'JavaScript' && this.kindOf(target, value instanceof objects_1.PdfRef ? value.num : undefined, 'JavaScript') === 'action';
990
+ // Only scripts belong in this tree. Anything else is reported, and left out unless the caller keeps malformed
991
+ // objects; what is left out is still read, so its findings are reported.
992
+ if (!isScript && this.emit(types_1.PdfCategory.Corrupted, types_1.PdfDetail.MalformedObject, ctx.location, { reason: 'not a script' }).action === 'strip') {
993
+ if (value instanceof objects_1.PdfRef)
994
+ await this.processRef(value, { ...ctx, shadow: true });
995
+ else if (value instanceof objects_1.PdfDict)
996
+ await this.handleAction(value, { ...ctx, shadow: true }, slot, '');
997
+ continue;
998
+ }
999
+ if (value instanceof objects_1.PdfRef) {
1000
+ const again = this.visited.has(value.num);
1001
+ await this.processRef(value, ctx);
1002
+ if (isScript && again && !this.dropped.has(value.num))
1003
+ await this.decideForSlot(value.num, slot, ctx, () => this.handleAction(target, ctx, `obj:${value.num}`, '', false));
1004
+ if (this.visited.has(value.num) && !this.dropped.has(value.num) && !this.refusedSlots.has(slot))
1005
+ this.jsTree.entries.push([name, value]);
1006
+ }
1007
+ else {
1008
+ const out = isScript ? await this.handleAction(value, ctx, slot, '') : await this.tv(value, ctx, slot, '');
1009
+ if (lost(value, out))
1010
+ this.removedAt ??= ctx.location;
1011
+ if (out !== undefined)
1012
+ this.jsTree.entries.push([name, out]);
1013
+ }
1014
+ }
1015
+ if (this.jsTree.entries.length < entries.length)
1016
+ this.removedAt ??= 'document scripts';
1017
+ }
1018
+ async postWalk() {
1019
+ const doc = this.doc;
1020
+ // A field only the calculation order lists is not written, and readers may still run its calculation, so it is
1021
+ // read as removed content. That waits until the walk is done, so a real visit of a field always comes first.
1022
+ for (const num of this.coFields)
1023
+ if (!this.visited.has(num))
1024
+ await this.processRef(new objects_1.PdfRef(num, doc.genOf(num)), { location: 'form', shadow: true });
1025
+ await this.drain();
1026
+ // Further names are decided only during the walk.
1027
+ for (const d of this.fileDecisions.values()) {
1028
+ const decoded = d.types?.decoded;
1029
+ if (d.types)
1030
+ d.types.decoded = undefined;
1031
+ await (await decoded?.catch(() => undefined))?.close?.();
1032
+ }
1033
+ for (const list of this.shadowScripts.values())
1034
+ for (const f of list)
1035
+ this.push(f);
1036
+ if (this.dropped.size || this.refusedSlots.size)
1037
+ this.removedAt ??= 'document';
1038
+ if (this.coPresent && this.calcRemoved && this.keptCalcFields.size === 0)
1039
+ this.emit(types_1.PdfCategory.Form, types_1.PdfDetail.CalculationOrder, 'form');
1040
+ if (doc.issues.escapedNames.size)
1041
+ this.emit(types_1.PdfCategory.Structure, types_1.PdfDetail.EscapedNames, undefined, { names: [...doc.issues.escapedNames].slice(0, 10).join(', ') });
1042
+ // Unreferenced objects, excluding structural ones.
1043
+ let unreferenced = 0;
1044
+ let linearHint = -1;
1045
+ // An object a filter entry names counts as referenced only when the walk visited that stream. The walk never
1046
+ // visits object streams or hint streams, so what only they name is counted.
1047
+ const filterRefs = new Set();
1048
+ for (const [streamNum, refs] of doc.filterRefs)
1049
+ if (this.visited.has(streamNum) || this.shadowVisited.has(streamNum))
1050
+ for (const r of refs)
1051
+ filterRefs.add(r);
1052
+ for (const num of Array.from(doc.liveNumbers())) {
1053
+ if (this.visited.has(num) ||
1054
+ this.shadowVisited.has(num) ||
1055
+ this.reached.has(num) ||
1056
+ this.treeNodes.has(num) ||
1057
+ this.strippedMeta.has(num) ||
1058
+ filterRefs.has(num) ||
1059
+ doc.encryptRefs.has(num) ||
1060
+ doc.encryptRef?.num === num)
1061
+ continue;
1062
+ doc.checkTime();
1063
+ let obj;
1064
+ try {
1065
+ obj = await doc.getObject(new objects_1.PdfRef(num, doc.genOf(num)));
1066
+ }
1067
+ catch (e) {
1068
+ // A limit stops the run. An object that cannot be read is still there.
1069
+ if (e instanceof filters_1.DecompressionLimitError || e instanceof document_1.TimeLimitError || e instanceof document_1.ObjectLimitError)
1070
+ throw e;
1071
+ unreferenced++;
1072
+ continue;
1073
+ }
1074
+ if (obj === null || typeof obj === 'number' || typeof obj === 'boolean')
1075
+ continue;
1076
+ const d = (0, objects_1.dictOf)(obj);
1077
+ if (d?.has('Linearized')) {
1078
+ const h = d.get('H');
1079
+ if (Array.isArray(h) && typeof h[0] === 'number')
1080
+ linearHint = h[0];
1081
+ continue;
1082
+ }
1083
+ if (classify(obj) === 'structural')
1084
+ continue;
1085
+ if (obj instanceof objects_1.PdfStream && linearHint >= 0 && obj.offset >= linearHint && obj.offset - linearHint < 64)
1086
+ continue;
1087
+ if (obj instanceof objects_1.PdfStream && !obj.dict.has('Type') && typeof obj.dict.get('S') === 'number')
1088
+ continue; // hint stream
1089
+ unreferenced++;
1090
+ if (d && (d.has('JS') || (await this.actionType(d)) === 'JavaScript'))
1091
+ this.emit(types_1.PdfCategory.JavaScript, types_1.PdfDetail.Unattached, `unreferenced object ${num}`);
1092
+ }
1093
+ if (unreferenced)
1094
+ this.emit(types_1.PdfCategory.Structure, types_1.PdfDetail.UnreferencedObjects, undefined, { count: unreferenced });
1095
+ this.pageContent = await this.contentSignature();
1096
+ }
1097
+ /** Total content-stream length of every page, so the output can be checked page by page. */
1098
+ async contentSignature() {
1099
+ const out = [];
1100
+ // Pages can share one /Contents array, so each array object is summed once.
1101
+ const shared = new Map();
1102
+ for (const p of this.pageOrder) {
1103
+ this.doc.checkTime();
1104
+ const page = typeof p === 'number' ? await this.doc.getObject(new objects_1.PdfRef(p, this.doc.genOf(p))) : p;
1105
+ let total = 0;
1106
+ if (page instanceof objects_1.PdfDict) {
1107
+ let c = page.get('Contents');
1108
+ let arrayNum;
1109
+ if (c instanceof objects_1.PdfRef) {
1110
+ const o = await this.doc.getObject(c);
1111
+ if (Array.isArray(o)) {
1112
+ arrayNum = c.num;
1113
+ c = o;
1114
+ }
1115
+ }
1116
+ const known = arrayNum === undefined ? undefined : shared.get(arrayNum);
1117
+ if (known !== undefined)
1118
+ total = known;
1119
+ else {
1120
+ for (const x of Array.isArray(c) ? c : c === undefined ? [] : [c]) {
1121
+ this.doc.checkTime();
1122
+ if (!(x instanceof objects_1.PdfRef))
1123
+ continue;
1124
+ const s = await this.doc.getObject(x);
1125
+ if (s instanceof objects_1.PdfStream && classify(s) !== 'structural')
1126
+ total += await this.doc.plainLength(s, x.num);
1127
+ }
1128
+ if (arrayNum !== undefined)
1129
+ shared.set(arrayNum, total);
1130
+ }
1131
+ }
1132
+ out.push(total);
1133
+ }
1134
+ return out;
1135
+ }
1136
+ // ---------- transforms (both phases) ----------
1137
+ /**
1138
+ * What an object is: the catalog and the pages by the role the trailer and the page tree give them, anything
1139
+ * else by its content. A page whose content is an annotation stays an annotation, so the annotation rules apply.
1140
+ */
1141
+ kindOf(obj, num, s) {
1142
+ const kind = classify(obj, s);
1143
+ if (num === undefined || !(obj instanceof objects_1.PdfDict))
1144
+ return kind;
1145
+ if (num === this.rootNum)
1146
+ return 'catalog';
1147
+ if (this.pageInfo.has(num) && kind !== 'annot')
1148
+ return 'page';
1149
+ if (this.annotRole.has(num))
1150
+ return 'annot';
1151
+ if (this.fileRole.has(num))
1152
+ return 'filespec';
1153
+ return kind;
1154
+ }
1155
+ async transformTop(obj, ctx, num) {
1156
+ // Metadata the caller asked to strip goes, whatever reaches it.
1157
+ if (this.strippedMeta.has(num))
1158
+ return undefined;
1159
+ const owner = `obj:${num}`;
1160
+ const hasRole = num === this.rootNum || this.pageInfo.has(num);
1161
+ const special = hasRole ? undefined : num === this.namesNum ? 'names' : num === this.infoNum ? 'info' : this.phase === 'B' ? this.special.get(num) : ctx.special;
1162
+ let d = (0, objects_1.dictOf)(obj);
1163
+ if (obj instanceof objects_1.PdfStream && d) {
1164
+ // The writer always writes Length directly, so an indirect Length object must not be kept.
1165
+ d = d.clone();
1166
+ d.delete('Length');
1167
+ }
1168
+ if (special === 'annotsArray' && Array.isArray(obj))
1169
+ return this.handleAnnotsArray(obj, ctx, owner, '');
1170
+ if (!d)
1171
+ return this.tv(obj, ctx, owner, '');
1172
+ let out;
1173
+ if (special === 'names')
1174
+ out = await this.handleNames(d, ctx, owner, '');
1175
+ else if (special === 'aa') {
1176
+ out = (await this.handleAA(d, ctx, owner, ''));
1177
+ if (this.phase === 'A' && !ctx.shadow && out !== undefined && d.entries().every(([, a]) => this.scriptFreeChain(a)))
1178
+ this.scriptFree.add(num);
1179
+ }
1180
+ else {
1181
+ const info = d;
1182
+ if (special === 'info' && ['Author', 'Creator', 'Producer', 'Title', 'Subject', 'Keywords'].some(k => info.has(k)) && this.aggregate(types_1.PdfCategory.Metadata, types_1.PdfDetail.InfoDictionary).action === 'strip')
1183
+ return undefined;
1184
+ // A stream decided as a contained file stays one, whether or not it says /Type /EmbeddedFile.
1185
+ const kind = obj instanceof objects_1.PdfStream && this.fileDecisions.has(num) ? 'efStream' : this.kindOf(obj, num, await this.actionType(d));
1186
+ switch (kind) {
1187
+ case 'structural':
1188
+ this.doc.issues.structuralRefs++;
1189
+ return undefined;
1190
+ case 'catalog':
1191
+ out = await this.handleCatalog(d, ctx, owner);
1192
+ break;
1193
+ case 'page':
1194
+ out = await this.handlePage(d, ctx, owner, num);
1195
+ break;
1196
+ case 'pagesNode':
1197
+ out = await this.handlePage(d, ctx, owner, num, '', true);
1198
+ break;
1199
+ case 'annot':
1200
+ out = await this.handleAnnot(d, ctx, owner, '', num);
1201
+ break;
1202
+ case 'field':
1203
+ out = await this.handleField(d, ctx, owner, num);
1204
+ break;
1205
+ case 'acroform':
1206
+ out = await this.handleAcroForm(d, ctx, owner, '');
1207
+ break;
1208
+ case 'action':
1209
+ out = await this.handleAction(d, ctx, owner, '');
1210
+ if (this.phase === 'A' && !ctx.shadow && out !== undefined) {
1211
+ if (this.scriptFreeChain(d))
1212
+ this.scriptFree.add(num);
1213
+ else
1214
+ this.scriptActions.add(num);
1215
+ }
1216
+ break;
1217
+ case 'filespec':
1218
+ if (this.phase === 'A' && !ctx.shadow)
1219
+ this.fileRole.add(num);
1220
+ out = await this.handleFilespec(d, ctx, owner, '');
1221
+ break;
1222
+ case 'efStream': {
1223
+ const keep = await this.decideFile(new objects_1.PdfRef(num, 0), obj, ctx, undefined);
1224
+ out = keep ? await this.handleGeneric(d, ctx, owner, '') : undefined;
1225
+ break;
1226
+ }
1227
+ case 'outlineRoot':
1228
+ case 'outline':
1229
+ out = await this.handleOutline(d, ctx, owner, kind);
1230
+ break;
1231
+ default:
1232
+ out = await this.handleGeneric(d, ctx, owner, '');
1233
+ }
1234
+ }
1235
+ if (out === undefined)
1236
+ return undefined;
1237
+ if (obj instanceof objects_1.PdfStream)
1238
+ return new objects_1.PdfStream(out, obj.offset, obj.length);
1239
+ return out;
1240
+ }
1241
+ /**
1242
+ * Whether no script lies down an action's chain. A reference counts only once its object was read to the end of its
1243
+ * own chain, so a chain that loops back counts as holding one.
1244
+ */
1245
+ scriptFreeChain(v) {
1246
+ if (v instanceof objects_1.PdfRef)
1247
+ return this.scriptFree.has(v.num);
1248
+ if (Array.isArray(v))
1249
+ return v.every(x => this.scriptFreeChain(x));
1250
+ if (!(v instanceof objects_1.PdfDict))
1251
+ return true;
1252
+ // An /S given by reference is read when the action is handled, so here it may name a script.
1253
+ if (v.get('S') instanceof objects_1.PdfRef)
1254
+ return false;
1255
+ const s = v.name('S');
1256
+ return s !== 'JavaScript' && !(s === 'Rendition' && v.has('JS')) && this.scriptFreeChain(v.get('Next'));
1257
+ }
1258
+ /** Transforms any value. Returns undefined when the value must be removed. */
1259
+ async tv(v, ctx, owner, path) {
1260
+ if (v instanceof objects_1.PdfRef) {
1261
+ if (this.phase === 'A') {
1262
+ const child = { location: ctx.location, pageIndex: ctx.pageIndex, pageBox: ctx.pageBox, shadow: ctx.shadow, fieldName: ctx.fieldName };
1263
+ // Readers run an action from /A, /PA and /Next, so only such a slot asks a shared script again.
1264
+ const slot = /\/(?:A|PA|Next)(?:\[\d+\])?$/.test(path) ? owner + path : undefined;
1265
+ if (!this.visited.has(v.num))
1266
+ this.enqueue(v, child, slot);
1267
+ else if (slot !== undefined)
1268
+ await this.reachAgain(v, slot, child);
1269
+ return v;
1270
+ }
1271
+ return this.dropped.has(v.num) || this.refusedSlots.has(owner + path) ? undefined : v;
1272
+ }
1273
+ if (Array.isArray(v)) {
1274
+ const out = [];
1275
+ for (let i = 0; i < v.length; i++) {
1276
+ const x = await this.tv(v[i], ctx, owner, `${path}[${i}]`);
1277
+ out.push(x === undefined ? null : x);
1278
+ }
1279
+ return out;
1280
+ }
1281
+ if (v instanceof objects_1.PdfDict) {
1282
+ const kind = classify(v, await this.actionType(v));
1283
+ // A page written directly in /Kids is a page whatever its content says, unless it is an annotation.
1284
+ if (kind !== 'annot' && this.inlinePages.has(owner + path))
1285
+ return this.handlePage(v, ctx, owner, undefined, path);
1286
+ switch (kind) {
1287
+ case 'action':
1288
+ return this.handleAction(v, ctx, owner, path);
1289
+ case 'annot':
1290
+ return this.handleAnnot(v, ctx, owner, path);
1291
+ case 'page':
1292
+ return this.handlePage(v, ctx, owner, undefined, path);
1293
+ case 'pagesNode':
1294
+ return this.handlePage(v, ctx, owner, undefined, path, true);
1295
+ case 'filespec':
1296
+ return this.handleFilespec(v, ctx, owner, path);
1297
+ case 'field':
1298
+ return this.handleField(v, ctx, owner, undefined, path);
1299
+ case 'acroform':
1300
+ return this.handleAcroForm(v, ctx, owner, path);
1301
+ case 'outline':
1302
+ case 'outlineRoot':
1303
+ return this.handleOutline(v, ctx, owner, kind, path);
1304
+ default:
1305
+ return this.handleGeneric(v, ctx, owner, path);
1306
+ }
1307
+ }
1308
+ return v;
1309
+ }
1310
+ /** Rules every dictionary gets, applied to the keys a specific handler did not take. */
1311
+ async handleGeneric(d, ctx, owner, path, skip = new Set(), out = new objects_1.PdfDict()) {
1312
+ if (d.name('Type') === 'Font' && d.name('Subtype') === 'Type3')
1313
+ this.aggregate(types_1.PdfCategory.Content, types_1.PdfDetail.Type3Font);
1314
+ if (d.name('Subtype') === 'Image') {
1315
+ const fs = (0, filters_1.filtersOf)(d).map(f => f.name);
1316
+ if (fs.includes('JBIG2Decode'))
1317
+ this.aggregate(types_1.PdfCategory.Content, types_1.PdfDetail.Jbig2Image);
1318
+ if (fs.includes('JPXDecode'))
1319
+ this.aggregate(types_1.PdfCategory.Content, types_1.PdfDetail.JpxImage);
1320
+ }
1321
+ const child = { location: ctx.location, pageIndex: ctx.pageIndex, pageBox: ctx.pageBox, shadow: ctx.shadow, fieldName: ctx.fieldName };
1322
+ for (const [k, v] of d.entries()) {
1323
+ if (skip.has(k))
1324
+ continue;
1325
+ const p = `${path}/${k}`;
1326
+ if (k === 'JS') {
1327
+ const f = this.emit(types_1.PdfCategory.JavaScript, types_1.PdfDetail.Unattached, ctx.location);
1328
+ if (f.action === 'strip') {
1329
+ await this.reach(v);
1330
+ continue;
1331
+ }
1332
+ }
1333
+ if (k === 'AA') {
1334
+ const x = await this.handleAA(v, { ...ctx, aaOwner: ctx.aaOwner ?? 'generic' }, owner, p);
1335
+ if (x !== undefined)
1336
+ out.set(k, x);
1337
+ continue;
1338
+ }
1339
+ if (k === 'AF') {
1340
+ const x = await this.handleFilespecList(v, { ...child, location: `${ctx.location}, associated file` }, owner, p);
1341
+ if (x !== undefined)
1342
+ out.set(k, x);
1343
+ continue;
1344
+ }
1345
+ // A file attachment's /FS: its key makes it a file specification, whatever reaches it. In a file
1346
+ // specification, /FS names a file system.
1347
+ if (k === 'FS' && (v instanceof objects_1.PdfRef || v instanceof objects_1.PdfDict)) {
1348
+ const x = await this.handleFilespecValue(v, child, owner, p);
1349
+ if (x !== undefined)
1350
+ out.set(k, x);
1351
+ continue;
1352
+ }
1353
+ if (k === 'XFA') {
1354
+ const f = this.emit(types_1.PdfCategory.Form, types_1.PdfDetail.Xfa, 'form');
1355
+ if (f.action === 'strip') {
1356
+ await this.reach(v);
1357
+ continue;
1358
+ }
1359
+ }
1360
+ // XFA rendering goes with any rewrite. A file passed through unchanged keeps it, so it is no removal.
1361
+ if (k === 'NeedsRendering' && this.phase === 'B')
1362
+ continue;
1363
+ if (k === 'Metadata') {
1364
+ // XMP, on the catalog, a page, an image or anything else.
1365
+ const xmp = this.aggregate(types_1.PdfCategory.Metadata, types_1.PdfDetail.Xmp);
1366
+ if (this.options.stripMetadata || xmp.action === 'strip') {
1367
+ if (this.options.stripMetadata)
1368
+ this.emit(types_1.PdfCategory.Metadata, types_1.PdfDetail.Stripped, `${ctx.location}, XMP metadata`);
1369
+ // The stream goes wherever else it is referenced, and is not counted as unreferenced.
1370
+ if (v instanceof objects_1.PdfRef && this.phase === 'A') {
1371
+ this.strippedMeta.add(v.num);
1372
+ if (this.visited.has(v.num))
1373
+ this.dropped.add(v.num);
1374
+ }
1375
+ continue;
1376
+ }
1377
+ }
1378
+ if (NAME_TREE_KEYS.has(k) && (v instanceof objects_1.PdfDict || v instanceof objects_1.PdfRef)) {
1379
+ // These only mean something in the catalog's names dictionary.
1380
+ const f = k === 'JavaScript'
1381
+ ? this.emit(types_1.PdfCategory.JavaScript, types_1.PdfDetail.Unattached, ctx.location)
1382
+ : k === 'EmbeddedFiles'
1383
+ ? this.emit(types_1.PdfCategory.EmbeddedFile, types_1.PdfDetail.NoPlugin, ctx.location)
1384
+ : this.emit(types_1.PdfCategory.Media, k === 'Renditions' ? types_1.PdfDetail.Rendition : types_1.PdfDetail.Slideshow, ctx.location);
1385
+ if (f.action === 'strip') {
1386
+ await this.reach(v);
1387
+ continue;
1388
+ }
1389
+ }
1390
+ const x = await this.tv(v, child, owner, p);
1391
+ if (x !== undefined)
1392
+ out.set(k, x);
1393
+ }
1394
+ return out;
1395
+ }
1396
+ // ---------- catalog, pages, outlines, names, forms ----------
1397
+ async handleCatalog(d, ctx, owner) {
1398
+ const out = new objects_1.PdfDict();
1399
+ const doc = this.doc;
1400
+ const v = d.name('Version');
1401
+ if (v && Number(v) > Number(doc.headerVersion))
1402
+ this.emit(types_1.PdfCategory.Structure, types_1.PdfDetail.VersionUpgraded, undefined, { header: doc.headerVersion, catalog: v });
1403
+ const taken = new Set();
1404
+ for (const [k, val] of d.entries()) {
1405
+ const p = `/${k}`;
1406
+ taken.add(k);
1407
+ switch (k) {
1408
+ case 'OpenAction': {
1409
+ const target = val instanceof objects_1.PdfRef ? await doc.getObject(val) : val;
1410
+ if (Array.isArray(target)) {
1411
+ const x = await this.tv(val, { location: 'document open action', shadow: ctx.shadow }, owner, p);
1412
+ if (x !== undefined)
1413
+ out.set(k, x);
1414
+ }
1415
+ else {
1416
+ // It runs with no click, so anything but a plain jump gets the rule for triggered actions.
1417
+ const jump = target instanceof objects_1.PdfDict && (await this.actionType(target)) === 'GoTo' && !target.has('Next');
1418
+ const x = await this.actionRef(val, { trigger: 'open', jsDetail: types_1.PdfDetail.OpenAction, location: 'document open action', shadow: ctx.shadow }, owner, p, !jump);
1419
+ if (x !== undefined)
1420
+ out.set(k, x);
1421
+ }
1422
+ break;
1423
+ }
1424
+ case 'AA': {
1425
+ const x = await this.handleAA(val, { ...ctx, aaOwner: 'catalog', location: 'document' }, owner, p);
1426
+ if (x !== undefined)
1427
+ out.set(k, x);
1428
+ break;
1429
+ }
1430
+ case 'Names': {
1431
+ let x;
1432
+ if (val instanceof objects_1.PdfDict)
1433
+ x = await this.handleNames(val, ctx, owner, p);
1434
+ else if (val instanceof objects_1.PdfRef) {
1435
+ if (this.phase === 'A')
1436
+ this.enqueue(val, { location: 'document names', special: 'names' });
1437
+ x = this.phase === 'B' && this.dropped.has(val.num) ? undefined : val;
1438
+ }
1439
+ else {
1440
+ taken.delete(k);
1441
+ break;
1442
+ }
1443
+ if (x !== undefined)
1444
+ out.set(k, x);
1445
+ break;
1446
+ }
1447
+ case 'Collection': {
1448
+ const f = this.emit(types_1.PdfCategory.EmbeddedFile, types_1.PdfDetail.Portfolio, 'document');
1449
+ if (f.action !== 'strip') {
1450
+ const x = await this.tv(val, { location: 'portfolio', shadow: ctx.shadow }, owner, p);
1451
+ if (x !== undefined)
1452
+ out.set(k, x);
1453
+ }
1454
+ else
1455
+ await this.reach(val);
1456
+ break;
1457
+ }
1458
+ case 'Perms': {
1459
+ const perms = await doc.resolve(val);
1460
+ if (!(perms instanceof objects_1.PdfDict)) {
1461
+ taken.delete(k);
1462
+ break;
1463
+ }
1464
+ // Written inline, so a referenced permissions dictionary is not written itself.
1465
+ if (val instanceof objects_1.PdfRef)
1466
+ await this.reach(val);
1467
+ const np = new objects_1.PdfDict();
1468
+ for (const [pk, pv] of perms.entries()) {
1469
+ if (pk === 'DocMDP') {
1470
+ this.aggregate(types_1.PdfCategory.Signature, types_1.PdfDetail.Certified);
1471
+ const x = await this.tv(pv, { location: 'certification', shadow: ctx.shadow }, owner, `${p}/${pk}`);
1472
+ if (x !== undefined)
1473
+ np.set(pk, x);
1474
+ }
1475
+ else if (pk === 'UR3' || pk === 'UR') {
1476
+ this.aggregate(types_1.PdfCategory.Signature, types_1.PdfDetail.UsageRights);
1477
+ // A rewrite breaks the usage-rights signature, so only a rewrite drops it, and it is no unreferenced object.
1478
+ if (this.phase === 'A')
1479
+ np.set(pk, pv);
1480
+ await this.reach(pv);
1481
+ }
1482
+ else {
1483
+ const x = await this.tv(pv, { location: 'permissions', shadow: ctx.shadow }, owner, `${p}/${pk}`);
1484
+ if (x !== undefined)
1485
+ np.set(pk, x);
1486
+ }
1487
+ }
1488
+ if (np.keys().length || this.phase === 'A')
1489
+ out.set(k, np);
1490
+ break;
1491
+ }
1492
+ default:
1493
+ taken.delete(k);
1494
+ }
1495
+ }
1496
+ await this.alsoAction(d, ctx, owner, '', out, taken);
1497
+ return this.handleGeneric(d, { ...ctx, location: 'document catalog' }, owner, '', taken, out);
1498
+ }
1499
+ /**
1500
+ * `num` is undefined for a page written directly in /Kids. `node` is a /Pages node, whose /Annots and /AA its pages
1501
+ * inherit, as pdf.js reads them.
1502
+ */
1503
+ async handlePage(d, ctx, owner, num, path = '', node = false) {
1504
+ const info = node ? undefined : num === undefined ? this.inlinePages.get(owner + path) : this.pageInfo.get(num);
1505
+ const index = info?.index ?? ctx.pageIndex ?? 0;
1506
+ const pctx = node ? { location: 'page tree', shadow: ctx.shadow } : { location: `page ${index + 1}`, pageIndex: index, pageBox: info?.box ?? ctx.pageBox, shadow: ctx.shadow };
1507
+ const out = new objects_1.PdfDict();
1508
+ const taken = new Set();
1509
+ for (const [k, v] of d.entries()) {
1510
+ const p = `${path}/${k}`;
1511
+ taken.add(k);
1512
+ if (k === 'Annots') {
1513
+ let x;
1514
+ if (Array.isArray(v))
1515
+ x = await this.handleAnnotsArray(v, pctx, owner, p);
1516
+ else if (v instanceof objects_1.PdfRef) {
1517
+ if (this.phase === 'A')
1518
+ this.enqueue(v, { ...pctx, special: 'annotsArray' });
1519
+ x = this.phase === 'B' && this.dropped.has(v.num) ? undefined : v;
1520
+ }
1521
+ else {
1522
+ const f = this.emit(types_1.PdfCategory.Corrupted, types_1.PdfDetail.MalformedObject, `${pctx.location} annotations`);
1523
+ // Kept or not, what it holds is still read.
1524
+ if (f.action !== 'strip')
1525
+ x = await this.tv(v, pctx, owner, p);
1526
+ else if (this.phase === 'A')
1527
+ await this.tv(v, { ...pctx, shadow: true }, owner, p);
1528
+ }
1529
+ if (x !== undefined)
1530
+ out.set(k, x);
1531
+ }
1532
+ else if (k === 'AA') {
1533
+ const x = await this.handleAA(v, { ...pctx, aaOwner: 'page' }, owner, p);
1534
+ if (x !== undefined)
1535
+ out.set(k, x);
1536
+ }
1537
+ else if (k === 'Contents' && !node && !(v instanceof objects_1.PdfRef) && !Array.isArray(v)) {
1538
+ const f = this.emit(types_1.PdfCategory.Corrupted, types_1.PdfDetail.MalformedObject, `${pctx.location} content`);
1539
+ if (f.action !== 'strip') {
1540
+ const x = await this.tv(v, pctx, owner, p);
1541
+ if (x !== undefined)
1542
+ out.set(k, x);
1543
+ }
1544
+ else if (this.phase === 'A')
1545
+ await this.tv(v, { ...pctx, shadow: true }, owner, p);
1546
+ }
1547
+ else if (k === 'Parent' && v instanceof objects_1.PdfRef) {
1548
+ out.set(k, v);
1549
+ if (this.phase === 'A')
1550
+ this.enqueue(v, { location: 'page tree', shadow: ctx.shadow });
1551
+ }
1552
+ else
1553
+ taken.delete(k);
1554
+ }
1555
+ await this.alsoAction(d, pctx, owner, path, out, taken);
1556
+ return this.handleGeneric(d, pctx, owner, path, taken, out);
1557
+ }
1558
+ /** Keeps only references to real, kept annotations. */
1559
+ async handleAnnotsArray(arr, ctx, owner, path) {
1560
+ // An array several pages show, or inherit, is measured against the smallest of them.
1561
+ const list = this.annotLists.get(owner + path);
1562
+ const pctx = list ? { ...ctx, location: `page ${list.index + 1}`, pageIndex: list.index, pageBox: list.box } : ctx;
1563
+ const out = [];
1564
+ for (let i = 0; i < arr.length; i++) {
1565
+ const v = arr[i];
1566
+ const actx = { ...pctx, location: `${pctx.location}, annotation ${i + 1}` };
1567
+ if (v instanceof objects_1.PdfRef) {
1568
+ const target = await this.doc.getObject(v);
1569
+ if (this.kindOf(target, v.num) !== 'annot') {
1570
+ const f = this.emit(types_1.PdfCategory.Corrupted, types_1.PdfDetail.MalformedObject, actx.location, { reason: 'not an annotation' });
1571
+ if (f.action === 'strip') {
1572
+ // The reference goes; what it points at is still read, so its findings are reported.
1573
+ if (this.phase === 'A')
1574
+ await this.processRef(v, { ...actx, shadow: true });
1575
+ continue;
1576
+ }
1577
+ if (target instanceof objects_1.PdfDict) {
1578
+ // Kept, it is an annotation to pdf.js all the same, so it gets the annotation rules. An object that
1579
+ // already has another role cannot get them, so its reference goes.
1580
+ const free = !this.visited.has(v.num) && v.num !== this.rootNum && v.num !== this.namesNum && v.num !== this.infoNum && !this.pageInfo.has(v.num) && !this.fileRole.has(v.num);
1581
+ if (this.phase === 'A' && free)
1582
+ this.annotRole.add(v.num);
1583
+ else {
1584
+ if (this.phase === 'A')
1585
+ this.emitRemoval(types_1.PdfCategory.Corrupted, types_1.PdfDetail.MalformedObject, actx.location, { reason: 'an annotation with another role' });
1586
+ continue;
1587
+ }
1588
+ }
1589
+ }
1590
+ if (this.phase === 'A')
1591
+ await this.processRef(v, actx);
1592
+ if (!this.dropped.has(v.num) && (this.phase === 'A' || this.newNum.has(v.num)))
1593
+ out.push(v);
1594
+ }
1595
+ else if (v instanceof objects_1.PdfDict && classify(v) === 'annot') {
1596
+ const x = await this.handleAnnot(v, actx, owner, `${path}[${i}]`);
1597
+ if (x)
1598
+ out.push(x);
1599
+ }
1600
+ else {
1601
+ const f = this.emit(types_1.PdfCategory.Corrupted, types_1.PdfDetail.MalformedObject, actx.location, { reason: 'not an annotation' });
1602
+ if (f.action !== 'strip') {
1603
+ // Kept, a dictionary is an annotation to pdf.js all the same.
1604
+ const x = v instanceof objects_1.PdfDict ? await this.handleAnnot(v, actx, owner, `${path}[${i}]`) : await this.tv(v, actx, owner, `${path}[${i}]`);
1605
+ if (x !== undefined)
1606
+ out.push(x);
1607
+ }
1608
+ else if (this.phase === 'A')
1609
+ await this.tv(v, { ...actx, shadow: true }, owner, `${path}[${i}]`);
1610
+ }
1611
+ }
1612
+ return out;
1613
+ }
1614
+ async handleOutline(d, ctx, owner, kind, path = '') {
1615
+ const title = text(d.get('Title'));
1616
+ const loc = kind === 'outline' ? `bookmark "${short(title ?? '')}"` : 'bookmarks';
1617
+ const out = new objects_1.PdfDict();
1618
+ const taken = new Set();
1619
+ for (const [k, v] of d.entries()) {
1620
+ const p = `${path}/${k}`;
1621
+ taken.add(k);
1622
+ if (kind === 'outline' && k === 'A') {
1623
+ const x = await this.actionRef(v, { trigger: 'bookmark', jsDetail: types_1.PdfDetail.Bookmark, location: loc, shadow: ctx.shadow }, owner, p);
1624
+ if (x !== undefined)
1625
+ out.set(k, x);
1626
+ }
1627
+ else if (k === 'Parent' && v instanceof objects_1.PdfRef)
1628
+ out.set(k, v);
1629
+ else
1630
+ taken.delete(k);
1631
+ }
1632
+ return this.handleGeneric(d, { location: loc, shadow: ctx.shadow }, owner, path, taken, out);
1633
+ }
1634
+ async handleNames(d, ctx, owner, path) {
1635
+ const out = new objects_1.PdfDict();
1636
+ const taken = new Set();
1637
+ for (const [k, v] of d.entries()) {
1638
+ const p = `${path}/${k}`;
1639
+ taken.add(k);
1640
+ // Both trees are written anew from the entries kept; the walk compares each entry, so here the tree stands as it was.
1641
+ if (k === 'JavaScript') {
1642
+ if (this.phase === 'A')
1643
+ out.set(k, v);
1644
+ else if (this.jsTree?.entries.length)
1645
+ out.set(k, new objects_1.PdfRef(-1, 0));
1646
+ }
1647
+ else if (k === 'EmbeddedFiles') {
1648
+ // The names dictionary is always walked for real too, which builds the tree.
1649
+ if (this.phase === 'A' && !ctx.shadow) {
1650
+ out.set(k, v);
1651
+ const entries = await this.collectNameTree(v);
1652
+ this.efTree = { entries: [] };
1653
+ for (const [idx, [name, value]] of entries.entries()) {
1654
+ const fctx = { location: `attachment "${short((0, objects_1.decodeTextString)(name.bytes))}"` };
1655
+ const x = await this.handleFilespecValue(value, fctx, `efname#${idx}`, '');
1656
+ if (lost(value, x))
1657
+ this.removedAt ??= fctx.location;
1658
+ if (x !== undefined)
1659
+ this.efTree.entries.push([name, x]);
1660
+ }
1661
+ if (this.efTree.entries.length < entries.length)
1662
+ this.removedAt ??= 'attachments';
1663
+ }
1664
+ else if (this.phase === 'B' && this.efTree?.entries.length)
1665
+ out.set(k, new objects_1.PdfRef(-2, 0));
1666
+ }
1667
+ else if (k === 'AlternatePresentations') {
1668
+ const entries = await this.collectNameTree(v);
1669
+ const f = this.emit(types_1.PdfCategory.Media, types_1.PdfDetail.Slideshow, 'document', { count: entries.length });
1670
+ if (f.action !== 'strip') {
1671
+ const x = await this.tv(v, { location: 'slideshow', shadow: ctx.shadow }, owner, p);
1672
+ if (x !== undefined)
1673
+ out.set(k, x);
1674
+ }
1675
+ else
1676
+ await this.reach(v);
1677
+ }
1678
+ else if (k === 'Renditions') {
1679
+ await this.collectNameTree(v);
1680
+ const f = this.emit(types_1.PdfCategory.Media, types_1.PdfDetail.Rendition, 'document renditions');
1681
+ if (f.action !== 'strip') {
1682
+ const x = await this.tv(v, { location: 'renditions', shadow: ctx.shadow }, owner, p);
1683
+ if (x !== undefined)
1684
+ out.set(k, x);
1685
+ }
1686
+ else
1687
+ await this.reach(v);
1688
+ }
1689
+ else
1690
+ taken.delete(k);
1691
+ }
1692
+ await this.alsoAction(d, ctx, owner, path, out, taken);
1693
+ return this.handleGeneric(d, { location: 'document names', shadow: ctx.shadow }, owner, path, taken, out);
1694
+ }
1695
+ async handleAcroForm(d, ctx, owner, path) {
1696
+ const out = new objects_1.PdfDict();
1697
+ const taken = new Set();
1698
+ for (const [k, v] of d.entries()) {
1699
+ const p = `${path}/AcroForm/${k}`;
1700
+ taken.add(k);
1701
+ if (k === 'CO') {
1702
+ this.coPresent = true;
1703
+ // Written inline once every calculation is decided. When a calculation script is removed, the order keeps
1704
+ // the fields whose calculations stay, and CALCULATION_ORDER reports an order left with none.
1705
+ if (this.phase === 'A') {
1706
+ out.set(k, v);
1707
+ if (v instanceof objects_1.PdfRef)
1708
+ await this.reach(v);
1709
+ const co = await this.doc.resolve(v);
1710
+ if (Array.isArray(co) && !ctx.shadow)
1711
+ for (const r of co)
1712
+ if (r instanceof objects_1.PdfRef)
1713
+ this.coFields.add(r.num);
1714
+ }
1715
+ else {
1716
+ const co = await this.doc.resolve(v);
1717
+ const kept = Array.isArray(co) ? co.filter(r => r instanceof objects_1.PdfRef && this.newNum.has(r.num) && (!this.calcRemoved || this.keptCalcFields.has(r.num))) : [];
1718
+ if (kept.length || (Array.isArray(co) && !this.calcRemoved))
1719
+ out.set(k, kept);
1720
+ else if (!this.calcRemoved || this.cfg.factory.actionFor(types_1.PdfCategory.Form, types_1.PdfDetail.CalculationOrder) !== 'strip')
1721
+ out.set(k, v);
1722
+ }
1723
+ }
1724
+ else if (k === 'Fields' && Array.isArray(await this.doc.resolve(v))) {
1725
+ const fields = (await this.doc.resolve(v));
1726
+ const arr = [];
1727
+ for (let i = 0; i < fields.length; i++) {
1728
+ const x = await this.tv(fields[i], { location: 'form field', shadow: ctx.shadow }, owner, `${p}[${i}]`);
1729
+ if (x !== undefined)
1730
+ arr.push(x);
1731
+ }
1732
+ out.set(k, arr);
1733
+ }
1734
+ else
1735
+ taken.delete(k);
1736
+ }
1737
+ return this.handleGeneric(d, { location: 'form', shadow: ctx.shadow }, owner, path, taken, out);
1738
+ }
1739
+ async handleField(d, ctx, owner, num, path = '') {
1740
+ const name = text(d.get('T')) ?? ctx.fieldName ?? '';
1741
+ const loc = `field "${short(name)}"`;
1742
+ if (d.has('FT') || d.has('T'))
1743
+ this.aggregate(types_1.PdfCategory.Form, types_1.PdfDetail.Fields);
1744
+ if (d.name('FT') === 'Sig' && d.has('V')) {
1745
+ this.aggregate(types_1.PdfCategory.Signature, types_1.PdfDetail.Signed);
1746
+ if (this.phase === 'A' && !ctx.shadow && !this.signed)
1747
+ this.signed = await this.signsWholeFile(d.get('V'));
1748
+ }
1749
+ const fctx = { location: loc, fieldName: name, shadow: ctx.shadow, aaOwner: 'field', fieldNum: num };
1750
+ const out = new objects_1.PdfDict();
1751
+ const taken = new Set();
1752
+ for (const [k, v] of d.entries()) {
1753
+ const p = `${path}/${k}`;
1754
+ taken.add(k);
1755
+ if (k === 'A') {
1756
+ const x = await this.actionRef(v, { trigger: 'field', jsDetail: types_1.PdfDetail.Field, location: loc, fieldName: name, shadow: ctx.shadow }, owner, p);
1757
+ if (x !== undefined)
1758
+ out.set(k, x);
1759
+ }
1760
+ else
1761
+ taken.delete(k);
1762
+ }
1763
+ // /Parent goes through the generic rules, so an ancestor missing from /AcroForm /Fields is still walked:
1764
+ // readers inherit its type, value and triggers.
1765
+ return this.handleGeneric(d, fctx, owner, path, taken, out);
1766
+ }
1767
+ /** Whether a signature value covers every byte of the file but its own /Contents, which sits right after its key. */
1768
+ async signsWholeFile(v) {
1769
+ const sig = await this.doc.resolve(v);
1770
+ if (!(sig instanceof objects_1.PdfDict))
1771
+ return false;
1772
+ const range = await this.doc.resolve(sig.get('ByteRange'));
1773
+ const contents = await this.doc.resolve(sig.get('Contents'));
1774
+ if (!Array.isArray(range) || range.length !== 4 || !range.every(x => typeof x === 'number' && Number.isInteger(x) && x >= 0) || !(contents instanceof objects_1.PdfString))
1775
+ return false;
1776
+ // The bytes from `gap` to `end` are left out: "<", the hex digits of /Contents, and ">".
1777
+ const [start, gap, end, rest] = range;
1778
+ if (start !== 0 || gap === 0 || end !== gap + 2 + 2 * contents.bytes.length || end + rest !== this.doc.size)
1779
+ return false;
1780
+ const key = Buffer.from(await this.doc.reader.read(Math.max(0, gap - 16), Math.min(gap, 16) + 1)).toString('latin1');
1781
+ return /\/Contents\s*<$/.test(key) && (await this.doc.reader.read(end - 1, 1))[0] === 0x3e;
1782
+ }
1783
+ // ---------- annotations ----------
1784
+ async handleAnnot(d, ctx, owner, path, num) {
1785
+ // pdf.js resolves a /Subtype given by reference.
1786
+ const subtype = await this.doc.resolve(d.get('Subtype'));
1787
+ const sub = subtype instanceof objects_1.PdfName ? subtype.name : '';
1788
+ const placed = num !== undefined ? this.annotPage.get(num) : undefined;
1789
+ const pageBox = placed?.box ?? ctx.pageBox;
1790
+ const isWidget = sub === 'Widget';
1791
+ const widgetName = isWidget ? (text(d.get('T')) ?? ctx.fieldName) : undefined;
1792
+ let loc = placed ? `page ${placed.index + 1}, annotation ${placed.k + 1}` : ctx.location;
1793
+ if (widgetName !== undefined)
1794
+ loc = `field "${short(widgetName)}"${placed ? ` on page ${placed.index + 1}` : ''}`;
1795
+ const actx = { location: loc, pageIndex: placed?.index ?? ctx.pageIndex, pageBox, shadow: ctx.shadow, fieldName: widgetName };
1796
+ let fileKept;
1797
+ if (MEDIA_ANNOTS[sub]) {
1798
+ const f = this.emit(types_1.PdfCategory.Media, MEDIA_ANNOTS[sub], loc);
1799
+ if (f.action === 'strip') {
1800
+ await this.shadowScan(d, actx, owner, path);
1801
+ return undefined;
1802
+ }
1803
+ }
1804
+ else if (sub === 'FileAttachment' && d.has('FS')) {
1805
+ const x = await this.handleFilespecValue(d.get('FS'), actx, owner, `${path}/FS`);
1806
+ if (x === undefined) {
1807
+ await this.shadowScan(d, actx, owner, path);
1808
+ return undefined;
1809
+ }
1810
+ fileKept = x;
1811
+ }
1812
+ else if (!ANNOT_ALLOWED.has(sub)) {
1813
+ const f = this.emit(types_1.PdfCategory.Annotation, types_1.PdfDetail.UnknownSubtype, loc, { subtype: sub || 'none' });
1814
+ if (f.action === 'strip') {
1815
+ await this.shadowScan(d, actx, owner, path);
1816
+ return undefined;
1817
+ }
1818
+ }
1819
+ // pdf.js draws a push button with an action as a link, and a click on any widget runs its /A.
1820
+ if (((sub === 'Link' && d.has('Dest')) || ((sub === 'Link' || isWidget) && d.has('A'))) && pageBox) {
1821
+ const r = await this.box(d.get('Rect'));
1822
+ if (r) {
1823
+ const [x1, y1, x2, y2] = r;
1824
+ const [bx1, by1, bx2, by2] = pageBox;
1825
+ const area = (x2 - x1) * (y2 - y1);
1826
+ const pageArea = Math.abs((bx2 - bx1) * (by2 - by1));
1827
+ if (pageArea > 0 && area / pageArea >= 0.5) {
1828
+ const f = this.emit(types_1.PdfCategory.Link, types_1.PdfDetail.FullPage, loc);
1829
+ if (f.action === 'strip') {
1830
+ await this.shadowScan(d, actx, owner, path);
1831
+ return undefined;
1832
+ }
1833
+ }
1834
+ }
1835
+ }
1836
+ if (isWidget && (d.has('FT') || d.has('T')))
1837
+ this.aggregate(types_1.PdfCategory.Form, types_1.PdfDetail.Fields);
1838
+ if (isWidget && d.name('FT') === 'Sig' && d.has('V')) {
1839
+ this.aggregate(types_1.PdfCategory.Signature, types_1.PdfDetail.Signed);
1840
+ if (this.phase === 'A' && !ctx.shadow && !this.signed)
1841
+ this.signed = await this.signsWholeFile(d.get('V'));
1842
+ }
1843
+ // A widget's /TU is the tooltip pdf.js shows on a push button.
1844
+ const labels = [text(await this.doc.resolve(d.get('Contents'))), isWidget ? text(await this.doc.resolve(d.get('TU'))) : undefined, num !== undefined ? this.altText.get(num) : undefined];
1845
+ const jsDetail = sub === 'Link' ? types_1.PdfDetail.Link : isWidget ? types_1.PdfDetail.Field : types_1.PdfDetail.Annotation;
1846
+ const out = new objects_1.PdfDict();
1847
+ const taken = new Set();
1848
+ for (const [k, v] of d.entries()) {
1849
+ const p = `${path}/${k}`;
1850
+ taken.add(k);
1851
+ if (k === 'A' || k === 'PA') {
1852
+ if (await this.labelMismatch(v, labels, loc)) {
1853
+ await this.actionRef(v, { trigger: 'link', jsDetail, location: loc, shadow: true }, owner, p);
1854
+ continue;
1855
+ }
1856
+ const x = await this.actionRef(v, { trigger: sub === 'Link' ? 'link' : 'click', jsDetail, location: loc, fieldName: widgetName, shadow: ctx.shadow }, owner, p);
1857
+ if (x !== undefined)
1858
+ out.set(k, x);
1859
+ }
1860
+ else if (k === 'AA') {
1861
+ const x = await this.handleAA(v, { ...actx, aaOwner: isWidget ? 'widget' : 'annot', fieldNum: isWidget ? num : undefined }, owner, p);
1862
+ if (x !== undefined)
1863
+ out.set(k, x);
1864
+ }
1865
+ else if (k === 'FS' && sub === 'FileAttachment') {
1866
+ if (fileKept !== undefined)
1867
+ out.set(k, fileKept);
1868
+ }
1869
+ else if (k === 'P' && v instanceof objects_1.PdfRef)
1870
+ out.set(k, v);
1871
+ else
1872
+ taken.delete(k);
1873
+ }
1874
+ await this.alsoAction(d, actx, owner, path, out, taken);
1875
+ return this.handleGeneric(d, actx, owner, path, taken, out);
1876
+ }
1877
+ /**
1878
+ * A link whose tooltip or alt text names a different site than a web address its click opens. Readers perform
1879
+ * the /Next actions too, so the whole chain is checked; a chain too long to check counts as a mismatch.
1880
+ */
1881
+ async labelMismatch(v, shownLabels, loc) {
1882
+ if (!shownLabels.some(Boolean))
1883
+ return false;
1884
+ // Only the first 4096 characters of a label are read. Many links can share one chain and one label, so the
1885
+ // outcome is kept per chain object and label.
1886
+ const labels = shownLabels.map(t => t?.slice(0, 4096));
1887
+ const key = v instanceof objects_1.PdfRef ? `${v.num}:${(0, node_crypto_1.createHash)('md5').update(JSON.stringify(labels)).digest('hex')}` : undefined;
1888
+ let found = key === undefined ? undefined : this.labelChecks.get(key);
1889
+ if (found === undefined) {
1890
+ found = null;
1891
+ // The sites a label names are read once, as a lookup, not once per action down the chain.
1892
+ const shown = labels.map(t => (t ? new uri_1.LabelSites((0, uri_1.hostsIn)(t)) : undefined));
1893
+ const queue = [v];
1894
+ const seen = new Set();
1895
+ walk: for (const queued of queue) {
1896
+ this.doc.checkTime();
1897
+ if (queue.length > 256) {
1898
+ found = { reason: 'action chain too long to check' };
1899
+ break;
1900
+ }
1901
+ let a = queued;
1902
+ let num;
1903
+ if (a instanceof objects_1.PdfRef) {
1904
+ if (seen.has(a.num))
1905
+ continue;
1906
+ seen.add(a.num);
1907
+ num = a.num;
1908
+ a = await this.doc.getObject(a);
1909
+ }
1910
+ if (Array.isArray(a)) {
1911
+ queue.push(...a.slice(0, 257));
1912
+ continue;
1913
+ }
1914
+ if (!(a instanceof objects_1.PdfDict))
1915
+ continue;
1916
+ queue.push(a.get('Next'));
1917
+ if ((await this.actionType(a)) !== 'URI')
1918
+ continue;
1919
+ // What an action's address offers a label does not depend on the label, so each action object works it out once.
1920
+ let sites = num === undefined ? undefined : this.chainSites.get(num);
1921
+ if (!sites) {
1922
+ sites = [];
1923
+ for (const uri of uriReadings(await this.doc.resolve(a.get('URI')))) {
1924
+ // A target that fails on its own is removed by the action rules.
1925
+ const verdict = (0, uri_1.checkUri)(uri, this.uriBase);
1926
+ if (verdict.detail === types_1.PdfDetail.Safe)
1927
+ for (const site of verdict.sites ?? [])
1928
+ sites.push([site, uri]);
1929
+ }
1930
+ if (num !== undefined)
1931
+ this.chainSites.set(num, sites);
1932
+ }
1933
+ for (const [site, uri] of sites) {
1934
+ if (!shown.some(s => s !== undefined && s.size > 0 && !s.has(site)))
1935
+ continue;
1936
+ found = { uri: short(uri, 120) };
1937
+ break walk;
1938
+ }
1939
+ }
1940
+ if (key !== undefined)
1941
+ this.labelChecks.set(key, found);
1942
+ }
1943
+ return found !== null && this.emit(types_1.PdfCategory.Link, types_1.PdfDetail.TextMismatch, loc, { ...found }).action === 'strip';
1944
+ }
1945
+ /** Walks content being removed, so its findings are still reported. */
1946
+ async shadowScan(d, ctx, owner, path) {
1947
+ if (this.phase !== 'A')
1948
+ return;
1949
+ const sctx = { ...ctx, shadow: true };
1950
+ await this.alsoAction(d, sctx, owner, path, new objects_1.PdfDict(), new Set());
1951
+ for (const [k, v] of d.entries()) {
1952
+ if (k === 'P' || k === 'Parent')
1953
+ continue;
1954
+ if (k === 'A' || k === 'PA')
1955
+ await this.actionRef(v, { ...sctx, jsDetail: ctx.jsDetail ?? types_1.PdfDetail.Annotation, trigger: 'click' }, owner, `${path}/${k}`);
1956
+ else if (k === 'AA')
1957
+ await this.handleAA(v, { ...sctx, aaOwner: 'annot' }, owner, `${path}/${k}`);
1958
+ else {
1959
+ if (k === 'JS')
1960
+ this.emit(types_1.PdfCategory.JavaScript, types_1.PdfDetail.Unattached, ctx.location);
1961
+ // Appearance streams, an attached file's stream and the like: not unreferenced, though removed.
1962
+ await this.reach(v);
1963
+ }
1964
+ }
1965
+ }
1966
+ // ---------- actions ----------
1967
+ /**
1968
+ * A reference that must lead to an action. Anything else loses the reference but is not dropped,
1969
+ * since it may be legitimate where else it is used. In additional-actions dictionaries only kept scripts stay.
1970
+ */
1971
+ async actionRef(v, slotCtx, owner, path, triggered = slotCtx.triggered === true) {
1972
+ if (v === undefined || v === null)
1973
+ return undefined;
1974
+ // A triggered action's chain runs on the same event, so every action down it gets the rule for triggered actions.
1975
+ const ctx = triggered ? { ...slotCtx, triggered } : slotCtx;
1976
+ // Deciding again for another slot redoes only what can differ by slot: the scripts, down the chain. A slot in
1977
+ // the shared object that the rules already emptied stays empty for every slot.
1978
+ const again = this.deciding.size > 0;
1979
+ if (again && this.refusedSlots.has(owner + path))
1980
+ return undefined;
1981
+ if (again)
1982
+ this.againBudget -= 64;
1983
+ if (again && this.againBudget <= 0) {
1984
+ this.refusals++;
1985
+ this.quiet++;
1986
+ return undefined;
1987
+ }
1988
+ const target = v instanceof objects_1.PdfRef ? await this.doc.getObject(v) : v;
1989
+ const s = await this.actionType(target);
1990
+ const kind = this.kindOf(target, v instanceof objects_1.PdfRef ? v.num : undefined, s);
1991
+ if (kind !== 'action' || !(target instanceof objects_1.PdfDict)) {
1992
+ // An action of a type this package does not know, or not an action at all.
1993
+ const unknown = s !== undefined && !ACTION_TYPES.has(s) && kind !== 'annot';
1994
+ const f = again ? undefined : unknown ? this.emit(types_1.PdfCategory.Action, types_1.PdfDetail.Unknown, ctx.location, { action: s }) : this.emit(types_1.PdfCategory.Corrupted, types_1.PdfDetail.MalformedObject, ctx.location, { reason: 'not an action' });
1995
+ const action = f?.action ?? (unknown ? this.cfg.factory.actionFor(types_1.PdfCategory.Action, types_1.PdfDetail.Unknown) : this.cfg.factory.actionFor(types_1.PdfCategory.Corrupted, types_1.PdfDetail.MalformedObject));
1996
+ // Kept, a dictionary that is also an action runs as one in this chain, so its script is decided for this slot,
1997
+ // also when the slot is asked again. Its own role decides it under no trigger.
1998
+ if (action !== 'strip' && this.phase === 'A' && !ctx.shadow && target instanceof objects_1.PdfDict && (target.name('Type') === 'Action' || (s !== undefined && ACTION_TYPES.has(s)))) {
1999
+ if (!(v instanceof objects_1.PdfRef))
2000
+ await this.handleAction(target, ctx, owner, path, false);
2001
+ else {
2002
+ if (!this.visited.has(v.num))
2003
+ await this.processRef(v, ctx);
2004
+ if (!this.dropped.has(v.num))
2005
+ await this.decideForSlot(v.num, owner + path, ctx, () => this.handleAction(target, ctx, `obj:${v.num}`, '', false));
2006
+ }
2007
+ }
2008
+ if (again)
2009
+ return undefined;
2010
+ // Kept or not, it is walked like any other value, so a script inside it is found.
2011
+ if (action !== 'strip')
2012
+ return this.tv(v, ctx, owner, path);
2013
+ if (this.phase === 'A')
2014
+ await this.tv(v, { ...ctx, shadow: true }, owner, path);
2015
+ return undefined;
2016
+ }
2017
+ if (triggered && s !== 'JavaScript') {
2018
+ // Asked again for another slot, the rule holds for that slot as for the first, and costs it the reference.
2019
+ const f = this.emit(types_1.PdfCategory.Action, types_1.PdfDetail.Triggered, ctx.location, { action: s ?? 'none' });
2020
+ if (f.action === 'strip') {
2021
+ if (again) {
2022
+ this.refusals++;
2023
+ return undefined;
2024
+ }
2025
+ if (this.phase === 'A')
2026
+ this.refusedSlots.add(owner + path);
2027
+ // Still report what the trigger would have done.
2028
+ if (v instanceof objects_1.PdfRef) {
2029
+ if (this.phase === 'A')
2030
+ await this.processRef(v, { ...ctx, shadow: true });
2031
+ }
2032
+ else
2033
+ await this.handleAction(target, { ...ctx, shadow: true }, owner, path);
2034
+ return undefined;
2035
+ }
2036
+ }
2037
+ if (v instanceof objects_1.PdfRef) {
2038
+ if (this.phase === 'A') {
2039
+ if (ctx.shadow) {
2040
+ this.enqueue(v, ctx);
2041
+ return v;
2042
+ }
2043
+ const seen = this.visited.has(v.num);
2044
+ await this.processRef(v, ctx);
2045
+ // Decided under another slot's trigger: a script in it, or down its chain, is asked about again for this one.
2046
+ if (seen && !this.dropped.has(v.num) && !this.scriptFree.has(v.num))
2047
+ await this.decideForSlot(v.num, owner + path, ctx, () => this.handleAction(target, ctx, `obj:${v.num}`, '', false));
2048
+ }
2049
+ return this.dropped.has(v.num) || this.refusedSlots.has(owner + path) ? undefined : v;
2050
+ }
2051
+ return this.handleAction(target, ctx, owner, path);
2052
+ }
2053
+ /**
2054
+ * For a slot that reaches an object already decided from another slot: `run` asks the plugins again under this
2055
+ * slot's trigger. The object keeps its first decision, and this slot loses its reference when a script under it
2056
+ * would not be kept here. Past a set budget of this work, the slot loses it without asking.
2057
+ */
2058
+ async decideForSlot(num, slot, ctx, run) {
2059
+ if (this.deciding.has(num))
2060
+ return;
2061
+ this.doc.checkTime();
2062
+ const before = this.refusals;
2063
+ const quiet = this.quiet;
2064
+ this.againBudget -= 1024;
2065
+ if (this.againBudget > 0) {
2066
+ this.deciding.add(num);
2067
+ try {
2068
+ await run();
2069
+ }
2070
+ finally {
2071
+ this.deciding.delete(num);
2072
+ }
2073
+ }
2074
+ else {
2075
+ this.refusals++;
2076
+ this.quiet++;
2077
+ }
2078
+ // Only the outermost slot is refused: a slot inside the shared object belongs to every slot that reaches it.
2079
+ if (this.refusals === before || this.deciding.size)
2080
+ return;
2081
+ this.refusedSlots.add(slot);
2082
+ // A refusal nothing reported yet, because no plugin was asked or it asked for a different rewrite.
2083
+ if (this.quiet !== quiet) {
2084
+ const reason = this.againBudget > 0 ? 'a plugin rewrote it differently for another action' : 'not checked again: work limit reached';
2085
+ this.emitRemoval(types_1.PdfCategory.JavaScript, ctx.jsDetail ?? triggerFor(ctx.aaOwner, '')[1], ctx.location, { trigger: ctx.trigger ?? 'additional actions', reason });
2086
+ }
2087
+ }
2088
+ /**
2089
+ * An annotation, catalog, page or names dictionary whose content also makes it an action runs as that action
2090
+ * from any slot that points at it. Its action keys get the action rules. When those remove the action, /S goes,
2091
+ * so no reader runs it.
2092
+ */
2093
+ async alsoAction(d, ctx, owner, path, out, taken) {
2094
+ const s = await this.actionType(d);
2095
+ if (d.name('Type') !== 'Action' && (s === undefined || !ACTION_TYPES.has(s)))
2096
+ return;
2097
+ const a = await this.handleAction(d, ctx, owner, path, false);
2098
+ for (const k of s === 'JavaScript' || s === 'Rendition' ? ['S', 'Next', 'JS'] : ['S', 'Next']) {
2099
+ taken.add(k);
2100
+ const x = a?.get(k);
2101
+ if (x !== undefined)
2102
+ out.set(k, x);
2103
+ }
2104
+ }
2105
+ async handleAA(v, ctx, owner, path) {
2106
+ if (v instanceof objects_1.PdfRef) {
2107
+ if (this.phase === 'A') {
2108
+ const target = await this.doc.getObject(v);
2109
+ if (this.kindOf(target, v.num) !== 'generic' || v.num === this.namesNum || v.num === this.infoNum) {
2110
+ // An object with a role of its own cannot also be additional actions.
2111
+ const f = this.emit(types_1.PdfCategory.Corrupted, types_1.PdfDetail.MalformedObject, ctx.location, { reason: 'not additional actions' });
2112
+ if (f.action === 'strip') {
2113
+ this.refusedSlots.add(owner + path);
2114
+ if (target instanceof objects_1.PdfDict)
2115
+ await this.handleAA(target, { ...ctx, shadow: true }, owner, path);
2116
+ }
2117
+ else
2118
+ await this.processRef(v, ctx);
2119
+ }
2120
+ else if (!this.visited.has(v.num))
2121
+ await this.processRef(v, { ...ctx, special: 'aa' });
2122
+ else if (!this.dropped.has(v.num) && target instanceof objects_1.PdfDict) {
2123
+ if (this.special.get(v.num) !== 'aa') {
2124
+ // Reached first some other way: it is written as additional actions all the same, so the rules for
2125
+ // them run now, and a script decided under the other role is asked about again.
2126
+ this.special.set(v.num, 'aa');
2127
+ const before = this.refusals;
2128
+ const x = await this.handleAA(target, ctx, `obj:${v.num}`, '');
2129
+ if (lost(target, x))
2130
+ this.removedAt ??= ctx.location;
2131
+ if (x === undefined)
2132
+ this.dropped.add(v.num);
2133
+ else if (this.refusals !== before)
2134
+ this.refusedSlots.add(owner + path);
2135
+ }
2136
+ else if (!this.scriptFree.has(v.num))
2137
+ await this.decideForSlot(v.num, owner + path, ctx, () => this.handleAA(target, ctx, `obj:${v.num}`, ''));
2138
+ }
2139
+ }
2140
+ return this.dropped.has(v.num) || this.refusedSlots.has(owner + path) ? undefined : v;
2141
+ }
2142
+ if (!(v instanceof objects_1.PdfDict)) {
2143
+ if (v === null)
2144
+ return undefined;
2145
+ const f = this.emit(types_1.PdfCategory.Corrupted, types_1.PdfDetail.MalformedObject, ctx.location, { reason: 'not additional actions' });
2146
+ return f.action === 'strip' ? undefined : this.tv(v, ctx, owner, path);
2147
+ }
2148
+ const out = new objects_1.PdfDict();
2149
+ let gone = false;
2150
+ for (const [k, a] of v.entries()) {
2151
+ const [trigger, jsDetail] = triggerFor(ctx.aaOwner, k);
2152
+ const actx = { location: `${ctx.location} ${trigger} trigger`, trigger, jsDetail, fieldName: ctx.fieldName, shadow: ctx.shadow };
2153
+ const x = await this.actionRef(a, actx, owner, `${path}/${k}`, true);
2154
+ // A shadow visit returns references unchanged, but removes them all the same.
2155
+ const calc = k === 'C' && (ctx.aaOwner === 'field' || ctx.aaOwner === 'widget');
2156
+ if (calc && (x === undefined || ctx.shadow) && a !== null)
2157
+ this.calcRemoved = true;
2158
+ if (x !== undefined) {
2159
+ out.set(k, x);
2160
+ if (calc && !ctx.shadow) {
2161
+ const num = ctx.fieldNum ?? Number(owner.startsWith('obj:') ? owner.slice(4) : Number.NaN);
2162
+ if (Number.isFinite(num))
2163
+ this.keptCalcFields.add(num);
2164
+ }
2165
+ }
2166
+ else if (a !== null)
2167
+ gone = true;
2168
+ }
2169
+ // Emptied by the rules, it goes; empty to begin with, it stays as it was.
2170
+ return gone && !out.keys().length ? undefined : out;
2171
+ }
2172
+ /**
2173
+ * Decodes a script only when something will read it, and never past the memory threshold. `num` is the object
2174
+ * that holds the text, at the end of any chain of references, so a rewrite lands where a reader looks.
2175
+ */
2176
+ async scriptText(js) {
2177
+ // pdf.js removes NUL characters before it runs a script, so the plugins read the text without them.
2178
+ if (js instanceof objects_1.PdfString)
2179
+ return { text: (0, objects_1.decodeTextString)(js.bytes).replaceAll('\0', ''), length: js.bytes.length };
2180
+ if (!(js instanceof objects_1.PdfRef))
2181
+ return { text: '', length: 0 };
2182
+ let ref = js;
2183
+ let o = await this.doc.getObject(ref);
2184
+ for (let i = 1; i < 32 && o instanceof objects_1.PdfRef; i++) {
2185
+ ref = o;
2186
+ o = await this.doc.getObject(ref);
2187
+ }
2188
+ // A chain that never ends gives no text, so no plugin keeps it.
2189
+ if (o instanceof objects_1.PdfRef)
2190
+ return { length: 0 };
2191
+ if (o instanceof objects_1.PdfString)
2192
+ return { text: (0, objects_1.decodeTextString)(o.bytes).replaceAll('\0', ''), length: o.bytes.length, num: ref.num };
2193
+ if (!(o instanceof objects_1.PdfStream))
2194
+ return { text: '', length: 0 };
2195
+ const wanted = (this.options.scriptPlugins?.length ?? 0) > 0 || (this.cfg.verify?.scripts.size ?? 0) > 0;
2196
+ if (!wanted)
2197
+ return { length: o.length, num: ref.num };
2198
+ try {
2199
+ const bytes = await this.doc.decode(o, ref.num, this.options.limits?.decompressedBytes, this.options.memoryThreshold ?? 8 * 1024 * 1024);
2200
+ return { text: (0, objects_1.decodeTextString)(bytes).replaceAll('\0', ''), length: bytes.length, num: ref.num };
2201
+ }
2202
+ catch (e) {
2203
+ // A limit stops the run; anything else means the script cannot be read, so it goes.
2204
+ if (e instanceof filters_1.DecompressionLimitError || e instanceof document_1.TimeLimitError || e instanceof document_1.ObjectLimitError)
2205
+ throw e;
2206
+ return { length: o.length, num: ref.num };
2207
+ }
2208
+ }
2209
+ /**
2210
+ * Runs the script plugins. Returns whether the script stays. A script already decided under `key` is asked about
2211
+ * again for another trigger that reaches it: it keeps its first decision, and a different answer counts as a refusal.
2212
+ */
2213
+ async decideScript(js, ctx, key) {
2214
+ if (this.phase === 'B')
2215
+ return this.scriptDecisions.get(key) ?? { keep: false };
2216
+ const first = ctx.shadow ? undefined : this.scriptDecisions.get(key);
2217
+ // A script already removed stays removed; past the budget, a kept one counts as refused unasked.
2218
+ if (first && (!first.keep || this.againBudget <= 0)) {
2219
+ if (first.keep) {
2220
+ this.refusals++;
2221
+ this.quiet++;
2222
+ }
2223
+ return first;
2224
+ }
2225
+ const { text: src, length, num } = await this.scriptText(js);
2226
+ if (first)
2227
+ this.againBudget -= length >> 4;
2228
+ const verify = src !== undefined ? this.cfg.verify?.scripts.get(src) : undefined;
2229
+ if (verify) {
2230
+ this.emit(types_1.PdfCategory.JavaScript, verify.result === 'passed' ? types_1.PdfDetail.PluginPassed : types_1.PdfDetail.PluginScrubbed, ctx.location, { plugin: verify.plugin });
2231
+ if (first)
2232
+ return first;
2233
+ const d = { keep: true };
2234
+ this.scriptDecisions.set(key, d);
2235
+ return d;
2236
+ }
2237
+ // Made now and recorded once the plugins have answered, so it carries its final action.
2238
+ const f = this.cfg.factory.make(types_1.PdfCategory.JavaScript, ctx.jsDetail ?? types_1.PdfDetail.Annotation, ctx.location, { trigger: ctx.trigger ?? 'unknown', length });
2239
+ let decision = { keep: f.action !== 'strip' };
2240
+ let answer;
2241
+ if (f.action === 'strip' && !ctx.shadow && src !== undefined) {
2242
+ for (const plugin of this.options.scriptPlugins ?? []) {
2243
+ const script = { text: src, trigger: ctx.trigger ?? 'unknown', location: ctx.location, fieldName: ctx.fieldName, keptDocumentScripts: [...this.keptDocScripts] };
2244
+ const data = { plugin: plugin.name };
2245
+ try {
2246
+ if (!(await plugin.accepts(script)))
2247
+ continue;
2248
+ const r = await plugin.process(script);
2249
+ if (r.result === 'passed') {
2250
+ decision = { keep: true };
2251
+ answer = this.cfg.factory.make(types_1.PdfCategory.JavaScript, types_1.PdfDetail.PluginPassed, ctx.location, data);
2252
+ if (!first)
2253
+ this.kept.scripts.set(src, { result: 'passed', plugin: plugin.name });
2254
+ }
2255
+ else if (r.result === 'scrubbed' && typeof r.text === 'string' && r.text.length) {
2256
+ decision = { keep: true, text: r.text };
2257
+ answer = this.cfg.factory.make(types_1.PdfCategory.JavaScript, types_1.PdfDetail.PluginScrubbed, ctx.location, data);
2258
+ if (!first) {
2259
+ this.kept.scripts.set(r.text, { result: 'scrubbed', plugin: plugin.name });
2260
+ if (num !== undefined)
2261
+ this.jsReplace.set(num, (0, objects_1.encodeTextString)(r.text));
2262
+ }
2263
+ }
2264
+ else
2265
+ answer = this.cfg.factory.make(types_1.PdfCategory.JavaScript, r.result === 'removed' ? types_1.PdfDetail.PluginRemoved : types_1.PdfDetail.PluginFailed, ctx.location, data);
2266
+ }
2267
+ catch (e) {
2268
+ this.passOn(e);
2269
+ answer = this.cfg.factory.make(types_1.PdfCategory.JavaScript, types_1.PdfDetail.PluginFailed, ctx.location, data);
2270
+ }
2271
+ break;
2272
+ }
2273
+ if (decision.keep)
2274
+ f.action = 'info';
2275
+ }
2276
+ if (ctx.shadow) {
2277
+ // Recorded when the walk ends, after any real visit that keeps the script has corrected it.
2278
+ const list = this.shadowScripts.get(key);
2279
+ if (list)
2280
+ list.push(f);
2281
+ else
2282
+ this.shadowScripts.set(key, [f]);
2283
+ return { keep: false };
2284
+ }
2285
+ this.push(f);
2286
+ if (answer)
2287
+ this.push(answer);
2288
+ if (first) {
2289
+ // One object holds one text, so a different rewrite is a refusal too, and only the slot can report it.
2290
+ if (first.keep && (!decision.keep || decision.text !== first.text)) {
2291
+ this.refusals++;
2292
+ if (decision.keep)
2293
+ this.quiet++;
2294
+ }
2295
+ else if (decision.keep && ctx.docScriptName !== undefined)
2296
+ this.keptDocScripts.push(ctx.docScriptName);
2297
+ return first;
2298
+ }
2299
+ if (decision.keep) {
2300
+ // Reported as removed when first seen as removed content; it is kept after all.
2301
+ for (const prior of this.shadowScripts.get(key) ?? [])
2302
+ prior.action = 'info';
2303
+ if (ctx.docScriptName !== undefined)
2304
+ this.keptDocScripts.push(ctx.docScriptName);
2305
+ }
2306
+ this.scriptDecisions.set(key, decision);
2307
+ return decision;
2308
+ }
2309
+ /** `rest` false handles only the action's own keys (/S, /JS, /Next), for an object that is also something else. */
2310
+ async handleAction(d, ctx, owner, path, rest = true) {
2311
+ const s = (await this.actionType(d)) ?? '';
2312
+ const loc = ctx.location;
2313
+ const script = s === 'JavaScript' || s === 'Rendition';
2314
+ const again = this.deciding.size > 0;
2315
+ let keep = true;
2316
+ let jsText;
2317
+ if (s === 'JavaScript') {
2318
+ const dec = await this.decideScript(d.get('JS'), ctx, owner + path);
2319
+ keep = dec.keep;
2320
+ jsText = dec.text;
2321
+ }
2322
+ else if (s === 'Rendition') {
2323
+ if (d.has('JS')) {
2324
+ const dec = await this.decideScript(d.get('JS'), { ...ctx, jsDetail: types_1.PdfDetail.Rendition }, owner + path);
2325
+ if (!dec.keep)
2326
+ keep = false;
2327
+ jsText = dec.text;
2328
+ }
2329
+ if (!again && this.emit(types_1.PdfCategory.Media, types_1.PdfDetail.Rendition, loc).action === 'strip')
2330
+ keep = false;
2331
+ }
2332
+ else if (again) {
2333
+ // Decided again for another slot: only a script can be decided differently, and the rest was settled before.
2334
+ }
2335
+ else if (s === 'URI') {
2336
+ // Every reading a reader may take has to pass.
2337
+ const readings = uriReadings(await this.doc.resolve(d.get('URI')));
2338
+ let uri = readings[0];
2339
+ let verdict = (0, uri_1.checkUri)(uri, this.uriBase);
2340
+ for (let i = 1; i < readings.length && verdict.detail === types_1.PdfDetail.Safe; i++) {
2341
+ uri = readings[i];
2342
+ verdict = (0, uri_1.checkUri)(uri, this.uriBase);
2343
+ }
2344
+ if (verdict.detail === types_1.PdfDetail.Safe) {
2345
+ // A link being removed for another reason is not "kept", so it is not counted as safe.
2346
+ const f = ctx.shadow
2347
+ ? undefined
2348
+ : this.aggregate(types_1.PdfCategory.Link, types_1.PdfDetail.Safe, data => {
2349
+ const hosts = String(data.hosts ?? '')
2350
+ .split(', ')
2351
+ .filter(Boolean);
2352
+ if (verdict.host && hosts.length < 20 && !hosts.includes(verdict.host))
2353
+ hosts.push(verdict.host);
2354
+ data.hosts = hosts.join(', ');
2355
+ });
2356
+ keep = (f?.action ?? this.cfg.factory.actionFor(types_1.PdfCategory.Link, types_1.PdfDetail.Safe)) !== 'strip';
2357
+ }
2358
+ else {
2359
+ const f = verdict.detail === types_1.PdfDetail.Url ? this.emit(types_1.PdfCategory.JavaScript, types_1.PdfDetail.Url, loc, { uri: short(uri, 120) }) : this.emit(types_1.PdfCategory.Link, verdict.detail, loc, { uri: short(uri, 120) });
2360
+ keep = f.action !== 'strip';
2361
+ }
2362
+ }
2363
+ else if (s === 'GoTo' || s === 'GoToDp' || s === 'Trans' || s === 'ResetForm' || (s === 'Thread' && !d.has('F')) || (s === 'Named' && NAV_NAMED.has(d.name('N') ?? ''))) {
2364
+ // Navigation: kept.
2365
+ }
2366
+ else if (s === 'Thread') {
2367
+ keep = this.emit(types_1.PdfCategory.Action, types_1.PdfDetail.RemoteGoto, loc).action !== 'strip';
2368
+ }
2369
+ else if (s === 'Named') {
2370
+ keep = this.emit(types_1.PdfCategory.Action, types_1.PdfDetail.Named, loc, { name: d.name('N') ?? '' }).action !== 'strip';
2371
+ }
2372
+ else if (ACTION_DETAIL[s]) {
2373
+ const [cat, det] = ACTION_DETAIL[s];
2374
+ keep = this.emit(cat, det, loc).action !== 'strip';
2375
+ }
2376
+ else {
2377
+ keep = this.emit(types_1.PdfCategory.Action, types_1.PdfDetail.Unknown, loc, { action: s || 'none' }).action !== 'strip';
2378
+ }
2379
+ // The rest of the chain.
2380
+ const next = d.get('Next');
2381
+ const nctx = { ...ctx, jsDetail: types_1.PdfDetail.Chained, location: `${loc} (chained)`, shadow: ctx.shadow || !keep, docScriptName: undefined };
2382
+ let nextOut;
2383
+ if (next !== undefined) {
2384
+ if (Array.isArray(next)) {
2385
+ const arr = [];
2386
+ for (let i = 0; i < next.length; i++) {
2387
+ const x = await this.actionRef(next[i], nctx, owner, `${path}/Next[${i}]`);
2388
+ if (x !== undefined)
2389
+ arr.push(x);
2390
+ }
2391
+ nextOut = arr.length || next.every(x => x === null) ? arr : undefined;
2392
+ }
2393
+ else
2394
+ nextOut = await this.actionRef(next, nctx, owner, `${path}/Next`);
2395
+ }
2396
+ if (!keep) {
2397
+ // What only the removed action refers to is not unreferenced. Its chain was read above.
2398
+ for (const [k, v] of d.entries())
2399
+ if (k !== 'Next' && (rest || k === 'S' || k === 'JS'))
2400
+ await this.reach(v);
2401
+ return undefined;
2402
+ }
2403
+ const out = new objects_1.PdfDict();
2404
+ if (nextOut !== undefined)
2405
+ out.set('Next', nextOut);
2406
+ const js = d.get('JS');
2407
+ if (script && js !== undefined) {
2408
+ // A rewrite of a referenced text is written into that object, by jsReplace.
2409
+ if (jsText !== undefined && !(js instanceof objects_1.PdfRef))
2410
+ out.set('JS', new objects_1.PdfString((0, objects_1.encodeTextString)(jsText)));
2411
+ else if (js instanceof objects_1.PdfRef) {
2412
+ if (this.phase === 'A')
2413
+ this.enqueue(js, { location: loc });
2414
+ if (this.phase === 'A' || !this.dropped.has(js.num))
2415
+ out.set('JS', js);
2416
+ }
2417
+ else
2418
+ out.set('JS', js);
2419
+ }
2420
+ if (!rest || again) {
2421
+ const sEntry = d.get('S');
2422
+ if (sEntry !== undefined)
2423
+ out.set('S', sEntry);
2424
+ return out;
2425
+ }
2426
+ // Everything else gets the rules every dictionary gets; a script entry on a navigation action is not part of it.
2427
+ return this.handleGeneric(d, { location: loc, shadow: ctx.shadow, fieldName: ctx.fieldName }, owner, path, new Set(script ? ['Next', 'JS'] : ['Next']), out);
2428
+ }
2429
+ // ---------- contained files ----------
2430
+ async handleFilespecList(v, ctx, owner, path) {
2431
+ const list = v instanceof objects_1.PdfRef ? await this.doc.resolve(v) : v;
2432
+ if (!Array.isArray(list))
2433
+ return this.handleFilespecValue(v, ctx, owner, path);
2434
+ const out = [];
2435
+ for (let i = 0; i < list.length; i++) {
2436
+ const x = await this.handleFilespecValue(list[i], ctx, owner, `${path}[${i}]`);
2437
+ if (x !== undefined)
2438
+ out.push(x);
2439
+ }
2440
+ return out.length ? out : undefined;
2441
+ }
2442
+ /**
2443
+ * A value whose key makes it a file specification, under /FS, /AF or the EmbeddedFiles tree. That key decides what
2444
+ * it is, whatever its content also looks like, so its file is always decided; only the catalog, a page and the
2445
+ * names and information dictionaries keep the roles they have.
2446
+ */
2447
+ async handleFilespecValue(v, ctx, owner, path) {
2448
+ if (v instanceof objects_1.PdfDict)
2449
+ return this.handleFilespec(v, ctx, owner, path);
2450
+ if (v instanceof objects_1.PdfString) {
2451
+ // A bare file name: a reference to a file outside the PDF.
2452
+ const f = this.emit(types_1.PdfCategory.EmbeddedFile, types_1.PdfDetail.NoPlugin, ctx.location, { name: (0, objects_1.decodeTextString)(v.bytes), external: 'yes' });
2453
+ return f.action === 'strip' ? undefined : v;
2454
+ }
2455
+ const target = v instanceof objects_1.PdfRef ? await this.doc.getObject(v) : undefined;
2456
+ if (v instanceof objects_1.PdfRef && target instanceof objects_1.PdfDict && v.num !== this.rootNum && v.num !== this.namesNum && v.num !== this.infoNum && !this.pageInfo.has(v.num)) {
2457
+ if (this.phase === 'A' && !this.dropped.has(v.num)) {
2458
+ if (!this.visited.has(v.num)) {
2459
+ if (!ctx.shadow)
2460
+ this.fileRole.add(v.num);
2461
+ await this.processRef(v, ctx);
2462
+ }
2463
+ else if (!ctx.shadow && !this.fileRole.has(v.num) && !(await this.handleFilespec(target, ctx, `obj:${v.num}`, ''))) {
2464
+ // Kept before in another role. Its file goes, and the object with it, wherever else it is used.
2465
+ this.dropped.add(v.num);
2466
+ }
2467
+ }
2468
+ return this.dropped.has(v.num) ? undefined : v;
2469
+ }
2470
+ // The catalog, a page and the names and information dictionaries keep their roles, so a file one of them carries
2471
+ // is never decided. Readers still open it through this reference, which goes whatever the overrides say.
2472
+ const data = { reason: 'not a file specification' };
2473
+ const f = target instanceof objects_1.PdfDict && target.has('EF') ? this.emitRemoval(types_1.PdfCategory.Corrupted, types_1.PdfDetail.MalformedObject, ctx.location, data) : this.emit(types_1.PdfCategory.Corrupted, types_1.PdfDetail.MalformedObject, ctx.location, data);
2474
+ if (f.action === 'strip')
2475
+ return undefined;
2476
+ return v === undefined ? undefined : this.tv(v, ctx, owner, path);
2477
+ }
2478
+ async handleFilespec(d, ctx, owner, path) {
2479
+ // pdf.js names the file from the first of these keys it finds, and another reader may take another one.
2480
+ const names = [];
2481
+ for (const k of ['UF', 'F', 'Unix', 'Mac', 'DOS']) {
2482
+ const n = text(await this.doc.resolve(d.get(k)));
2483
+ if (n !== undefined && !names.includes(n))
2484
+ names.push(n);
2485
+ }
2486
+ const name = names[0] ?? '';
2487
+ const loc = ctx.location;
2488
+ const ef = await this.doc.resolve(d.get('EF'));
2489
+ const sref = ef instanceof objects_1.PdfDict ? (ef.get('UF') ?? ef.get('F')) : undefined;
2490
+ let keep;
2491
+ if (!(sref instanceof objects_1.PdfRef)) {
2492
+ keep = this.emit(types_1.PdfCategory.EmbeddedFile, types_1.PdfDetail.NoPlugin, loc, { name, external: 'yes' }).action !== 'strip';
2493
+ }
2494
+ else {
2495
+ const st = await this.doc.getObject(sref);
2496
+ keep = st instanceof objects_1.PdfStream ? await this.decideFile(sref, st, ctx, name) : this.emit(types_1.PdfCategory.Corrupted, types_1.PdfDetail.MalformedObject, loc, { reason: 'embedded file is not a stream' }).action !== 'strip';
2497
+ }
2498
+ // Readers open different streams of /EF and show different names, so the file stays only if every stream it
2499
+ // holds is kept under every name. Deciding a stream also marks it as a contained file, whatever reaches it first.
2500
+ if (keep && ef instanceof objects_1.PdfDict) {
2501
+ for (const [, ev] of ef.entries()) {
2502
+ const st = ev instanceof objects_1.PdfRef ? await this.doc.getObject(ev) : undefined;
2503
+ if (!(ev instanceof objects_1.PdfRef) || !(st instanceof objects_1.PdfStream))
2504
+ continue;
2505
+ for (const n of names.length ? names : [name])
2506
+ if (!(await this.decideFile(ev, st, ctx, n)))
2507
+ keep = false;
2508
+ }
2509
+ }
2510
+ if (!keep) {
2511
+ await this.reach(d);
2512
+ return undefined;
2513
+ }
2514
+ const out = new objects_1.PdfDict();
2515
+ for (const [k, v] of d.entries()) {
2516
+ // Related files are more embedded streams, which no plugin is shown.
2517
+ if (k === 'RF' && this.emit(types_1.PdfCategory.EmbeddedFile, types_1.PdfDetail.NoPlugin, loc, { name, reason: 'related files' }).action === 'strip') {
2518
+ await this.reach(v);
2519
+ continue;
2520
+ }
2521
+ if (k === 'EF' && ef instanceof objects_1.PdfDict) {
2522
+ // Written inline, so a referenced /EF dictionary is not written itself.
2523
+ if (v instanceof objects_1.PdfRef)
2524
+ await this.reach(v);
2525
+ const nef = new objects_1.PdfDict();
2526
+ for (const [ek, ev] of ef.entries()) {
2527
+ if (!(ev instanceof objects_1.PdfRef)) {
2528
+ if (ev !== null && this.emit(types_1.PdfCategory.Corrupted, types_1.PdfDetail.MalformedObject, loc, { reason: 'embedded file is not a stream' }).action !== 'strip')
2529
+ nef.set(ek, ev);
2530
+ continue;
2531
+ }
2532
+ if (this.phase === 'A')
2533
+ this.enqueue(ev, { location: loc, shadow: ctx.shadow });
2534
+ if (this.phase === 'A' || (!this.dropped.has(ev.num) && this.newNum.has(ev.num) && this.fileDecisions.get(ev.num)?.keep !== false))
2535
+ nef.set(ek, ev);
2536
+ }
2537
+ out.set(k, nef);
2538
+ continue;
2539
+ }
2540
+ const x = await this.tv(v, { location: loc, shadow: ctx.shadow }, owner, `${path}/${k}`);
2541
+ if (x !== undefined)
2542
+ out.set(k, x);
2543
+ }
2544
+ return out;
2545
+ }
2546
+ /**
2547
+ * Decodes a contained file and runs the file plugins. The content is decided once per embedded file stream; every
2548
+ * further name that reaches it is still checked against that content.
2549
+ */
2550
+ async decideFile(ref, st, ctx, name) {
2551
+ const cached = this.fileDecisions.get(ref.num);
2552
+ if (cached) {
2553
+ const t = cached.types;
2554
+ if (!cached.keep || !t || name === undefined || name === t.name || (!t.plugin && !(0, sniff_1.typesDisagree)(t.declared, name, t.sniffed)))
2555
+ return cached.keep;
2556
+ const key = `${ref.num}\0${name}`;
2557
+ const prior = this.nameDecisions.get(key);
2558
+ if (prior !== undefined || this.phase === 'B')
2559
+ return prior ?? false;
2560
+ // The type check runs for each name, as for the first.
2561
+ const mismatch = (0, sniff_1.typesDisagree)(t.declared, name, t.sniffed);
2562
+ const typeData = { name, declared: t.declared ?? '', sniffed: t.sniffed ?? '', nameType: (0, sniff_1.typeFromName)(name) ?? '' };
2563
+ let keep = !mismatch || this.emit(types_1.PdfCategory.EmbeddedFile, types_1.PdfDetail.TypeMismatch, ctx.location, typeData).action !== 'strip';
2564
+ let accepted = false;
2565
+ if (keep && t.plugin) {
2566
+ // The plugin that decided the content must also take it under this name. The content is decoded again only
2567
+ // if the plugin reads it, and then once for all the names. What it holds in memory counts with the scrubbed
2568
+ // copies, so together they stay under one threshold.
2569
+ const source = {
2570
+ size: async () => t.size ?? 0,
2571
+ read: async (offset, length) => {
2572
+ t.decoded ??= (async () => {
2573
+ const decoded = new io_1.SpillSink(this.cfg.temp, Math.max(0, (this.options.memoryThreshold ?? 8 * 1024 * 1024) - this.retained));
2574
+ try {
2575
+ for await (const c of (0, filters_1.decodeChunks)(this.doc.plainChunks(st, ref.num), st.dict, this.options.limits?.decompressedBytes, () => this.doc.checkTime()))
2576
+ await decoded.write(c);
2577
+ }
2578
+ finally {
2579
+ await decoded.close();
2580
+ }
2581
+ if (!decoded.spilled)
2582
+ this.retained += decoded.length;
2583
+ return decoded.source();
2584
+ })();
2585
+ return (await t.decoded).read(offset, length);
2586
+ },
2587
+ };
2588
+ try {
2589
+ accepted = await t.plugin.accepts({ name, declaredType: t.declared, sniffedType: t.sniffed, size: t.size ?? 0, location: ctx.location, depth: this.cfg.depth + 1, source });
2590
+ }
2591
+ catch (e) {
2592
+ if (e instanceof filters_1.DecompressionLimitError || e instanceof document_1.TimeLimitError || e instanceof document_1.ObjectLimitError)
2593
+ throw e;
2594
+ this.passOn(e);
2595
+ }
2596
+ }
2597
+ if (keep && t.plugin && !accepted) {
2598
+ const f = this.emit(types_1.PdfCategory.EmbeddedFile, types_1.PdfDetail.NoPlugin, ctx.location, { name, type: t.declared ?? t.sniffed ?? '', sniffed: t.sniffed ?? '' });
2599
+ keep = f.action !== 'strip';
2600
+ }
2601
+ if (!ctx.shadow)
2602
+ this.nameDecisions.set(key, keep);
2603
+ return keep;
2604
+ }
2605
+ if (this.phase === 'B')
2606
+ return false;
2607
+ const loc = ctx.location;
2608
+ const declaredType = st.dict.name('Subtype');
2609
+ const plugins = this.options.filePlugins ?? [];
2610
+ const verifying = (this.cfg.verify?.files.size ?? 0) > 0;
2611
+ // With no plugin to show the file to and nothing to verify, only the type check reads it, and only its first bytes.
2612
+ const whole = plugins.length > 0 || verifying;
2613
+ let types;
2614
+ const record = (keep, replacement) => {
2615
+ this.fileDecisions.set(ref.num, { keep, replacement, types });
2616
+ // A stream something else reached first and kept goes too.
2617
+ if (!keep && this.visited.has(ref.num))
2618
+ this.dropped.add(ref.num);
2619
+ return keep;
2620
+ };
2621
+ if (ctx.shadow)
2622
+ return false;
2623
+ const threshold = this.options.memoryThreshold ?? 8 * 1024 * 1024;
2624
+ // The first bytes alone stay in memory, however low the threshold.
2625
+ const decoded = new io_1.SpillSink(this.cfg.temp, whole ? threshold : Number.POSITIVE_INFINITY);
2626
+ try {
2627
+ for await (const c of (0, filters_1.decodeChunks)(this.doc.plainChunks(st, ref.num), st.dict, this.options.limits?.decompressedBytes, () => this.doc.checkTime())) {
2628
+ await decoded.write(c);
2629
+ if (!whole && decoded.length >= 1024)
2630
+ break;
2631
+ }
2632
+ }
2633
+ catch (e) {
2634
+ await decoded.dispose();
2635
+ // A limit stops the run; anything else means the file cannot be read.
2636
+ if (e instanceof filters_1.DecompressionLimitError || e instanceof document_1.TimeLimitError || e instanceof document_1.ObjectLimitError)
2637
+ throw e;
2638
+ const f = this.emit(types_1.PdfCategory.EmbeddedFile, types_1.PdfDetail.NoPlugin, loc, { name: name ?? '', reason: 'cannot decode' });
2639
+ return record(f.action !== 'strip');
2640
+ }
2641
+ await decoded.close();
2642
+ if (decoded.spilled)
2643
+ this.emit(types_1.PdfCategory.Processing, types_1.PdfDetail.MemoryFallback, loc, { part: `Attached file "${short(name ?? '')}"` });
2644
+ // The decoded copy goes as soon as the file is decided, so temporary space does not grow with the number of files.
2645
+ let source;
2646
+ try {
2647
+ const hash = decoded.digest();
2648
+ const expected = this.cfg.verify?.files.get(hash);
2649
+ if (expected) {
2650
+ this.emit(types_1.PdfCategory.EmbeddedFile, expected.result === 'passed' ? types_1.PdfDetail.PluginPassed : types_1.PdfDetail.PluginScrubbed, loc, { plugin: expected.plugin, name: name ?? '' });
2651
+ this.pushContained(expected.findings, loc, name);
2652
+ return record(true);
2653
+ }
2654
+ source = decoded.source();
2655
+ const head = await source.read(0, 1024);
2656
+ const sniffed = (0, sniff_1.sniffType)(head);
2657
+ types = { declared: declaredType, sniffed, name, size: whole ? decoded.length : undefined };
2658
+ const mismatch = (0, sniff_1.typesDisagree)(declaredType, name, sniffed);
2659
+ const typeData = { name: name ?? '', declared: declaredType ?? '', sniffed: sniffed ?? '', nameType: (0, sniff_1.typeFromName)(name) ?? '' };
2660
+ if (mismatch && this.emit(types_1.PdfCategory.EmbeddedFile, types_1.PdfDetail.TypeMismatch, loc, typeData).action === 'strip')
2661
+ return record(false);
2662
+ const file = { name, declaredType, sniffedType: sniffed, size: decoded.length, location: loc, depth: this.cfg.depth + 1, source };
2663
+ const context = { depth: file.depth, deadline: this.cfg.deadline, [exports.RUN_OPTIONS]: this.options };
2664
+ for (const plugin of plugins) {
2665
+ try {
2666
+ if (!(await plugin.accepts(file)))
2667
+ continue;
2668
+ }
2669
+ catch (e) {
2670
+ this.passOn(e);
2671
+ continue;
2672
+ }
2673
+ // Scrubbed copies wait in memory until the write, so all of them together stay under one threshold.
2674
+ const outSink = new io_1.SpillSink(this.cfg.temp, Math.max(0, threshold - this.retained));
2675
+ try {
2676
+ const out = await plugin.process(file, outSink, context);
2677
+ const r = typeof out === 'string' ? { result: out } : (out ?? {});
2678
+ await outSink.close();
2679
+ const data = { plugin: plugin.name, name: name ?? '' };
2680
+ let keep = false;
2681
+ if (r.result === 'passed') {
2682
+ this.emit(types_1.PdfCategory.EmbeddedFile, types_1.PdfDetail.PluginPassed, loc, data);
2683
+ // Bytes the plugin wrote are the ones kept, such as an attached PDF rewritten with nothing removed.
2684
+ this.kept.files.set(outSink.length > 0 ? outSink.digest() : hash, { result: 'passed', plugin: plugin.name, findings: r.outputFindings });
2685
+ keep = true;
2686
+ }
2687
+ else if (r.result === 'scrubbed' && outSink.length > 0) {
2688
+ this.emit(types_1.PdfCategory.EmbeddedFile, types_1.PdfDetail.PluginScrubbed, loc, data);
2689
+ this.kept.files.set(outSink.digest(), { result: 'scrubbed', plugin: plugin.name, findings: r.outputFindings });
2690
+ keep = true;
2691
+ }
2692
+ else
2693
+ this.emitRemoval(types_1.PdfCategory.EmbeddedFile, r.result === 'removed' ? types_1.PdfDetail.PluginRemoved : types_1.PdfDetail.PluginFailed, loc, data);
2694
+ this.pushContained(r.findings, loc, name);
2695
+ types.plugin = plugin;
2696
+ const replacement = keep && outSink.length > 0 ? outSink : undefined;
2697
+ if (replacement && !replacement.spilled)
2698
+ this.retained += replacement.length;
2699
+ if (!replacement)
2700
+ await outSink.dispose();
2701
+ return record(keep, replacement);
2702
+ }
2703
+ catch (e) {
2704
+ await outSink.dispose();
2705
+ this.passOn(e);
2706
+ this.emitRemoval(types_1.PdfCategory.EmbeddedFile, types_1.PdfDetail.PluginFailed, loc, { plugin: plugin.name, name: name ?? '' });
2707
+ return record(false);
2708
+ }
2709
+ }
2710
+ const f = this.emit(types_1.PdfCategory.EmbeddedFile, types_1.PdfDetail.NoPlugin, loc, { name: name ?? '', type: declaredType ?? sniffed ?? '', sniffed: sniffed ?? '' });
2711
+ return record(f.action !== 'strip');
2712
+ }
2713
+ finally {
2714
+ await source?.close?.();
2715
+ await decoded.dispose();
2716
+ }
2717
+ }
2718
+ // ---------- phase B ----------
2719
+ /** Writes the kept objects as a new single-revision PDF. */
2720
+ async write(sink) {
2721
+ this.phase = 'B';
2722
+ const doc = this.doc;
2723
+ // Some objects are dropped after they were first kept: stripped metadata, and files and additional actions
2724
+ // decided after something else reached them.
2725
+ this.order = this.order.filter(num => !this.dropped.has(num));
2726
+ if (this.jsTree)
2727
+ this.jsTree.entries = this.jsTree.entries.filter(([, v]) => !(v instanceof objects_1.PdfRef && this.dropped.has(v.num)));
2728
+ if (this.efTree)
2729
+ this.efTree.entries = this.efTree.entries.filter(([, v]) => !(v instanceof objects_1.PdfRef && this.dropped.has(v.num)));
2730
+ let n = 1;
2731
+ for (const num of this.order)
2732
+ this.newNum.set(num, n++);
2733
+ if (this.jsTree?.entries.length)
2734
+ this.synthJs = n++;
2735
+ if (this.efTree?.entries.length)
2736
+ this.synthEf = n++;
2737
+ const total = n;
2738
+ const offsets = new Array(total).fill(0);
2739
+ const mapRef = (r) => {
2740
+ if (r.num === -1)
2741
+ return this.synthJs ? new objects_1.PdfRef(this.synthJs, 0) : null;
2742
+ if (r.num === -2)
2743
+ return this.synthEf ? new objects_1.PdfRef(this.synthEf, 0) : null;
2744
+ const m = this.newNum.get(r.num);
2745
+ return m ? new objects_1.PdfRef(m, 0) : null;
2746
+ };
2747
+ const out = new Out(sink);
2748
+ await out.write(`%PDF-${doc.headerVersion}\n%\xE2\xE3\xCF\xD3\n`);
2749
+ for (const num of this.order) {
2750
+ doc.checkTime();
2751
+ const obj = await doc.getObject(new objects_1.PdfRef(num, doc.genOf(num)));
2752
+ let v = await this.transformTop(obj, { location: '' }, num);
2753
+ if (v === undefined)
2754
+ v = null;
2755
+ const nn = this.newNum.get(num);
2756
+ if (nn === undefined)
2757
+ throw new Error(`Object ${num} has no number in the output`);
2758
+ offsets[nn] = out.offset;
2759
+ if (v instanceof objects_1.PdfStream)
2760
+ await this.writeStream(out, nn, v, num, mapRef);
2761
+ else {
2762
+ // A script a plugin rewrote, held in a string object of its own.
2763
+ const js = v instanceof objects_1.PdfString ? this.jsReplace.get(num) : undefined;
2764
+ await out.write(`${nn} 0 obj\n${(0, writer_1.serialize)(js ? new objects_1.PdfString(js) : v, mapRef)}\nendobj\n`);
2765
+ }
2766
+ }
2767
+ const writeTree = async (num, entries) => {
2768
+ offsets[num] = out.offset;
2769
+ const names = [];
2770
+ for (const [k, v] of [...entries].sort((a, b) => Buffer.compare(Buffer.from(a[0].bytes), Buffer.from(b[0].bytes))))
2771
+ names.push(k, v);
2772
+ const d = new objects_1.PdfDict();
2773
+ d.set('Names', names);
2774
+ await out.write(`${num} 0 obj\n${(0, writer_1.serialize)(d, mapRef)}\nendobj\n`);
2775
+ };
2776
+ if (this.synthJs) {
2777
+ if (!this.jsTree)
2778
+ throw new Error('No JavaScript name tree to write');
2779
+ await writeTree(this.synthJs, this.jsTree.entries);
2780
+ }
2781
+ if (this.synthEf) {
2782
+ if (!this.efTree)
2783
+ throw new Error('No EmbeddedFiles name tree to write');
2784
+ await writeTree(this.synthEf, this.efTree.entries);
2785
+ }
2786
+ const xrefAt = out.offset;
2787
+ let xref = `xref\n0 ${total}\n0000000000 65535 f\r\n`;
2788
+ for (let i = 1; i < total; i++)
2789
+ xref += `${String(offsets[i]).padStart(10, '0')} 00000 n\r\n`;
2790
+ await out.write(xref);
2791
+ const trailer = new objects_1.PdfDict();
2792
+ trailer.set('Size', total);
2793
+ const root = doc.trailer.get('Root');
2794
+ if (root === undefined)
2795
+ throw new Error('The document has no /Root to write');
2796
+ trailer.set('Root', root);
2797
+ const info = doc.trailer.get('Info');
2798
+ if (info instanceof objects_1.PdfRef && this.newNum.has(info.num) && !this.options.stripMetadata)
2799
+ trailer.set('Info', info);
2800
+ const id = doc.trailer.get('ID');
2801
+ if (Array.isArray(id))
2802
+ trailer.set('ID', id);
2803
+ await out.write(`trailer\n${(0, writer_1.serialize)(trailer, mapRef)}\nstartxref\n${xrefAt}\n%%EOF\n`);
2804
+ await out.flush();
2805
+ }
2806
+ async writeStream(out, nn, s, num, mapRef) {
2807
+ const dict = s.dict.clone();
2808
+ const replaceJs = this.jsReplace.get(num);
2809
+ const fileDec = this.fileDecisions.get(num);
2810
+ let body;
2811
+ let compressed;
2812
+ if (replaceJs) {
2813
+ dict.delete('Filter');
2814
+ dict.delete('DecodeParms');
2815
+ dict.delete('DL');
2816
+ dict.set('Length', replaceJs.length);
2817
+ body = [replaceJs];
2818
+ }
2819
+ else if (fileDec?.replacement) {
2820
+ const sink = new io_1.SpillSink(this.cfg.temp, this.options.memoryThreshold ?? 8 * 1024 * 1024);
2821
+ compressed = sink;
2822
+ // pipeline() stops the reader and settles on any stage's error; a bare pipe() leaves both open.
2823
+ try {
2824
+ await (0, promises_1.pipeline)(fileDec.replacement.read(), zlib.createDeflate(), async (deflated) => {
2825
+ for await (const c of deflated)
2826
+ await sink.write(c);
2827
+ });
2828
+ }
2829
+ finally {
2830
+ await sink.close();
2831
+ }
2832
+ dict.set('Filter', new objects_1.PdfName('FlateDecode'));
2833
+ dict.delete('DecodeParms');
2834
+ dict.delete('DL');
2835
+ dict.set('Length', sink.length);
2836
+ const params = dict.get('Params') instanceof objects_1.PdfDict ? dict.get('Params').clone() : new objects_1.PdfDict();
2837
+ params.set('Size', fileDec.replacement.length);
2838
+ params.set('CheckSum', new objects_1.PdfString(Buffer.from(fileDec.replacement.digest(), 'hex')));
2839
+ dict.set('Params', params);
2840
+ body = sink.read();
2841
+ }
2842
+ else {
2843
+ const f = dict.get('Filter');
2844
+ if (Array.isArray(f) || f instanceof objects_1.PdfName) {
2845
+ const names = Array.isArray(f) ? f : [f];
2846
+ const ci = names.findIndex(x => x instanceof objects_1.PdfName && x.name === 'Crypt');
2847
+ if (ci >= 0) {
2848
+ const nf = names.filter((_, i) => i !== ci);
2849
+ const dp = dict.get('DecodeParms');
2850
+ if (Array.isArray(dp))
2851
+ dict.set('DecodeParms', dp.filter((_, i) => i !== ci));
2852
+ else if (dp !== undefined)
2853
+ dict.delete('DecodeParms');
2854
+ if (nf.length)
2855
+ dict.set('Filter', nf);
2856
+ else
2857
+ dict.delete('Filter');
2858
+ }
2859
+ }
2860
+ dict.set('Length', await this.doc.plainLength(s, num));
2861
+ body = this.doc.plainChunks(s, num);
2862
+ }
2863
+ await out.write(`${nn} 0 obj\n${(0, writer_1.serialize)(dict, mapRef)}\nstream\n`);
2864
+ for await (const c of body)
2865
+ await out.write(c);
2866
+ await out.write('\nendstream\nendobj\n');
2867
+ // Written once, so the copies go now instead of piling up until the run ends.
2868
+ await compressed?.dispose();
2869
+ await fileDec?.replacement?.dispose();
2870
+ }
2871
+ /** Frees per-object state once the output is written; only the plugin expectations stay. */
2872
+ releaseState() {
2873
+ this.jsTree = undefined;
2874
+ this.efTree = undefined;
2875
+ this.pageInfo.clear();
2876
+ this.pageOrder.length = 0;
2877
+ this.treeNodes.clear();
2878
+ this.keptCalcFields.clear();
2879
+ this.annotPage.clear();
2880
+ this.annotLists.clear();
2881
+ this.annotRole.clear();
2882
+ this.reached.clear();
2883
+ this.scriptActions.clear();
2884
+ this.keptDocScripts.length = 0;
2885
+ this.special.clear();
2886
+ this.order = [];
2887
+ this.newNum.clear();
2888
+ this.visited.clear();
2889
+ this.shadowVisited.clear();
2890
+ this.dropped.clear();
2891
+ this.queue = [];
2892
+ this.scriptDecisions.clear();
2893
+ this.jsReplace.clear();
2894
+ this.annotPage.clear();
2895
+ this.altText.clear();
2896
+ this.refusedSlots.clear();
2897
+ this.shadowScripts.clear();
2898
+ this.strippedMeta.clear();
2899
+ this.nameDecisions.clear();
2900
+ this.labelChecks.clear();
2901
+ this.chainSites.clear();
2902
+ this.scriptFree.clear();
2903
+ this.fileRole.clear();
2904
+ this.inlinePages.clear();
2905
+ return this.doc.release();
2906
+ }
2907
+ async cleanup() {
2908
+ for (const d of this.fileDecisions.values()) {
2909
+ await d.replacement?.close();
2910
+ await (await d.types?.decoded?.catch(() => undefined))?.close?.();
2911
+ }
2912
+ }
2913
+ }
2914
+ exports.Walker = Walker;