omitly-mcp 5.4.0 → 5.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1245 @@
1
+ // Page operations via pdf-lib. Each takes the current document bytes and
2
+ // returns new bytes — the caller re-renders and pushes onto the undo stack.
3
+ import { PDFArray, PDFBool, PDFDict, PDFDocument, PDFHexString, PDFName, PDFNumber, PDFObjectCopier, PDFPage, PDFPageLeaf, PDFRef, PDFStream, PDFString, degrees, } from "pdf-lib";
4
+ import { scrubFieldsOutsideKept } from "./docFacts.js";
5
+ export function remapAfterDelete(removed) {
6
+ return (p) => (p === removed ? null : p > removed ? p - 1 : p);
7
+ }
8
+ export function remapAfterInsert(after) {
9
+ return (p) => (p > after ? p + 1 : p);
10
+ }
11
+ export function remapAfterMove(from, to) {
12
+ return (p) => {
13
+ if (p === from)
14
+ return to;
15
+ // Removing `from` shifts later pages left; re-inserting at `to` shifts
16
+ // pages at/after `to` right. Net effect on a bystander page:
17
+ if (from < p && p <= to)
18
+ return p - 1;
19
+ if (to <= p && p < from)
20
+ return p + 1;
21
+ return p;
22
+ };
23
+ }
24
+ /** Apply a `PageRemap` to any list of page-indexed items (pending redaction
25
+ * regions, placed signatures, measurements — anything carrying a zero-based
26
+ * `page`). Items whose page was removed are dropped; the rest keep every other
27
+ * field untouched and follow their page to its new index. Pure, and shared so
28
+ * the three call sites in `mutate()` cannot drift apart — measurements were
29
+ * missing from that set until omitly#854. */
30
+ export function remapPageIndexed(items, remap) {
31
+ return items.flatMap((item) => {
32
+ const page = remap(item.page);
33
+ return page === null ? [] : [{ ...item, page }];
34
+ });
35
+ }
36
+ async function load(bytes) {
37
+ // slice() so pdf-lib can't detach the caller's buffer.
38
+ return PDFDocument.load(bytes.slice(), { updateMetadata: false });
39
+ }
40
+ /** Rotate one page by a delta (clamped to 90° steps), preserving prior angle. */
41
+ export async function rotatePage(bytes, index, deltaDeg) {
42
+ const doc = await load(bytes);
43
+ const page = doc.getPages()[index];
44
+ if (!page)
45
+ throw new Error(`no page ${index}`);
46
+ const current = page.getRotation().angle;
47
+ const next = (((current + deltaDeg) % 360) + 360) % 360;
48
+ page.setRotation(degrees(next));
49
+ return doc.save();
50
+ }
51
+ /**
52
+ * A page's current rotation, normalised to [0, 360). `extractPageText`
53
+ * (`src/redact/pageText.ts`) excludes any page whose rotation is non-zero
54
+ * from automatic PII detection, Find, and Replace All — callers use this to
55
+ * warn when a rotate action is about to cross that 0 → non-zero boundary
56
+ * (omitly#825), without having to duplicate rotatePage's own angle math.
57
+ */
58
+ export async function getPageRotation(bytes, index) {
59
+ const doc = await load(bytes);
60
+ const page = doc.getPages()[index];
61
+ if (!page)
62
+ throw new Error(`no page ${index}`);
63
+ return (((page.getRotation().angle % 360) + 360) % 360);
64
+ }
65
+ /** Delete a page (refuses to remove the last remaining page).
66
+ *
67
+ * Rebuilds via `extractPages` (keeping every OTHER page) rather than
68
+ * `doc.removePage(index)` + `doc.save()`. pdf-lib never garbage-collects: a
69
+ * `removePage`'d leaf is unlinked from `/Kids`, but its content stream,
70
+ * `/Annots`, and — critically — any `/Resources` XObject it names (the scanned
71
+ * image on a scanned page) stay registered as indirect objects, and `save()`
72
+ * re-emits every registered object regardless of reachability. For a
73
+ * redaction tool that means a "deleted" sensitive page is still fully
74
+ * byte-recoverable from the saved output. `extractPages` copies only objects
75
+ * reachable from the KEPT pages into a fresh document, so the deleted page's
76
+ * content, annotations and images are never carried over — the same
77
+ * default-deny extraction path already proven not to leak an excluded page
78
+ * (see `neuterPage` above).
79
+ *
80
+ * Follow-up (omitly#1007): because `extractPages` builds a fresh catalog, a
81
+ * plain extraction drops document-level structures the old `removePage` path
82
+ * preserved — the outline/bookmark tree, `/Info` metadata, page labels, named
83
+ * destinations and viewer preferences. `deletePage` passes
84
+ * `{ preserveCatalog: true }` so `extractPages` copies+prunes those structures
85
+ * back onto the fresh catalog (see its doc comment for exactly what that
86
+ * covers and the two documented gaps: a `/Kids`-split PageLabels/name-tree,
87
+ * and a destination named indirectly by name/string rather than a direct
88
+ * destination array — both left un-restored rather than guessed at, never
89
+ * silently wrong). The security property (a deleted page must not be
90
+ * byte-recoverable) still takes priority: nothing here ever copies a
91
+ * reference to the deleted page's leaf across, so it stays exactly as
92
+ * unreachable as a plain extraction leaves it. */
93
+ export async function deletePage(bytes, index) {
94
+ const doc = await load(bytes);
95
+ const count = doc.getPageCount();
96
+ if (count <= 1)
97
+ throw new Error("cannot delete the only page");
98
+ // Require an integer: a fractional/NaN index matches no loop counter below,
99
+ // so `keep` would silently contain every page and the delete would no-op.
100
+ if (!Number.isInteger(index) || index < 0 || index >= count)
101
+ throw new Error(`no page ${index}`);
102
+ const keep = [];
103
+ for (let i = 0; i < count; i++)
104
+ if (i !== index)
105
+ keep.push(i);
106
+ return extractPages(bytes, keep, { preserveCatalog: true });
107
+ }
108
+ /** Insert a blank page after `index`, matching that page's size (and its
109
+ * rotation, so the new page shows the same way up in a rotated document). */
110
+ export async function insertBlankPage(bytes, index) {
111
+ const doc = await load(bytes);
112
+ const ref = doc.getPages()[index];
113
+ if (!ref)
114
+ throw new Error(`no page ${index}`);
115
+ const page = doc.insertPage(index + 1, [ref.getWidth(), ref.getHeight()]);
116
+ page.setRotation(ref.getRotation());
117
+ return doc.save();
118
+ }
119
+ /** Copy every PDF-spec *inheritable* attribute (`/Resources`, `/MediaBox`,
120
+ * `/CropBox`, `/Rotate`) that a leaf currently inherits from an intermediate
121
+ * `/Pages` node directly ONTO the leaf. Must run before the leaf is reparented:
122
+ * inheritance is resolved by walking `/Parent` upwards, so a leaf moved onto a
123
+ * different parent silently loses everything it used to inherit — its page
124
+ * size, its rotation, and the resource dictionary its fonts and XObjects are
125
+ * named in. pdf-lib does exactly this in `PDFObjectCopier.copyPDFPage` before
126
+ * detaching a page; the in-place reorder below has to do it too. Producers that
127
+ * hang shared attributes off an intermediate node (pdftk, mail-merge and scan
128
+ * pipelines commonly do, to avoid repeating them per page) are the ones this
129
+ * affects — a flat tree inherits nothing and is unchanged. */
130
+ function bakeInheritedAttributes(leaf) {
131
+ for (const entry of PDFPageLeaf.InheritableEntries) {
132
+ const key = PDFName.of(entry);
133
+ if (leaf.get(key))
134
+ continue; // already set directly on the leaf
135
+ const inherited = leaf.getInheritableAttribute(key);
136
+ if (inherited)
137
+ leaf.set(key, inherited);
138
+ }
139
+ }
140
+ /** Reorder the document's pages in place (array of source indices, one per
141
+ * final position). Unlike a copyPages()-based rebuild, this only rewrites the
142
+ * page tree's `/Kids` — the catalog (and everything hanging off it, notably
143
+ * `/Outlines` and `/AcroForm`) is untouched, so bookmarks and form fields
144
+ * survive. Outline destinations hold a page *reference*, not an index, so
145
+ * they automatically continue to point at the right (relocated) page with no
146
+ * remapping needed. */
147
+ export async function reorderPages(bytes, order) {
148
+ const doc = await load(bytes);
149
+ const pages = doc.getPages();
150
+ const refs = pages.map((p) => p.ref);
151
+ if (order.length !== refs.length) {
152
+ throw new Error(`order has ${order.length} entries, expected ${refs.length}`);
153
+ }
154
+ // `order` must be a genuine permutation of 0..n-1. Length alone is not
155
+ // enough: a repeated index would put the same page ref in `/Kids` twice and
156
+ // drop another page entirely, leaving a structurally inconsistent tree whose
157
+ // `/Count` no longer matches its distinct pages.
158
+ const seen = new Set();
159
+ for (const sourceIndex of order) {
160
+ if (!Number.isInteger(sourceIndex) || sourceIndex < 0 || sourceIndex >= refs.length) {
161
+ throw new Error(`no page ${sourceIndex}`);
162
+ }
163
+ if (seen.has(sourceIndex))
164
+ throw new Error(`order repeats page ${sourceIndex}`);
165
+ seen.add(sourceIndex);
166
+ }
167
+ const root = doc.catalog.Pages();
168
+ const rootRef = doc.catalog.get(PDFName.of("Pages"));
169
+ if (!(rootRef instanceof PDFRef))
170
+ throw new Error("catalog has no Pages ref");
171
+ // Two passes: bake inherited attributes onto EVERY leaf while the original
172
+ // tree is still intact, and only then rewrite `/Parent`. Resolving
173
+ // inheritance against a half-flattened tree would be order-dependent.
174
+ const leaves = pages.map((p) => p.node);
175
+ leaves.forEach(bakeInheritedAttributes);
176
+ const kids = PDFArray.withContext(doc.context);
177
+ for (const sourceIndex of order) {
178
+ kids.push(refs[sourceIndex]);
179
+ // Flatten: every relocated leaf now hangs directly off the (single) root
180
+ // node, so a source doc with a nested/unbalanced page tree can't leave a
181
+ // leaf pointing at a `/Parent` that's no longer its actual parent.
182
+ leaves[sourceIndex].set(PDFName.of("Parent"), rootRef);
183
+ }
184
+ root.set(PDFName.of("Kids"), kids);
185
+ root.set(PDFName.of("Count"), PDFNumber.of(order.length));
186
+ return doc.save();
187
+ }
188
+ /** Move a page from one index to another. */
189
+ export async function movePage(bytes, from, to) {
190
+ const src = await load(bytes);
191
+ const order = Array.from({ length: src.getPageCount() }, (_, i) => i);
192
+ const [moved] = order.splice(from, 1);
193
+ order.splice(to, 0, moved);
194
+ return reorderPages(bytes, order);
195
+ }
196
+ /** Append every page of another PDF (given its bytes) to this one. */
197
+ export async function appendPdf(bytes, otherBytes) {
198
+ const doc = await load(bytes);
199
+ const other = await load(otherBytes);
200
+ await other.flush();
201
+ const copier = PDFObjectCopier.for(other.context, doc.context);
202
+ const copied = [];
203
+ for (const srcPage of other.getPages()) {
204
+ // Copy by ref so the copier caches src-page-ref -> dst-page-ref. A form
205
+ // widget's /P back-reference then resolves to this same page instead of
206
+ // copying a second, orphaned page object (#884).
207
+ const ref = copier.copy(srcPage.ref);
208
+ const node = doc.context.lookup(ref);
209
+ if (!(node instanceof PDFPageLeaf))
210
+ throw new Error("appended page did not copy");
211
+ copied.push(PDFPage.of(node, ref, doc));
212
+ }
213
+ copied.forEach((p) => doc.addPage(p));
214
+ // omitly#1238: the pages above carry their widget annotations and /AP
215
+ // streams across (#884) regardless of this call, so a skipped merge here
216
+ // is invisible at the pixel level — only readFields()/applyFieldValues,
217
+ // which walk /AcroForm /Fields rather than the page tree, would ever have
218
+ // noticed the appended document's fields were gone.
219
+ mergeAcroFormOnAppend(other, doc, copier);
220
+ return doc.save();
221
+ }
222
+ /** A field dict's partial name (`/T`), or `""` if absent. Mirrors pdf-lib's
223
+ * own `PDFAcroField.getPartialName()` decoding, without pulling in the
224
+ * `doc.getForm()` high-level API this file deliberately avoids (its lazy
225
+ * AcroForm creation is the exact trap `pageOps.test.ts:158` documents). */
226
+ function fieldPartialName(dict) {
227
+ const t = dict.lookupMaybe(PDFName.of("T"), PDFString, PDFHexString);
228
+ return t?.decodeText() ?? "";
229
+ }
230
+ /** Partial names already used by `acroForm`'s TOP-LEVEL fields. A fully
231
+ * qualified field name is only disambiguated by an ancestor's OWN `/T`, so
232
+ * two top-level fields sharing a `/T` are indistinguishable to
233
+ * `readFields()`/`applyFieldValues`, which key by name — this is the set an
234
+ * appended field's name must not collide with (omitly#1238). */
235
+ function topLevelFieldNames(acroForm) {
236
+ const names = new Set();
237
+ const fields = acroForm?.lookupMaybe(PDFName.of("Fields"), PDFArray);
238
+ if (!fields || !acroForm)
239
+ return names;
240
+ for (let i = 0; i < fields.size(); i++) {
241
+ const ref = fields.get(i);
242
+ if (!(ref instanceof PDFRef))
243
+ continue;
244
+ const name = fieldPartialName(acroForm.context.lookup(ref, PDFDict));
245
+ if (name)
246
+ names.add(name);
247
+ }
248
+ return names;
249
+ }
250
+ /** `base`, or the first `base (2)`, `base (3)`, ... not already in `taken`. */
251
+ function disambiguateFieldName(base, taken) {
252
+ if (!taken.has(base))
253
+ return base;
254
+ let i = 2;
255
+ while (taken.has(`${base} (${i})`))
256
+ i++;
257
+ return `${base} (${i})`;
258
+ }
259
+ /** Union `src`'s `/DR` (default resources) into `dst`'s, category by category
260
+ * (`/Font`, `/XObject`, ...), preferring `dst`'s own entry on a same-name
261
+ * collision so the base document's existing field appearances can't change —
262
+ * only genuinely new resource names are added. A viewer that regenerates an
263
+ * appearance (`/NeedAppearances`, or an in-place edit) looks a field's font up
264
+ * by name in `/DR`; an appended field whose font isn't there falls back to
265
+ * whatever the viewer substitutes, which is a wrong-but-plausible rendering
266
+ * rather than an honest failure — the class this codebase treats as worse
267
+ * than an outright miss. */
268
+ /** A structural fingerprint of an object, refs resolved (bounded, cycle-safe), stream bytes included
269
+ * by length and an FNV-1a hash. Two objects in DIFFERENT documents are "the same" when this matches,
270
+ * which is how a resource both forms share (the same Helvetica) is told from a name that merely collides. */
271
+ let deepFingerprints = 0;
272
+ function fingerprint(ctx, v, depth = 0, seen = new Set()) {
273
+ if (v === undefined)
274
+ return "u";
275
+ // Past the bound the answer must be CONSERVATIVE: a token no other fingerprint can equal, so two
276
+ // objects that differ only below the bound are treated as different (renamed), never as identical.
277
+ if (depth > 12)
278
+ return `deep#${++deepFingerprints}`;
279
+ if (v instanceof PDFRef) {
280
+ if (seen.has(v))
281
+ return "cycle";
282
+ seen.add(v);
283
+ const out = fingerprint(ctx, ctx.lookup(v), depth + 1, seen);
284
+ seen.delete(v);
285
+ return out;
286
+ }
287
+ if (v instanceof PDFStream) {
288
+ const bytes = v.getContents();
289
+ let h = 0x811c9dc5;
290
+ for (let i = 0; i < bytes.length; i++)
291
+ h = Math.imul(h ^ bytes[i], 0x01000193) >>> 0;
292
+ return `S{${fingerprint(ctx, v.dict, depth + 1, seen)}|${bytes.length}|${h}}`;
293
+ }
294
+ if (v instanceof PDFDict) {
295
+ const parts = v
296
+ .entries()
297
+ .map(([k, x]) => `${k.toString()}=${fingerprint(ctx, x, depth + 1, seen)}`)
298
+ .sort();
299
+ return `D{${parts.join(",")}}`;
300
+ }
301
+ if (v instanceof PDFArray)
302
+ return `A[${v.asArray().map((x) => fingerprint(ctx, x, depth + 1, seen)).join(",")}]`;
303
+ return v.toString();
304
+ }
305
+ /** `base`, or `base_2`, `base_3`, ... not used in any of `categories`. */
306
+ function uniqueResourceName(base, categories, taken) {
307
+ let i = 2;
308
+ const used = (n) => taken.has(n) || categories.some((c) => c.get(PDFName.of(n)) !== undefined);
309
+ while (used(`${base}_${i}`))
310
+ i++;
311
+ taken.add(`${base}_${i}`);
312
+ return `${base}_${i}`;
313
+ }
314
+ /** Union `src`'s `/DR` (default resources) into `dst`'s, category by category
315
+ * (`/Font`, `/XObject`, ...), preferring `dst`'s own entry on a same-name
316
+ * collision so the base document's existing field appearances can't change —
317
+ * only genuinely new resource names are added. A viewer that regenerates an
318
+ * appearance (`/NeedAppearances`, or an in-place edit) looks a field's font up
319
+ * by name in `/DR`; an appended field whose font isn't there falls back to
320
+ * whatever the viewer substitutes, which is a wrong-but-plausible rendering
321
+ * rather than an honest failure — the class this codebase treats as worse
322
+ * than an outright miss.
323
+ *
324
+ * omitly#1841 (Codex): the same applies when the name IS there but means a DIFFERENT object
325
+ * (Helvetica in one form, Courier in the other, both called /F1): dropping the incoming one
326
+ * silently re-fonts the appended form. So an incoming resource whose name clashes with a
327
+ * different object is added under a UNIQUE name (`/F1` -> `/F1_2`), and the returned map
328
+ * (Font category only) lets the caller rewrite the incoming fields' `/DA` strings. An
329
+ * identical object (the same font in both) is not renamed. */
330
+ function mergeDefaultResources(dstAcroForm, srcAcroForm, copier, dstContext) {
331
+ const fontRenames = new Map();
332
+ const srcDR = srcAcroForm.lookupMaybe(PDFName.of("DR"), PDFDict);
333
+ if (!srcDR)
334
+ return fontRenames;
335
+ const dstDR = dstAcroForm.lookupMaybe(PDFName.of("DR"), PDFDict) ?? dstContext.obj({});
336
+ for (const [key] of srcDR.entries()) {
337
+ const srcCategory = srcDR.lookupMaybe(key, PDFDict);
338
+ const dstCategory = dstDR.lookupMaybe(key, PDFDict);
339
+ if (!dstCategory) {
340
+ const value = srcDR.get(key);
341
+ if (value !== undefined)
342
+ dstDR.set(key, copier.copy(value));
343
+ continue;
344
+ }
345
+ if (!srcCategory)
346
+ continue; // dst already holds a non-dict value here; leave it
347
+ const taken = new Set();
348
+ for (const [subKey, subValue] of srcCategory.entries()) {
349
+ const existing = dstCategory.get(subKey);
350
+ if (existing === undefined) {
351
+ dstCategory.set(subKey, copier.copy(subValue));
352
+ continue;
353
+ }
354
+ if (fingerprint(srcAcroForm.context, subValue) === fingerprint(dstContext, existing))
355
+ continue; // the same object: nothing to rename
356
+ const newName = uniqueResourceName(subKey.decodeText(), [dstCategory, srcCategory], taken);
357
+ dstCategory.set(PDFName.of(newName), copier.copy(subValue));
358
+ if (key === PDFName.of("Font"))
359
+ fontRenames.set(subKey.decodeText(), newName);
360
+ }
361
+ }
362
+ dstAcroForm.set(PDFName.of("DR"), dstDR);
363
+ return fontRenames;
364
+ }
365
+ /** The bytes of a hex string, decoded per the PDF spec: white space (NUL, tab, LF, FF, CR, space) between digits is
366
+ * ignored and an odd final digit is padded with 0. pdf-lib's `PDFHexString.asBytes()` does neither (it reads a space
367
+ * as a digit), so `<2F 46 31>` came out as garbage. Undefined if a non-hex character is present. */
368
+ function hexStringBytes(raw) {
369
+ let digits = raw.replace(/[\0\t\n\f\r ]/g, "");
370
+ if (!/^[0-9A-Fa-f]*$/.test(digits))
371
+ return undefined;
372
+ if (digits.length % 2 === 1)
373
+ digits += "0";
374
+ let out = "";
375
+ for (let i = 0; i < digits.length; i += 2)
376
+ out += String.fromCharCode(parseInt(digits.slice(i, i + 2), 16));
377
+ return out;
378
+ }
379
+ /** A `/DA` value as bytes (as a Latin-1 string), whether it is written `(...)` or `<...>`; undefined if it is
380
+ * neither. Bytes, not `decodeText()`: a DA is operators and names, and decoding as text would reinterpret it. */
381
+ function daBytes(ctx, v) {
382
+ if (v === undefined)
383
+ return undefined;
384
+ const o = ctx.lookup(v);
385
+ if (o instanceof PDFHexString)
386
+ return hexStringBytes(o.asString());
387
+ if (o instanceof PDFString)
388
+ return Array.from(o.asBytes(), (b) => String.fromCharCode(b)).join("");
389
+ return undefined;
390
+ }
391
+ /** Hex-string form of a Latin-1 string: needs no escaping, so a `)` or `\` or comment in a `/DA` survives. */
392
+ function daAsHex(text) {
393
+ return PDFHexString.of(Array.from(text, (c) => c.charCodeAt(0).toString(16).padStart(2, "0")).join(""));
394
+ }
395
+ const PDF_WS = new Set([0, 9, 10, 12, 13, 32]);
396
+ const PDF_DELIM = new Set(["(", ")", "<", ">", "[", "]", "{", "}", "/", "%"]);
397
+ /** Tokenise a content-stream fragment: names, numbers/operators, strings, comments skipped. Start/end offsets
398
+ * are into `text`, so a token can be replaced in place. */
399
+ function daTokens(text) {
400
+ const out = [];
401
+ let unterminated = false;
402
+ let i = 0;
403
+ while (i < text.length) {
404
+ const c = text[i];
405
+ if (PDF_WS.has(c.charCodeAt(0))) {
406
+ i++;
407
+ continue;
408
+ }
409
+ if (c === "%") {
410
+ while (i < text.length && text[i] !== "\n" && text[i] !== "\r")
411
+ i++;
412
+ continue;
413
+ }
414
+ const start = i;
415
+ if (c === "(") {
416
+ let depth = 0;
417
+ for (; i < text.length; i++) {
418
+ if (text[i] === "\\") {
419
+ i++;
420
+ continue;
421
+ }
422
+ if (text[i] === "(")
423
+ depth++;
424
+ else if (text[i] === ")" && --depth === 0) {
425
+ i++;
426
+ break;
427
+ }
428
+ }
429
+ out.push({ kind: "other", text: text.slice(start, i), start, end: i });
430
+ }
431
+ else if (c === "/") {
432
+ i++;
433
+ while (i < text.length && !PDF_WS.has(text.charCodeAt(i)) && !PDF_DELIM.has(text[i]))
434
+ i++;
435
+ out.push({ kind: "name", text: text.slice(start, i), start, end: i });
436
+ }
437
+ else if (c === "<" && text[i + 1] !== "<") {
438
+ // A hex string: ONE token up to and including the next `>` (its contents, a `%` or a `/F2`, are not syntax).
439
+ const close = text.indexOf(">", i + 1);
440
+ if (close === -1) {
441
+ unterminated = true;
442
+ i = text.length;
443
+ }
444
+ else
445
+ i = close + 1;
446
+ out.push({ kind: "other", text: text.slice(start, i), start, end: i });
447
+ }
448
+ else if (c === "<" || c === ">") {
449
+ i += text[i + 1] === c ? 2 : 1; // `<<`, `>>` or a stray `>`
450
+ out.push({ kind: "other", text: text.slice(start, i), start, end: i });
451
+ }
452
+ else if (PDF_DELIM.has(c)) {
453
+ i++;
454
+ out.push({ kind: "other", text: c, start, end: i });
455
+ }
456
+ else {
457
+ while (i < text.length && !PDF_WS.has(text.charCodeAt(i)) && !PDF_DELIM.has(text[i]))
458
+ i++;
459
+ out.push({ kind: "word", text: text.slice(start, i), start, end: i });
460
+ }
461
+ }
462
+ return { tokens: out, unterminated };
463
+ }
464
+ /** Rewrite ONLY the font operand of `Tf` (`/F1 12 Tf`) through `renames`; a colour space called `/F1` (`/F1 cs`) is
465
+ * left alone. Name tokens carry `#xx` escapes (`/Times#20Roman`): they are decoded before the lookup, and a renamed
466
+ * name is written back with PDF name escaping. `text` is undefined when nothing changed; `fonts` lists the (decoded)
467
+ * font operands the string uses, after renaming. */
468
+ function rewriteDA(da, renames) {
469
+ const { tokens: t, unterminated } = daTokens(da);
470
+ let out = "";
471
+ let last = 0;
472
+ const fonts = [];
473
+ for (let i = 0; i + 2 < t.length; i++) {
474
+ if (t[i].kind !== "name" || t[i + 1].kind !== "word" || t[i + 2].kind !== "word" || t[i + 2].text !== "Tf")
475
+ continue;
476
+ if (!/^[+-]?(\d+\.?\d*|\.\d+)$/.test(t[i + 1].text))
477
+ continue;
478
+ const decoded = t[i].text.slice(1).replace(/#([0-9A-Fa-f]{2})/g, (_, h) => String.fromCharCode(parseInt(h, 16)));
479
+ const to = renames.get(decoded);
480
+ fonts.push(to ?? decoded);
481
+ if (to === undefined)
482
+ continue;
483
+ out += da.slice(last, t[i].start) + PDFName.of(to).toString();
484
+ last = t[i].end;
485
+ }
486
+ return { text: last === 0 ? undefined : out + da.slice(last), fonts, unterminated };
487
+ }
488
+ /** Rewrite `/DA` on every field and widget under `roots` (the incoming fields, already copied into
489
+ * `dst`), following `/Kids`. A rewritten `/DA` is written as a hex string; an unchanged one is not touched.
490
+ * Returns how many `/DA` values could not be read (neither a string nor a hex string) plus how many name a font that resolves nowhere in `knownFonts`. */
491
+ function rewriteIncomingDA(dstContext, roots, renames, knownFonts) {
492
+ let unreadable = 0;
493
+ const seen = new Set();
494
+ const visit = (v, depth) => {
495
+ if (v === undefined || depth > 64)
496
+ return;
497
+ const dict = dstContext.lookup(v);
498
+ if (!(dict instanceof PDFDict) || seen.has(dict))
499
+ return;
500
+ seen.add(dict);
501
+ const da = dict.get(PDFName.of("DA"));
502
+ if (da !== undefined) {
503
+ const text = daBytes(dstContext, da);
504
+ if (text === undefined)
505
+ unreadable += 1;
506
+ else {
507
+ const next = rewriteDA(text, renames);
508
+ if (next.text !== undefined)
509
+ dict.set(PDFName.of("DA"), daAsHex(next.text));
510
+ if (next.unterminated)
511
+ unreadable += 1; // could not be tokenised reliably: warn rather than guess
512
+ // A font the /DA uses that resolves nowhere in the merged /DR: it would render in a substitute.
513
+ for (const f of next.fonts)
514
+ if (!knownFonts.has(f))
515
+ unreadable += 1;
516
+ }
517
+ }
518
+ const kids = dstContext.lookup(dict.get(PDFName.of("Kids")));
519
+ if (kids instanceof PDFArray)
520
+ for (const k of kids.asArray())
521
+ visit(k, depth + 1);
522
+ };
523
+ roots.forEach((r) => visit(r, 0));
524
+ return unreadable;
525
+ }
526
+ /** Form-level keys merged verbatim from `src` when `dst` doesn't already carry
527
+ * one — `/DR` and `/NeedAppearances` are handled separately (a real merge and
528
+ * an OR respectively, not "dst wins"), and `/CO` is rebuilt below rather than
529
+ * copied, same reasoning as `SAFE_ACROFORM_KEYS` above. */
530
+ const APPEND_SIMPLE_MERGE_KEYS = ["DA", "Q", "SigFlags"];
531
+ /** Merge the appended document's AcroForm into `dst`'s (omitly#1238).
532
+ *
533
+ * Every source field is added — never pruned, since every page it lives on
534
+ * was just appended alongside it — renamed only where its top-level partial
535
+ * name would otherwise collide with one already in `dst`. Reuses `copier`'s
536
+ * cache, so a field merged with the widget already copied as part of its
537
+ * page's `/Annots` (the common case: `field.addToPage` produces ONE dict
538
+ * serving both roles) resolves to that SAME destination object rather than a
539
+ * duplicate — the same #884 argument the page-copy loop above already relies
540
+ * on, extended to the field tree. */
541
+ export function mergeAcroFormOnAppend(src, dst, copier) {
542
+ // omitly#1841: exported for `mergePdfs`; the counts let it warn that a field rename
543
+ // can break a calculation that names the old field, and that a font rename could not
544
+ // be applied to some /DA. `appendPdf` ignores both.
545
+ let renamed = 0;
546
+ let fontRenameFailed = 0;
547
+ const srcAcroForm = src.catalog.AcroForm();
548
+ const srcFields = srcAcroForm?.lookupMaybe(PDFName.of("Fields"), PDFArray);
549
+ if (!srcAcroForm || !srcFields || srcFields.size() === 0)
550
+ return { renamed, fontRenameFailed };
551
+ const dstAcroFormExisting = dst.catalog.AcroForm();
552
+ const takenNames = topLevelFieldNames(dstAcroFormExisting);
553
+ const fieldRefMap = new Map();
554
+ const copiedRefs = [];
555
+ for (let i = 0; i < srcFields.size(); i++) {
556
+ const srcRef = srcFields.get(i);
557
+ if (!(srcRef instanceof PDFRef))
558
+ continue;
559
+ const dstRef = copier.copy(srcRef);
560
+ fieldRefMap.set(srcRef, dstRef);
561
+ copiedRefs.push(dstRef);
562
+ const originalName = fieldPartialName(src.context.lookup(srcRef, PDFDict));
563
+ const finalName = disambiguateFieldName(originalName, takenNames);
564
+ takenNames.add(finalName);
565
+ if (finalName !== originalName) {
566
+ dst.context.lookup(dstRef, PDFDict).set(PDFName.of("T"), PDFHexString.fromText(finalName));
567
+ renamed += 1;
568
+ }
569
+ }
570
+ if (copiedRefs.length === 0)
571
+ return { renamed, fontRenameFailed };
572
+ const dstAcroForm = dstAcroFormExisting ?? dst.context.obj({});
573
+ const dstFields = dstAcroForm.lookupMaybe(PDFName.of("Fields"), PDFArray) ?? PDFArray.withContext(dst.context);
574
+ copiedRefs.forEach((r) => dstFields.push(r));
575
+ dstAcroForm.set(PDFName.of("Fields"), dstFields);
576
+ for (const name of APPEND_SIMPLE_MERGE_KEYS) {
577
+ const key = PDFName.of(name);
578
+ if (dstAcroForm.get(key) !== undefined)
579
+ continue; // dst's own value wins
580
+ const value = srcAcroForm.get(key);
581
+ if (value !== undefined)
582
+ dstAcroForm.set(key, copier.copy(value));
583
+ }
584
+ const fontRenames = mergeDefaultResources(dstAcroForm, srcAcroForm, copier, dst.context);
585
+ // The incoming fields keep their own fonts: rewrite their /DA through the renames; and if they relied on the
586
+ // source form's /DA (no /DA of their own) while the destination keeps a DIFFERENT form-level /DA, give them
587
+ // that one, rewritten, explicitly.
588
+ const original = daBytes(src.context, srcAcroForm.get(PDFName.of("DA")));
589
+ if (original !== undefined) {
590
+ const dstFormDA = daBytes(dst.context, dstAcroForm.get(PDFName.of("DA")));
591
+ const rewritten = rewriteDA(original, fontRenames).text;
592
+ if (dstFormDA !== undefined && (dstFormDA !== original || fontRenames.size > 0)) {
593
+ for (const ref of copiedRefs) {
594
+ const f = dst.context.lookup(ref, PDFDict);
595
+ // Rewritten: as hex (no escaping to get wrong). Unchanged: the source's own bytes, as a hex string too.
596
+ if (f.get(PDFName.of("DA")) === undefined)
597
+ f.set(PDFName.of("DA"), daAsHex(rewritten ?? original));
598
+ }
599
+ }
600
+ }
601
+ if (fontRenames.size > 0) {
602
+ const drFonts = dstAcroForm.lookupMaybe(PDFName.of("DR"), PDFDict)?.lookupMaybe(PDFName.of("Font"), PDFDict);
603
+ const known = new Set((drFonts?.entries() ?? []).map(([k]) => k.decodeText()));
604
+ fontRenameFailed += rewriteIncomingDA(dst.context, copiedRefs, fontRenames, known);
605
+ }
606
+ // OR, not "dst wins": if EITHER document relies on viewer-regenerated
607
+ // appearances, the merged form still needs to.
608
+ const srcNeedApp = srcAcroForm.lookupMaybe(PDFName.of("NeedAppearances"), PDFBool);
609
+ if (srcNeedApp?.asBoolean())
610
+ dstAcroForm.set(PDFName.of("NeedAppearances"), PDFBool.True);
611
+ // Rebuilt from the surviving field map, not concatenated: a raw
612
+ // concatenation of two /CO arrays would carry stale-looking src refs if
613
+ // any entry didn't map (defensive; every top-level field is copied above,
614
+ // so in practice every entry maps).
615
+ const srcCalcOrder = srcAcroForm.lookupMaybe(PDFName.of("CO"), PDFArray);
616
+ if (srcCalcOrder && srcCalcOrder.size() > 0) {
617
+ const dstCalcOrder = dstAcroForm.lookupMaybe(PDFName.of("CO"), PDFArray) ?? PDFArray.withContext(dst.context);
618
+ for (let i = 0; i < srcCalcOrder.size(); i++) {
619
+ const entry = srcCalcOrder.get(i);
620
+ const mapped = entry instanceof PDFRef ? fieldRefMap.get(entry) : undefined;
621
+ if (mapped)
622
+ dstCalcOrder.push(mapped);
623
+ }
624
+ if (dstCalcOrder.size() > 0)
625
+ dstAcroForm.set(PDFName.of("CO"), dstCalcOrder);
626
+ }
627
+ if (!dstAcroFormExisting) {
628
+ const acroFormRef = dst.context.register(dstAcroForm);
629
+ dst.catalog.set(PDFName.of("AcroForm"), acroFormRef);
630
+ }
631
+ return { renamed, fontRenameFailed };
632
+ }
633
+ /** Recursively copy the subset of a source AcroForm field tree whose widgets
634
+ * survived onto an extracted page, reusing `copier`'s cache so a merged
635
+ * field/widget dict already copied as part of the page's `/Annots` resolves
636
+ * to the SAME destination object rather than being duplicated. A container
637
+ * field is kept only if at least one descendant survived; a field none of
638
+ * whose widgets made it across is dropped entirely rather than carried as a
639
+ * dangling reference. Returns the destination ref, or undefined if dropped. */
640
+ function copyKeptField(copier, srcContext, dstContext, ref, keptWidgetRefs, keptFieldMap) {
641
+ const dict = srcContext.lookup(ref, PDFDict);
642
+ const kids = dict.lookupMaybe(PDFName.of("Kids"), PDFArray);
643
+ if (!kids) {
644
+ const subtype = dict.lookup(PDFName.of("Subtype"));
645
+ // Merged field/widget (the common case): keep only if its widget
646
+ // actually landed on an extracted page.
647
+ if (subtype === PDFName.of("Widget") && keptWidgetRefs.has(ref)) {
648
+ const copied = copier.copy(ref);
649
+ keptFieldMap.set(ref, copied);
650
+ return copied;
651
+ }
652
+ return undefined;
653
+ }
654
+ const keptKidRefs = [];
655
+ for (let i = 0; i < kids.size(); i++) {
656
+ const kidRef = kids.get(i);
657
+ if (!(kidRef instanceof PDFRef))
658
+ continue;
659
+ const copiedKidRef = copyKeptField(copier, srcContext, dstContext, kidRef, keptWidgetRefs, keptFieldMap);
660
+ if (copiedKidRef)
661
+ keptKidRefs.push(copiedKidRef);
662
+ }
663
+ if (keptKidRefs.length === 0)
664
+ return undefined;
665
+ const newDict = dstContext.obj({});
666
+ for (const [key, value] of dict.entries()) {
667
+ if (key === PDFName.of("Kids"))
668
+ continue;
669
+ newDict.set(key, copier.copy(value));
670
+ }
671
+ const newKids = PDFArray.withContext(dstContext);
672
+ keptKidRefs.forEach((r) => newKids.push(r));
673
+ newDict.set(PDFName.of("Kids"), newKids);
674
+ const newRef = dstContext.register(newDict);
675
+ keptFieldMap.set(ref, newRef);
676
+ for (const kidRef of keptKidRefs) {
677
+ dstContext.lookup(kidRef, PDFDict).set(PDFName.of("Parent"), newRef);
678
+ }
679
+ return newRef;
680
+ }
681
+ /** Form-level `/AcroForm` entries that are safe to carry into an extracted
682
+ * document. Every key here holds either a scalar or a resource dictionary; none
683
+ * can reference a field, a widget or a page, so none can drag a page the user
684
+ * did NOT select into the output. The list is an ALLOWLIST on purpose: an
685
+ * unrecognised entry is dropped, not copied.
686
+ *
687
+ * `/CO` (JS calculation order) is deliberately absent — it names fields by
688
+ * reference, and forms with any calculated field routinely list every
689
+ * participating field in it, including ones whose only widget sits on an
690
+ * excluded page. It is rebuilt below from the surviving fields only.
691
+ * `/XFA` is absent for the same reason and worse: it carries a whole second
692
+ * copy of the form (structure and data) for every page, which a pruned page
693
+ * subset has no way to honour. */
694
+ const SAFE_ACROFORM_KEYS = ["DA", "DR", "Q", "NeedAppearances", "SigFlags"];
695
+ /** Carry the source document's AcroForm across to `dst`, pruned to only the
696
+ * fields whose widgets landed on `extractedPages` — never the whole form. A
697
+ * document with no surviving fields ends up with no `/AcroForm` at all. */
698
+ function copyAcroFormForExtractedPages(src, dst, copier, extractedSrcPages) {
699
+ const srcAcroForm = src.catalog.AcroForm();
700
+ if (!srcAcroForm)
701
+ return;
702
+ const keptWidgetRefs = new Set();
703
+ for (const page of extractedSrcPages) {
704
+ const annots = page.node.Annots();
705
+ if (!annots)
706
+ continue;
707
+ for (let i = 0; i < annots.size(); i++) {
708
+ const annotRef = annots.get(i);
709
+ if (!(annotRef instanceof PDFRef))
710
+ continue;
711
+ const annotDict = src.context.lookup(annotRef, PDFDict);
712
+ if (annotDict.lookup(PDFName.of("Subtype")) === PDFName.of("Widget")) {
713
+ keptWidgetRefs.add(annotRef);
714
+ }
715
+ }
716
+ }
717
+ if (keptWidgetRefs.size === 0)
718
+ return;
719
+ const srcFields = srcAcroForm.lookupMaybe(PDFName.of("Fields"), PDFArray);
720
+ const keptFieldRefs = [];
721
+ const keptFieldMap = new Map();
722
+ if (srcFields) {
723
+ for (let i = 0; i < srcFields.size(); i++) {
724
+ const fieldRef = srcFields.get(i);
725
+ if (!(fieldRef instanceof PDFRef))
726
+ continue;
727
+ const copiedRef = copyKeptField(copier, src.context, dst.context, fieldRef, keptWidgetRefs, keptFieldMap);
728
+ if (copiedRef)
729
+ keptFieldRefs.push(copiedRef);
730
+ }
731
+ }
732
+ if (keptFieldRefs.length === 0)
733
+ return;
734
+ const dstAcroForm = dst.context.obj({});
735
+ // Allowlist, never a blanket copy of every remaining entry: deep-copying an
736
+ // arbitrary form-level entry that references a field (notably /CO) pulls that
737
+ // field's widget across with `copier`, and the widget's `/P` back-reference
738
+ // then drags in the whole EXCLUDED page it sits on — a page the user chose to
739
+ // leave behind, byte-recoverable in the output despite being unreachable from
740
+ // the page tree.
741
+ for (const name of SAFE_ACROFORM_KEYS) {
742
+ const key = PDFName.of(name);
743
+ const value = srcAcroForm.get(key);
744
+ if (value !== undefined)
745
+ dstAcroForm.set(key, copier.copy(value));
746
+ }
747
+ // Rebuild /CO from the surviving fields only, preserving their relative
748
+ // order, so calculated fields that DID come across still evaluate in order.
749
+ const srcCalcOrder = srcAcroForm.lookupMaybe(PDFName.of("CO"), PDFArray);
750
+ if (srcCalcOrder) {
751
+ const dstCalcOrder = PDFArray.withContext(dst.context);
752
+ for (let i = 0; i < srcCalcOrder.size(); i++) {
753
+ const entry = srcCalcOrder.get(i);
754
+ const mapped = entry instanceof PDFRef ? keptFieldMap.get(entry) : undefined;
755
+ if (mapped)
756
+ dstCalcOrder.push(mapped);
757
+ }
758
+ if (dstCalcOrder.size() > 0)
759
+ dstAcroForm.set(PDFName.of("CO"), dstCalcOrder);
760
+ }
761
+ const fieldsArr = PDFArray.withContext(dst.context);
762
+ keptFieldRefs.forEach((r) => fieldsArr.push(r));
763
+ dstAcroForm.set(PDFName.of("Fields"), fieldsArr);
764
+ const acroFormRef = dst.context.register(dstAcroForm);
765
+ dst.catalog.set(PDFName.of("AcroForm"), acroFormRef);
766
+ }
767
+ /** A before-image of every dictionary and array an extraction mutates in the SOURCE, so one parsed
768
+ * source can serve many extractions (`openExtractionSession`): parsing is the dominant cost of an
769
+ * extraction (a 2 MB, 10,000-page file takes ~0.36 s of a ~0.41 s extraction), and `split_pdf`
770
+ * does one per range. Only the mutations of default-deny (neuter, detach, field scrub) are
771
+ * journaled; nothing is journaled for a one-shot `extractPages`. */
772
+ class Journal {
773
+ dicts = new Map();
774
+ arrays = new Map();
775
+ dict(d) {
776
+ if (!this.dicts.has(d))
777
+ this.dicts.set(d, [...d.entries()]);
778
+ }
779
+ array(a) {
780
+ if (!this.arrays.has(a))
781
+ this.arrays.set(a, a.asArray());
782
+ }
783
+ restore() {
784
+ for (const [d, saved] of this.dicts) {
785
+ for (const [k] of [...d.entries()])
786
+ d.delete(k);
787
+ for (const [k, v] of saved)
788
+ d.set(k, v);
789
+ }
790
+ for (const [a, saved] of this.arrays) {
791
+ while (a.size() > 0)
792
+ a.remove(a.size() - 1);
793
+ for (const v of saved)
794
+ a.push(v);
795
+ }
796
+ this.dicts.clear();
797
+ this.arrays.clear();
798
+ }
799
+ }
800
+ /** Detach every widget annotation on `page` from the field it belongs to, so a
801
+ * surviving sibling widget's `/Parent` chain can't reach back down into a page
802
+ * the user excluded. Mutates the SOURCE document, which for `extractPages` is a
803
+ * private throwaway copy. */
804
+ function detachWidgetsFromFields(context, page, journal) {
805
+ const annots = page.node.Annots();
806
+ if (!annots)
807
+ return;
808
+ for (let i = 0; i < annots.size(); i++) {
809
+ const annotRef = annots.get(i);
810
+ if (!(annotRef instanceof PDFRef))
811
+ continue;
812
+ const annot = context.lookupMaybe(annotRef, PDFDict);
813
+ const parentRef = annot?.get(PDFName.of("Parent"));
814
+ if (!(parentRef instanceof PDFRef))
815
+ continue;
816
+ const kids = context.lookupMaybe(parentRef, PDFDict)?.lookupMaybe(PDFName.of("Kids"), PDFArray);
817
+ if (!kids)
818
+ continue;
819
+ for (let k = kids.size() - 1; k >= 0; k--) {
820
+ if (kids.get(k) === annotRef) {
821
+ journal?.array(kids);
822
+ kids.remove(k);
823
+ }
824
+ }
825
+ }
826
+ }
827
+ /** Reduce a page to an inert stub — drop every entry but `/Type`, then give it
828
+ * a degenerate `/MediaBox` so it stays a structurally valid (if empty) page.
829
+ *
830
+ * This is the DEFAULT-DENY half of the extraction leak fix. The allowlist above
831
+ * closes the reference paths we know about; this closes the ones we don't. Any
832
+ * object graph that still manages to reach a page the user did not select finds
833
+ * nothing there to leak: no `/Contents`, no `/Annots`, no `/Resources`. Safe to
834
+ * do because `extractPages` loads its own private copy of the source bytes and
835
+ * discards it — the user's document is never touched. */
836
+ function neuterPage(context, page, journal) {
837
+ const leaf = page.node;
838
+ journal?.dict(leaf);
839
+ for (const [key] of leaf.entries()) {
840
+ if (key === PDFName.of("Type"))
841
+ continue;
842
+ leaf.delete(key);
843
+ }
844
+ leaf.set(PDFName.of("MediaBox"), context.obj([0, 0, 1, 1]));
845
+ }
846
+ // ---------------------------------------------------------------------------
847
+ // Document-level (catalog) structures for `extractPages({ preserveCatalog })`
848
+ // (omitly#1007). Every helper below only ever ADDS a reference to a page ref
849
+ // already present in `keptSrcPageRefs` (or copies something with no page
850
+ // dependency at all, like `/Info`/`/ViewerPreferences`) — never to a deleted
851
+ // page's (neutered) leaf — so none of this can reopen the #1004 leak. A PDF
852
+ // destination names its target page by REFERENCE, not by index, so once a
853
+ // kept page's ref has been copied once (in the page-copy loop below),
854
+ // `copier`'s src->dst cache remaps every further reference to it for free —
855
+ // the same trick `reorderPages` already relies on for outline destinations.
856
+ // ---------------------------------------------------------------------------
857
+ /** Resolve an explicit destination array's target page ref: `[pageRef, /Fit,
858
+ * ...]`. `undefined` for anything else (an empty/malformed array, or a value
859
+ * that isn't an array at all) — callers treat "can't resolve" as "don't
860
+ * assume this destination is safe to keep," never as "assume it's fine." */
861
+ function resolveDestArrayPageRef(value) {
862
+ if (!(value instanceof PDFArray) || value.size() === 0)
863
+ return undefined;
864
+ const first = value.get(0);
865
+ return first instanceof PDFRef ? first : undefined;
866
+ }
867
+ /** Resolve a destination VALUE (as found in an outline item's `/Dest`, a
868
+ * GoTo action's `/D`, or a `/Dests`/name-tree entry) to its target page ref.
869
+ * Handles an indirect reference to the array, and the `{ D: [...] }` wrapper
870
+ * form the name tree sometimes uses. A destination named indirectly by a
871
+ * PDF name/string (rather than holding the array itself) is NOT followed
872
+ * further — that would require cross-referencing the `/Dests` tree from
873
+ * inside its own construction, so it's a documented gap: such a destination
874
+ * degrades to "unresolved," never to a guess. */
875
+ function resolveDestTargetPageRef(context, value) {
876
+ let resolved = value;
877
+ if (resolved instanceof PDFRef)
878
+ resolved = context.lookup(resolved);
879
+ if (resolved instanceof PDFArray)
880
+ return resolveDestArrayPageRef(resolved);
881
+ if (resolved instanceof PDFDict) {
882
+ return resolveDestTargetPageRef(context, resolved.get(PDFName.of("D")));
883
+ }
884
+ return undefined;
885
+ }
886
+ /** Whether an outline item names a page at all, and if so, which one. An
887
+ * item with neither `/Dest` nor a `/A` GoTo action (a URI link, a JavaScript
888
+ * action, a pure container heading) is `pageDependent: false` — it has no
889
+ * relationship to the page set and is always kept untouched, regardless of
890
+ * what got deleted. */
891
+ function outlineItemPageDependency(context, item) {
892
+ const dest = item.get(PDFName.of("Dest"));
893
+ if (dest !== undefined) {
894
+ return { pageDependent: true, targetRef: resolveDestTargetPageRef(context, dest) };
895
+ }
896
+ const action = item.lookupMaybe(PDFName.of("A"), PDFDict);
897
+ if (action && action.get(PDFName.of("S")) === PDFName.of("GoTo")) {
898
+ return {
899
+ pageDependent: true,
900
+ targetRef: resolveDestTargetPageRef(context, action.get(PDFName.of("D"))),
901
+ };
902
+ }
903
+ return { pageDependent: false };
904
+ }
905
+ /** Fields the tree-linking step (`linkOutlineSiblings`) rebuilds itself —
906
+ * never copied verbatim from the source item, since the source's own
907
+ * Parent/Prev/Next/First/Last/Count describe a tree shape that pruning may
908
+ * have changed. `Dest`/`A` are handled separately too (only copied when the
909
+ * destination survives), so they're excluded from the generic field copy. */
910
+ const OUTLINE_STRUCTURAL_KEYS = new Set([
911
+ PDFName.of("Parent"),
912
+ PDFName.of("Prev"),
913
+ PDFName.of("Next"),
914
+ PDFName.of("First"),
915
+ PDFName.of("Last"),
916
+ PDFName.of("Count"),
917
+ PDFName.of("Dest"),
918
+ PDFName.of("A"),
919
+ ]);
920
+ /** Recursively rebuild one sibling level of the outline tree (the items
921
+ * chained via `/Next` starting at `firstRef`), keeping an item if its own
922
+ * destination survives OR at least one descendant's does; an item that
923
+ * survives only via a descendant degrades to a destination-less container
924
+ * rather than carrying a dangling/incorrect `/Dest`. Returns the rebuilt
925
+ * siblings in order, unlinked (the caller wires Parent/Prev/Next/First/Last). */
926
+ function rebuildOutlineSiblings(copier, srcContext, dstContext, firstRef, keptSrcPageRefs) {
927
+ const built = [];
928
+ const seen = new Set(); // guards against a malformed cyclic /Next chain
929
+ let cursor = firstRef;
930
+ while (cursor instanceof PDFRef) {
931
+ if (seen.has(cursor))
932
+ break;
933
+ seen.add(cursor);
934
+ const item = srcContext.lookupMaybe(cursor, PDFDict);
935
+ if (!item)
936
+ break;
937
+ const children = rebuildOutlineSiblings(copier, srcContext, dstContext, item.get(PDFName.of("First")), keptSrcPageRefs);
938
+ const { pageDependent, targetRef } = outlineItemPageDependency(srcContext, item);
939
+ const targetSurvives = !pageDependent || (targetRef !== undefined && keptSrcPageRefs.has(targetRef));
940
+ if (pageDependent && !targetSurvives && children.length === 0) {
941
+ cursor = item.get(PDFName.of("Next"));
942
+ continue; // nothing left of this item; drop it
943
+ }
944
+ const newDict = dstContext.obj({});
945
+ for (const [key, value] of item.entries()) {
946
+ if (OUTLINE_STRUCTURAL_KEYS.has(key))
947
+ continue;
948
+ newDict.set(key, copier.copy(value));
949
+ }
950
+ if (targetSurvives) {
951
+ const dest = item.get(PDFName.of("Dest"));
952
+ const action = item.get(PDFName.of("A"));
953
+ if (dest !== undefined)
954
+ newDict.set(PDFName.of("Dest"), copier.copy(dest));
955
+ else if (action !== undefined)
956
+ newDict.set(PDFName.of("A"), copier.copy(action));
957
+ }
958
+ // else: pageDependent, but its target page was deleted — the item
959
+ // survives only because a descendant does, so it degrades to a plain
960
+ // (destination-less) container rather than carrying a dangling ref.
961
+ built.push({ ref: dstContext.register(newDict), children });
962
+ cursor = item.get(PDFName.of("Next"));
963
+ }
964
+ return built;
965
+ }
966
+ /** Total descendant count of a rebuilt subtree, every surviving item counted
967
+ * as OPEN — a deliberate simplification (see `copyOutlineForExtractedPages`)
968
+ * rather than trying to preserve each item's original open/closed `/Count`
969
+ * sign across a pruned tree shape. */
970
+ function subtreeDescendantCount(children) {
971
+ let total = 0;
972
+ for (const child of children)
973
+ total += 1 + subtreeDescendantCount(child.children);
974
+ return total;
975
+ }
976
+ /** Wire Parent/Prev/Next/First/Last/Count across a rebuilt sibling level
977
+ * (and recursively, its children), onto the already-registered dst dicts
978
+ * `rebuildOutlineSiblings` produced. */
979
+ function linkOutlineSiblings(dstContext, siblings, parentRef) {
980
+ for (let i = 0; i < siblings.length; i++) {
981
+ const dict = dstContext.lookup(siblings[i].ref, PDFDict);
982
+ dict.set(PDFName.of("Parent"), parentRef);
983
+ if (i > 0)
984
+ dict.set(PDFName.of("Prev"), siblings[i - 1].ref);
985
+ if (i < siblings.length - 1)
986
+ dict.set(PDFName.of("Next"), siblings[i + 1].ref);
987
+ const kids = siblings[i].children;
988
+ if (kids.length > 0) {
989
+ linkOutlineSiblings(dstContext, kids, siblings[i].ref);
990
+ dict.set(PDFName.of("First"), kids[0].ref);
991
+ dict.set(PDFName.of("Last"), kids[kids.length - 1].ref);
992
+ dict.set(PDFName.of("Count"), PDFNumber.of(subtreeDescendantCount(kids)));
993
+ }
994
+ }
995
+ }
996
+ /** Copy the `/Outlines` bookmark tree onto the extracted subset, keeping a
997
+ * bookmark whose destination resolves to a RETAINED page (remapped for free
998
+ * via `copier`'s cache — see the module-doc comment above) and dropping one
999
+ * that targets the DELETED page, unless it has a surviving descendant, in
1000
+ * which case it degrades to a plain container. A bookmark with no page
1001
+ * destination at all (a URI link, a JavaScript action, a heading with
1002
+ * neither) is never touched. Every surviving item is reported OPEN
1003
+ * (`/Count` positive) regardless of the source's collapsed state — cosmetic,
1004
+ * not a correctness or leak concern. */
1005
+ function copyOutlineForExtractedPages(src, dst, copier, keptSrcPageRefs) {
1006
+ const outlinesRef = src.catalog.get(PDFName.of("Outlines"));
1007
+ if (outlinesRef === undefined)
1008
+ return;
1009
+ const outlines = src.context.lookupMaybe(outlinesRef, PDFDict);
1010
+ if (!outlines)
1011
+ return;
1012
+ const topLevel = rebuildOutlineSiblings(copier, src.context, dst.context, outlines.get(PDFName.of("First")), keptSrcPageRefs);
1013
+ if (topLevel.length === 0)
1014
+ return;
1015
+ const dstOutlines = dst.context.obj({ Type: PDFName.of("Outlines") });
1016
+ const dstOutlinesRef = dst.context.register(dstOutlines);
1017
+ linkOutlineSiblings(dst.context, topLevel, dstOutlinesRef);
1018
+ dstOutlines.set(PDFName.of("First"), topLevel[0].ref);
1019
+ dstOutlines.set(PDFName.of("Last"), topLevel[topLevel.length - 1].ref);
1020
+ dstOutlines.set(PDFName.of("Count"), PDFNumber.of(subtreeDescendantCount(topLevel)));
1021
+ dst.catalog.set(PDFName.of("Outlines"), dstOutlinesRef);
1022
+ }
1023
+ /** Copy `/Info` (Title/Author/Subject/Keywords/Creator/CreationDate/custom
1024
+ * fields...) across UNMODIFIED. This matches the existing convention for
1025
+ * every OTHER page op in this file: `reorderPages`/`movePage` never touch
1026
+ * `/Info` at all, because they mutate the loaded document in place rather
1027
+ * than rebuilding the catalog — so its natural "preserved" state is simply
1028
+ * "left alone." No preserve-metadata TOGGLE applies here: `preserveMetadata`
1029
+ * (`src/ipc/redact.ts`, `src/preserveMetadataPref.ts`) gates whether the Rust
1030
+ * redaction ENGINE scrubs `/Info` as part of removing sensitive content; a
1031
+ * page delete was never a metadata-scrubbing operation, so its own catalog
1032
+ * rebuild has no reason to invent a competing toggle — it just carries
1033
+ * whatever was already there, like its siblings do. */
1034
+ function copyInfoForExtractedPages(src, dst, copier) {
1035
+ const info = src.context.trailerInfo.Info;
1036
+ if (info === undefined)
1037
+ return;
1038
+ dst.context.trailerInfo.Info = copier.copy(info);
1039
+ }
1040
+ /** Copy `/ViewerPreferences` (page layout/mode, print scaling, etc.) across
1041
+ * as-is — it never references a page, so there's nothing to prune. */
1042
+ function copyViewerPreferencesForExtractedPages(src, dst, copier) {
1043
+ const key = PDFName.of("ViewerPreferences");
1044
+ const value = src.catalog.get(key);
1045
+ if (value === undefined)
1046
+ return;
1047
+ dst.catalog.set(key, copier.copy(value));
1048
+ }
1049
+ /** Remap the `/PageLabels` number tree onto the extracted page subset, using
1050
+ * the same old-index -> new-index mapping `extractPages` computes for its
1051
+ * own bookkeeping. Only the flat shape (`{ Nums: [...] }` directly on the
1052
+ * root, no `/Kids` sub-trees — what any ordinarily-sized document produces)
1053
+ * is handled; a split tree is left un-restored rather than guessed at. A
1054
+ * `/Nums` entry keyed at the deleted page's index describes a labelling
1055
+ * range that started there and has no home after the delete, so it is
1056
+ * dropped rather than kept under a fabricated new key. */
1057
+ function copyPageLabelsForExtractedPages(src, dst, copier, oldIndexToNewIndex) {
1058
+ const ref = src.catalog.get(PDFName.of("PageLabels"));
1059
+ if (ref === undefined)
1060
+ return;
1061
+ const tree = src.context.lookupMaybe(ref, PDFDict);
1062
+ if (!tree)
1063
+ return;
1064
+ const nums = tree.lookupMaybe(PDFName.of("Nums"), PDFArray);
1065
+ if (!nums)
1066
+ return; // /Kids-split tree: not restored (documented gap)
1067
+ const remapped = [];
1068
+ for (let i = 0; i + 1 < nums.size(); i += 2) {
1069
+ const key = nums.get(i);
1070
+ if (!(key instanceof PDFNumber))
1071
+ continue;
1072
+ const newIndex = oldIndexToNewIndex.get(key.asNumber());
1073
+ if (newIndex === undefined)
1074
+ continue; // this range started on the deleted page
1075
+ remapped.push({ newIndex, value: nums.get(i + 1) });
1076
+ }
1077
+ if (remapped.length === 0)
1078
+ return;
1079
+ remapped.sort((a, b) => a.newIndex - b.newIndex); // number-tree keys must be ascending
1080
+ const dstNums = PDFArray.withContext(dst.context);
1081
+ for (const { newIndex, value } of remapped) {
1082
+ dstNums.push(PDFNumber.of(newIndex));
1083
+ dstNums.push(copier.copy(value));
1084
+ }
1085
+ const dstTree = dst.context.obj({ Nums: dstNums });
1086
+ dst.catalog.set(PDFName.of("PageLabels"), dst.context.register(dstTree));
1087
+ }
1088
+ /** Copy named destinations, dropping any entry whose target page was
1089
+ * deleted (never remapping-and-keeping a dangling one). Handles both the
1090
+ * legacy `/Dests` catalog dictionary (PDF 1.1) and the `/Names /Dests` name
1091
+ * tree (PDF 1.2+, flat shape only — a `/Kids`-split tree is left
1092
+ * un-restored, same documented gap as `/PageLabels` above). Like outline
1093
+ * destinations, a surviving target's ref is remapped for free by `copier`'s
1094
+ * cache. */
1095
+ function copyNamedDestinationsForExtractedPages(src, dst, copier, keptSrcPageRefs) {
1096
+ const legacyRef = src.catalog.get(PDFName.of("Dests"));
1097
+ const legacyDests = src.context.lookupMaybe(legacyRef, PDFDict);
1098
+ if (legacyDests) {
1099
+ const dstDests = dst.context.obj({});
1100
+ let any = false;
1101
+ for (const [name, value] of legacyDests.entries()) {
1102
+ const target = resolveDestTargetPageRef(src.context, value);
1103
+ if (!target || !keptSrcPageRefs.has(target))
1104
+ continue;
1105
+ dstDests.set(name, copier.copy(value));
1106
+ any = true;
1107
+ }
1108
+ if (any)
1109
+ dst.catalog.set(PDFName.of("Dests"), dst.context.register(dstDests));
1110
+ }
1111
+ const namesRef = src.catalog.get(PDFName.of("Names"));
1112
+ const namesDict = src.context.lookupMaybe(namesRef, PDFDict);
1113
+ const destTreeRef = namesDict?.get(PDFName.of("Dests"));
1114
+ const destTree = namesDict && src.context.lookupMaybe(destTreeRef, PDFDict);
1115
+ const namesArr = destTree?.lookupMaybe(PDFName.of("Names"), PDFArray);
1116
+ if (!namesArr)
1117
+ return; // absent, or a /Kids-split tree: not restored
1118
+ const dstNames = PDFArray.withContext(dst.context);
1119
+ let anyNamed = false;
1120
+ for (let i = 0; i + 1 < namesArr.size(); i += 2) {
1121
+ const nameObj = namesArr.get(i);
1122
+ const value = namesArr.get(i + 1);
1123
+ const target = resolveDestTargetPageRef(src.context, value);
1124
+ if (!target || !keptSrcPageRefs.has(target))
1125
+ continue;
1126
+ dstNames.push(copier.copy(nameObj));
1127
+ dstNames.push(copier.copy(value));
1128
+ anyNamed = true;
1129
+ }
1130
+ if (!anyNamed)
1131
+ return;
1132
+ const dstDestTree = dst.context.obj({ Names: dstNames });
1133
+ const dstNamesDict = dst.context.obj({ Dests: dst.context.register(dstDestTree) });
1134
+ dst.catalog.set(PDFName.of("Names"), dst.context.register(dstNamesDict));
1135
+ }
1136
+ /** Extract the given page indices into a brand-new document. By default,
1137
+ * document-level structures are dropped (a bookmark tree naming pages that
1138
+ * no longer exist has no home in a subset document) — the `bytesForPrint`
1139
+ * range-print path relies on exactly that, disclosed to the user as lossy.
1140
+ * `{ preserveCatalog: true }` (used by `deletePage`, omitly#1007) instead
1141
+ * copies+prunes them onto the fresh catalog: `/Outlines`, `/Info`,
1142
+ * `/PageLabels`, named destinations (`/Dests` and `/Names /Dests`) and
1143
+ * `/ViewerPreferences` — see each helper above for exactly what's covered
1144
+ * and the two documented gaps (a `/Kids`-split number/name tree; a
1145
+ * destination named indirectly by name/string rather than a direct array).
1146
+ * The AcroForm is ALWAYS carried across regardless of the option, pruned to
1147
+ * only the fields whose widgets are actually present on an extracted page,
1148
+ * so a field left entirely behind is dropped rather than becoming a
1149
+ * dangling reference. */
1150
+ export async function extractPagesDoc(bytes, indices, options = {}) {
1151
+ if (indices.length === 0)
1152
+ throw new Error("no pages selected");
1153
+ const src = await load(bytes);
1154
+ options.inspectSource?.(src);
1155
+ await src.flush();
1156
+ return buildExtraction(src, src.getPages(), indices, options.preserveCatalog === true, undefined);
1157
+ }
1158
+ /** Parse `bytes` ONCE for many extractions (omitly#1841 `split_pdf`: one per range). The default-deny
1159
+ * steps still run in full for every extraction; their mutations of the source are journaled and
1160
+ * undone afterwards, so nothing one range blanked out is missing from the next. */
1161
+ export async function openExtractionSession(bytes, options = {}) {
1162
+ const src = await load(bytes);
1163
+ options.inspectSource?.(src);
1164
+ await src.flush();
1165
+ const srcPages = src.getPages();
1166
+ return {
1167
+ pageCount: srcPages.length,
1168
+ async extract(indices, o = {}) {
1169
+ const journal = new Journal();
1170
+ try {
1171
+ return await buildExtraction(src, srcPages, indices, o.preserveCatalog === true, journal);
1172
+ }
1173
+ finally {
1174
+ journal.restore();
1175
+ }
1176
+ },
1177
+ };
1178
+ }
1179
+ async function buildExtraction(src, srcPages, indices, preserveCatalog, journal) {
1180
+ if (indices.length === 0)
1181
+ throw new Error("no pages selected");
1182
+ const dst = await PDFDocument.create({ updateMetadata: false });
1183
+ const keptIndices = new Set();
1184
+ for (const i of indices) {
1185
+ if (!srcPages[i])
1186
+ throw new Error(`no page ${i}`);
1187
+ if (keptIndices.has(i))
1188
+ throw new Error(`page ${i} selected twice`);
1189
+ keptIndices.add(i);
1190
+ }
1191
+ const extractedSrcPages = indices.map((i) => srcPages[i]);
1192
+ // BEFORE anything is copied, blank out every page the user did NOT select in
1193
+ // our private copy of the source. See neuterPage: this is what makes the
1194
+ // extraction default-deny rather than dependent on having enumerated every
1195
+ // possible back-reference into an excluded page.
1196
+ srcPages.forEach((page, i) => {
1197
+ if (keptIndices.has(i))
1198
+ return;
1199
+ detachWidgetsFromFields(src.context, page, journal);
1200
+ neuterPage(src.context, page, journal);
1201
+ });
1202
+ // By enumeration, not by following routes (omitly#1841, Codex): after the steps above, in any order.
1203
+ scrubFieldsOutsideKept(src, extractedSrcPages, journal);
1204
+ // Copy pages with our OWN PDFObjectCopier (rather than the doc.copyPages()
1205
+ // convenience method, which would spin up its own copier and discard it)
1206
+ // so the AcroForm copy below can reuse the SAME src->dst object cache: a
1207
+ // merged field/widget dict already copied as part of a page's /Annots must
1208
+ // resolve to that identical destination object, not a duplicate.
1209
+ const copier = PDFObjectCopier.for(src.context, dst.context);
1210
+ const copied = [];
1211
+ for (const srcPage of extractedSrcPages) {
1212
+ // Copy by REF, not by node. Copying the node hands the copier a fresh clone
1213
+ // it has never seen, so its cache is keyed on an object nothing else points
1214
+ // at — and a widget's `/P` back-reference to this same page then copies the
1215
+ // page a SECOND time, as an orphan duplicate. Going through the ref caches
1216
+ // src-ref -> dst-ref, so every back-reference lands on the one real copy.
1217
+ const ref = copier.copy(srcPage.ref);
1218
+ const node = dst.context.lookup(ref);
1219
+ if (!(node instanceof PDFPageLeaf))
1220
+ throw new Error(`page ${indices[copied.length]} did not copy`);
1221
+ copied.push(PDFPage.of(node, ref, dst));
1222
+ }
1223
+ copied.forEach((p) => dst.addPage(p));
1224
+ copyAcroFormForExtractedPages(src, dst, copier, extractedSrcPages);
1225
+ if (preserveCatalog) {
1226
+ const keptSrcPageRefs = new Set(extractedSrcPages.map((p) => p.ref));
1227
+ // Position within `indices` IS the new page index — both call sites
1228
+ // (deletePage's ascending `keep`, bytesForPrint's pre-sorted range) build
1229
+ // `indices` in output order already.
1230
+ const oldIndexToNewIndex = new Map();
1231
+ indices.forEach((oldIndex, newIndex) => oldIndexToNewIndex.set(oldIndex, newIndex));
1232
+ copyOutlineForExtractedPages(src, dst, copier, keptSrcPageRefs);
1233
+ copyInfoForExtractedPages(src, dst, copier);
1234
+ copyPageLabelsForExtractedPages(src, dst, copier, oldIndexToNewIndex);
1235
+ copyNamedDestinationsForExtractedPages(src, dst, copier, keptSrcPageRefs);
1236
+ copyViewerPreferencesForExtractedPages(src, dst, copier);
1237
+ }
1238
+ return dst;
1239
+ }
1240
+ /** Extract the given page indices into a brand-new document and save it. */
1241
+ export async function extractPages(bytes, indices, options = {}) {
1242
+ const dst = await extractPagesDoc(bytes, indices, options);
1243
+ return dst.save();
1244
+ }
1245
+ //# sourceMappingURL=pageOps.js.map