omitly-mcp 5.3.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.
- package/README.md +34 -2
- package/THIRD-PARTY-NOTICES.md +175 -10
- package/dist/annotations.js +8 -0
- package/dist/annotations.js.map +1 -0
- package/dist/edit-tools.js +993 -0
- package/dist/edit-tools.js.map +1 -0
- package/dist/index.js +58 -11
- package/dist/index.js.map +1 -1
- package/dist/paths.js +102 -1
- package/dist/paths.js.map +1 -1
- package/dist/pdf-edit/compose.js +175 -0
- package/dist/pdf-edit/compose.js.map +1 -0
- package/dist/pdf-edit/docFacts.js +587 -0
- package/dist/pdf-edit/docFacts.js.map +1 -0
- package/dist/pdf-edit/info.js +50 -0
- package/dist/pdf-edit/info.js.map +1 -0
- package/dist/pdf-edit/merge.js +78 -0
- package/dist/pdf-edit/merge.js.map +1 -0
- package/dist/pdf-edit/pageOps.js +1245 -0
- package/dist/pdf-edit/pageOps.js.map +1 -0
- package/dist/pdf-edit/pageSpec.js +85 -0
- package/dist/pdf-edit/pageSpec.js.map +1 -0
- package/dist/pdf-edit/refusals.js +108 -0
- package/dist/pdf-edit/refusals.js.map +1 -0
- package/dist/pdf-edit/worker.js +364 -0
- package/dist/pdf-edit/worker.js.map +1 -0
- package/dist/seal.js +15 -0
- package/dist/seal.js.map +1 -1
- package/dist/skill-prompt.js +108 -0
- package/dist/skill-prompt.js.map +1 -0
- package/dist/usage.js +3 -1
- package/dist/usage.js.map +1 -1
- package/package.json +4 -2
- package/skill/SKILL.md +2 -2
- package/wasm/leakcheck_wasm.d.ts +2 -1
- package/wasm/leakcheck_wasm.js +2 -1
- package/wasm/leakcheck_wasm_bg.wasm +0 -0
|
@@ -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
|