PDF manipulation (merge / split / extract)
New in v1.4.0. Combine, slice, and reorder existing PDFs with a production-safe page-tree API. Each operation rebuilds a clean object graph rather than patching bytes in place — inherited page attributes are resolved, dangling references are pruned, and the result is a fresh, well-formed PDF.
TL;DR#
import { mergePdfs, splitPdf, extractPages } from 'pdfnative';
import { readFileSync, writeFileSync } from 'node:fs';
const a = readFileSync('cover.pdf');
const b = readFileSync('body.pdf');
// Merge
writeFileSync('combined.pdf', mergePdfs([a, b]));
// Split into page ranges (0-based, end inclusive; end defaults to start)
const [intro, rest] = splitPdf(b, [
{ start: 0, end: 1 }, // pages 0–1
{ start: 2, end: 9 }, // pages 2–9
]);
// Extract specific pages (0-based)
writeFileSync('selected.pdf', extractPages(b, [0, 3, 7]));
All three accept and return Uint8Array PDF bytes.
New in v1.6.0. Encrypted sources are now decrypted on ingest — pass a password (see Encrypted sources) — the rebuilt output can be re-encrypted with
encrypt(see Re-encrypting the output), and there are constant-memory streaming variants (streamMergedPdfs/streamSplitPdf/streamExtractPages, see Streaming merge & split).
mergePdfs(sources, options?)#
Concatenates multiple PDFs into one, in order.
function mergePdfs(
sources: readonly PdfSourceInput[],
options?: MergeOptions,
): Uint8Array;
// Raw bytes, or bytes + password for an encrypted source (v1.6.0):
type PdfSourceInput = Uint8Array | { bytes: Uint8Array; password?: string };
interface MergeOptions {
/** Strip digital-signature widgets/fields from the result. Default false. */
dropSignatures?: boolean;
/** Strip all annotations (links, comments, …) from the result. Default false. */
dropAnnotations?: boolean;
/** Password used to decrypt encrypted sources (default for every source). v1.6.0 */
password?: string;
/**
* Maximum size, in bytes, of the assembled output. The operation throws as
* soon as the copied object graph would exceed this limit — even mid-copy,
* before an oversized stream is materialised — so a malicious or accidentally
* huge source cannot exhaust process memory. Defaults to **256 MiB**; pass
* `Infinity` to disable (not recommended for untrusted input).
*/
maxOutputSize?: number;
}
- Up to 50 source documents per call (
MAX_MERGE_SOURCES). - Output is hard-capped at 256 MiB by default (
maxOutputSize) so a hostile source cannot OOM the process; raise it for legitimately large merges. - Page resources (
/Font,/XObject, …) are deep-copied into a fresh object-number space, so there are no collisions between sources. - Inherited attributes (
/MediaBox,/CropBox,/Rotate,/Resources) are resolved from each page's ancestors and folded onto the page, so pages keep their geometry even when the original relied on inheritance.
Merging a signed PDF invalidates its signature (the bytes change). Pass
dropSignatures: trueto remove the now-meaningless signature fields.
splitPdf(source, ranges)#
Splits one PDF into several, one output per range.
function splitPdf(
source: Uint8Array,
ranges: readonly PageRange[],
options?: MergeOptions,
): Uint8Array[];
interface PageRange {
/** 0-based first page (inclusive). */
start: number;
/** 0-based last page (inclusive). Defaults to `start` (single page). */
end?: number;
}
Ranges may overlap and need not be contiguous. Each output is an independent,
fully-formed PDF. options (including maxOutputSize) applies to every emitted
document.
extractPages(source, indices)#
Builds a new PDF from an explicit list of 0-based page indices, in the order given — handy for reordering or cherry-picking.
function extractPages(
source: Uint8Array,
indices: readonly number[],
options?: MergeOptions,
): Uint8Array;
extractPages(pdf, [4, 0, 1]); // page 5 first, then 1, then 2
options (including maxOutputSize and dropAnnotations) is honoured here too.
Encrypted sources#
Since v1.6.0, mergePdfs / splitPdf / extractPages decrypt encrypted
sources transparently (Standard Security Handler — RC4, AES-128, AES-256). Give
the password either per-source or as a shared default:
// Per-source password (only that source is encrypted):
mergePdfs([cover, { bytes: encryptedBody, password: 'secret' }]);
// Shared password for every source, via options:
mergePdfs([a, b], { password: 'secret' });
// splitPdf / extractPages take the password on options:
splitPdf(encrypted, [{ start: 0, end: 2 }], { password: 'secret' });
A wrong or missing password throws PdfPasswordError; an unsupported handler
(e.g. public-key) throws PdfEncryptionUnsupportedError. The rebuilt output is
unencrypted unless you set encrypt (below). See the
reader guide for openPdf(bytes, { password }).
Re-encrypting the output#
Since v1.6.0, MergeOptions.encrypt re-encrypts the rebuilt document —
closing the round trip: open encrypted → edit → re-secure. It takes the same
shape as the document builder's encryption option:
import { mergePdfs, splitPdf } from 'pdfnative';
// Merge, then protect the result (AES-256):
const secured = mergePdfs([a, b], {
encrypt: {
ownerPassword: 'owner-secret', // required, non-empty
userPassword: 'user-secret', // optional (empty = opens freely)
algorithm: 'aes256', // 'aes128' (V4/R4, default) | 'aes256' (V5/R6)
permissions: { print: true, copy: false, modify: false },
},
});
// Change a document's password: decrypt on ingest, re-encrypt on output.
const rekeyed = mergePdfs(
[{ bytes: oldPdf, password: 'old-password' }],
{ encrypt: { ownerPassword: 'new-password', algorithm: 'aes256' } },
);
// Works identically on splitPdf / extractPages and the streaming variants.
splitPdf(src, [{ start: 0, end: 4 }], { encrypt: { ownerPassword: 'o' } });
Notes:
- AES only — new output is never RC4-encrypted (legacy RC4 is read-only).
- CSPRNG required — a missing Web Crypto secure random source throws
before any copying starts (encryption keys are never derived from
Math.random()). - Fresh keys — no key material, permissions, or passwords from any source document are reused; the caller states the new protection explicitly.
- Non-deterministic output — random IVs/salts and a random document
/IDmean encrypted output is not byte-reproducible (the unencrypted path keeps its content-addressed deterministic/ID).
Streaming merge & split#
For large documents, the streaming variants emit the result as fixed-size
chunks while holding only the cross-reference offsets and small object dicts in
memory — stream payloads flow straight from the (in-memory) source bytes, so the
fully-joined document is never materialised. Each is byte-identical to its
buffered counterpart (except with encrypt, where fresh random IVs make each
invocation structurally — not byte — identical) and composes with
streamToFile:
import { streamMergedPdfs, streamSplitPdf, streamToFile } from 'pdfnative';
// Constant-memory merge straight to disk:
await streamToFile(streamMergedPdfs([a, b]), 'combined.pdf');
// Split: one output stream per range (drain each fully before advancing):
for await (const part of streamSplitPdf(body, [{ start: 0, end: 1 }, { start: 2, end: 9 }])) {
await streamToFile(part.pdf, `part-${part.index}.pdf`);
}
StreamMergeOptions adds chunkSize (1 KiB–16 MiB, default 64 KiB) on top of
MergeOptions. For multi-gigabyte merges pass maxOutputSize: Infinity — safe
with streaming because output bytes are never buffered (the sources themselves
are still in-memory Uint8Arrays).
For freshly built (not merged) documents, combine the true streaming builders
with streamToFile so the binary never fully materialises:
import { buildDocumentPDFStreamTrue, streamToFile } from 'pdfnative';
await streamToFile(buildDocumentPDFStreamTrue(params), 'report.pdf');
Updating metadata in place#
New in v1.7.0.
PdfModifier.updateMetadata()rewrites a document's metadata as a non-destructive incremental revision — no re-serialisation, existing signatures over earlier revisions stay intact.
import { openPdf, createModifier } from 'pdfnative';
const modifier = createModifier(openPdf(bytes));
modifier.updateMetadata({
title: 'Q3 report (final)',
author: 'Finance',
keywords: 'quarterly, revenue',
// modDate: new Date('2026-08-21T00:00:00Z'), // pin for reproducible bytes
});
const updated = modifier.save();
Only the fields you pass change; the rest of /Info is preserved. /ModDate
is always refreshed (pass modDate to pin it). When the document carries an
XMP packet, it is resynchronised in the same revision — dc:title,
dc:creator, dc:description, pdf:Keywords, xmp:ModifyDate and
xmp:MetadataDate — while xmp:CreateDate and any pdfaid:* conformance
claim are preserved, so Info↔XMP parity holds for PDF/A documents.
Safety & limits#
- Encrypted input is decrypted on ingest (v1.6.0) when a valid password is
supplied; a wrong/missing password throws
PdfPasswordError. Output is unencrypted unlessencryptre-protects it (AES-128/AES-256, CSPRNG required, fresh keys only). - Annotations are filtered to URI
/Linkonly during the rebuild (plus the full strip whendropAnnotationsis set), so interactive form/JS annotations don't leak across documents. - Bounded-depth copy. The object-graph copy is capped at a fixed recursion depth, so a pathologically nested or adversarial source can never overflow the stack — it throws a descriptive error instead.
- Bounded output size. Cumulative output is capped at 256 MiB by default
(
maxOutputSize), checked before each stream is materialised, so a source full of multi-gigabyte objects is rejected rather than allowed to exhaust memory. Tune or disable (Infinity) per call. - Deterministic output. Every unencrypted result carries a
content-addressed trailer
/ID(ISO 32000-1 §7.5.5) derived from the assembled bytes, so the same inputs always produce a byte-identical PDF — friendly to caching, diffing, and reproducible builds. (Withencrypt, the/IDis the encryption state's random document ID, as the file key is bound to it.) - Full rebuild, not in-place surgery. The clean-graph approach trades a little speed for correctness and is safe to run on third-party PDFs.
- Page boxes survive (v1.7.0). Declared
/TrimBox,/BleedBox,/ArtBox,/CropBoxand/UserUnitentries are preserved throughmergePdfs/splitPdf/extractPages(they were dropped by the rebuild before 1.7.0) — so print-ready sources keep their geometry. See the print production guide.
How it works#
src/parser/pdf-pagetree.ts
opens each source with the built-in PDF reader, walks the
page tree, and deep-copies every kept page plus its transitive object graph into
a new document (obj 1 = Catalog, obj 2 = Pages root, obj 3+ = the copied
graph). The copy is memoised per reader and cycle-safe, and all values
are serialised binary-safe (Latin-1) so embedded fonts and image streams survive
intact.
See also#
- Architecture — the parser/reader internals
- Streaming output —
streamToFile - Outlines & page labels
- CHANGELOG