Home  ›  Guides  ›  PDF manipulation

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;
}

Merging a signed PDF invalidates its signature (the bytes change). Pass dropSignatures: true to 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:

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#

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#