Merge and extract PDF pages with PDFtk and Python
PDFtk’s cat operation can join PDFs, extract a page range, or reorder pages without rasterizing
them. This walkthrough uses PDFtk Java to merge two small sample documents, select pages from the
result, and automate merging in Python with a nonzero exit status on failure and no replacement of
existing output files.
System requirements
The commands below were tested on Linux with Bash 5.3.15, OpenJDK 21.0.12.1, Python 3.14.7,
qpdf 12.4.1, and Poppler 26.08.0. Have java, python3, qpdf, pdftotext, curl, and
sha256sum on your PATH before starting. These are the tested versions, not a claim that every
older version works. Poppler supplies pdftotext; qpdf checks PDF structure independently of PDFtk.
Use trusted, unencrypted PDFs for this local example. A parser accepting a PDF does not establish that it is harmless, visually intact, or free of active content. Run untrusted documents in an appropriately isolated environment, not through this script as a security filter.
Installing PDFtk
PDFtk Java is a port of PDFtk, not the original native PDFtk Server executable. Its
versioned installation guide
lists distribution packages and a standalone JAR containing its dependencies. We use that JAR so
every invocation selects version 3.3.3 rather than whichever pdftk a package manager provides.
This walkthrough uses Linux shell commands; it does not cover macOS or Windows installers.
Paste this block in Bash from a writable directory. It creates a new pdf-workflow directory and
leaves your shell in its original directory. An existing directory, failed download, or checksum
mismatch stops the block. Do not delete an existing project to make it succeed.
(
set -eu
mkdir pdf-workflow
cd pdf-workflow
curl -fsSLo pdftk-java-3.3.3-all.jar \
https://gitlab.com/api/v4/projects/5024297/packages/generic/pdftk-java/v3.3.3/pdftk-all.jar
printf '%s %s\n' \
a694d49bd03e1edd4c23b3ba808bc221eb8a8ccfe7bfd2a0a884b2b2fb425188 \
pdftk-java-3.3.3-all.jar | sha256sum -c -
java -Xms16m -Xmx256m -XX:ActiveProcessorCount=2 -XX:-UsePerfData \
-Djava.io.tmpdir="$PWD" -Duser.home="$PWD" \
-jar pdftk-java-3.3.3-all.jar --version
)
The hash pins the downloaded bytes; it is not a publisher signature. The version output should identify PDFtk Java 3.3.3. A failed setup can leave the new directory and downloaded file behind; inspect them before choosing a fresh location.
Basic PDF operations
For a reproducible input, save the following as pdf-workflow/make_samples.py. It uses only
Python’s standard library to create a two-page PDF labeled ALPHA ONE and ALPHA TWO, and a
one-page PDF labeled BETA ONE. It refuses existing sample filenames rather than replacing them.
from pathlib import Path
def write_pdf(path, labels):
page_ids = [4 + 2 * index for index in range(len(labels))]
kids = ' '.join(f'{page_id} 0 R' for page_id in page_ids)
objects = [
b'<< /Type /Catalog /Pages 2 0 R >>',
f'<< /Type /Pages /Count {len(labels)} /Kids [{kids}] >>'.encode(),
b'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>',
]
for page_id, label in zip(page_ids, labels):
stream = f'BT /F1 24 Tf 40 180 Td ({label}) Tj ET\n'.encode('ascii')
objects.append(
f'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 360 240] '
f'/Resources << /Font << /F1 3 0 R >> >> '
f'/Contents {page_id + 1} 0 R >>'.encode()
)
objects.append(f'<< /Length {len(stream)} >>\nstream\n'.encode()
+ stream + b'endstream')
data = bytearray(b'%PDF-1.4\n')
offsets = [0]
for object_id, payload in enumerate(objects, start=1):
offsets.append(len(data))
data.extend(f'{object_id} 0 obj\n'.encode() + payload + b'\nendobj\n')
xref = len(data)
data.extend(f'xref\n0 {len(offsets)}\n0000000000 65535 f \n'.encode())
for offset in offsets[1:]:
data.extend(f'{offset:010d} 00000 n \n'.encode())
data.extend(f'trailer\n<< /Size {len(offsets)} /Root 1 0 R >>\n'
f'startxref\n{xref}\n%%EOF\n'.encode())
with path.open('xb') as output:
output.write(data)
write_pdf(Path('input-a.pdf'), ['ALPHA ONE', 'ALPHA TWO'])
write_pdf(Path('input-b.pdf'), ['BETA ONE'])
Merging PDFs
Run this from the same parent directory as setup. A and B are input handles; A1-end B1-end
includes every page of A followed by every page of B. The
PDFtk manual documents page ranges and
handles. With your own documents, replace the input names and check their page counts first.
(
set -eu
cd pdf-workflow
python3 make_samples.py
java -Xms16m -Xmx256m -XX:ActiveProcessorCount=2 -XX:-UsePerfData \
-Djava.io.tmpdir="$PWD" -Duser.home="$PWD" \
-jar pdftk-java-3.3.3-all.jar A=input-a.pdf B=input-b.pdf \
cat A1-end B1-end output combined.pdf dont_ask
)
The low-level commands deliberately use dont_ask: they run without prompts and overwrite their
named outputs. They are sequential sample commands, not safe publication wrappers, and a failure
can leave a partial output. Do not point them at files you need to preserve. The Python merger
below uses a different, no-replacement policy. Rerunning the whole merge block stops at the
existing sample files; it does not continue using stale input after that failure.
Splitting PDFs
Extract and reorder pages using the merged PDF produced above. Pages are numbered from one.
cat 3 1 selects its third page followed by its first; cat 1-2 would select a contiguous range.
(
set -eu
cd pdf-workflow
java -Xms16m -Xmx256m -XX:ActiveProcessorCount=2 -XX:-UsePerfData \
-Djava.io.tmpdir="$PWD" -Duser.home="$PWD" \
-jar pdftk-java-3.3.3-all.jar combined.pdf cat 3 1 output selected.pdf dont_ask
)
Check both actual outputs, not just the exit status of the producer:
(
set -eu
cd pdf-workflow
qpdf --check combined.pdf >/dev/null
test "$(qpdf --show-npages combined.pdf)" = 3
printf 'Merged pages:\n'
pdftotext -layout combined.pdf -
qpdf --check selected.pdf >/dev/null
test "$(qpdf --show-npages selected.pdf)" = 2
printf 'Selected pages:\n'
pdftotext -layout selected.pdf -
)
The merged text should be ALPHA ONE, ALPHA TWO, then BETA ONE; the selected text should be
BETA ONE, then ALPHA ONE. Open both PDFs in a viewer to check appearance. These text and
structure checks do not prove that arbitrary documents preserve forms, annotations, signatures,
or every visual detail. Image-only scans can have no extractable text.
Optimizing PDF file size
PDFtk’s compress option restores page
stream compression; it is not an image downsampler or a guarantee of a smaller PDF. Merging also
does not “standardize” a document. Image compression requires a separate rewriting workflow with
its own quality checks, so there is no Ghostscript conversion step in this example.
Integration example
Save this as pdf-workflow/merge_pdfs.py. It accepts a JAR, a new output filename, and one or more
inputs. Put -- before the JAR argument, as in the invocation below, so Python’s argument parser
treats leading hyphens in filenames literally. The script resolves input paths before passing them
as separate subprocess arguments; it does not build a shell command from your filenames.
This local Linux script rejects a combined input size above 100 MiB, checks each input with qpdf,
and stages the merged PDF in the destination directory. After checking the staged PDF, it uses
os.link to publish without replacing an
existing destination. The filesystem must support hard links. It does not promise crash durability
or safe operation against someone maliciously changing the directory or inputs during processing.
import argparse
import os
import subprocess
import sys
import tempfile
from pathlib import Path
def main():
parser = argparse.ArgumentParser(description='Merge PDFs without replacing an output.')
parser.add_argument('jar', type=Path)
parser.add_argument('output', type=Path)
parser.add_argument('inputs', type=Path, nargs='+')
args = parser.parse_args()
destination = args.output.absolute()
if os.path.lexists(destination):
raise FileExistsError(f'Output already exists: {destination}')
jar = args.jar.resolve(strict=True)
inputs = [path.resolve(strict=True) for path in args.inputs]
if not jar.is_file() or any(not path.is_file() for path in inputs):
raise ValueError('The JAR and inputs must be regular files.')
if sum(path.stat().st_size for path in inputs) > 100 * 1024 * 1024:
raise ValueError('Combined inputs exceed 100 MiB.')
for path in inputs:
subprocess.run(['qpdf', '--check', str(path)], check=True,
capture_output=True, timeout=60)
with tempfile.TemporaryDirectory(prefix='.pdf-merge-', dir=destination.parent) as staging:
staged = Path(staging) / 'merged.pdf'
command = [
'java', '-Xms16m', '-Xmx256m', '-XX:ActiveProcessorCount=2', '-XX:-UsePerfData',
f'-Djava.io.tmpdir={staging}', f'-Duser.home={staging}', '-jar', str(jar),
*map(str, inputs), 'cat', 'output', str(staged), 'dont_ask',
]
subprocess.run(command, check=True, capture_output=True, timeout=60)
subprocess.run(['qpdf', '--check', str(staged)], check=True,
capture_output=True, timeout=60)
os.link(staged, destination)
print(f'Created {destination.name}')
if __name__ == '__main__':
try:
main()
except (OSError, ValueError, subprocess.SubprocessError) as error:
print(f'Cannot merge PDFs: {error}', file=sys.stderr)
sys.exit(1)
Run it against the same sample inputs. The output has three pages in the same order as
combined.pdf:
(
set -eu
cd pdf-workflow
python3 merge_pdfs.py -- pdftk-java-3.3.3-all.jar merged-from-python.pdf input-a.pdf input-b.pdf
qpdf --check merged-from-python.pdf >/dev/null
test "$(qpdf --show-npages merged-from-python.pdf)" = 3
pdftotext -layout merged-from-python.pdf -
)
Missing files, invalid PDFs, oversized inputs, a missing executable, a timeout, or failed
publication return a nonzero status without reporting a successful merge. A second invocation refuses
merged-from-python.pdf and leaves its bytes unchanged. Preflight failures create no staged output;
ordinary failures during processing clean up the staging directory. Forced termination can leave
that private directory behind. Each native subprocess has a 60-second deadline, not a 60-second
deadline for the whole merge. qpdf warnings are treated as failures here; inspect your source rather
than silently accepting a repaired document.
Troubleshooting common issues
Memory-related errors
The 100 MiB check is an input admission policy, not a RAM estimate. PDF complexity, decompression,
and Java’s non-heap allocations matter too. -Xmx256m caps the Java heap, not total process memory,
and does not guarantee that an accepted input will finish. There is no automatic batching: an
oversized document is rejected, not put in an oversized batch or preceded by an empty batch.
File access errors
Check that the inputs and JAR are readable and the output’s parent directory exists and is writable. Choose a new destination when one exists; the Python script does not delete it, even if another step fails. Do not broadly relax file permissions just to get a merge working. For a qpdf or PDFtk failure, run its command on the offending input locally to inspect the diagnostic.
Process one job at a time to start. Smaller input files alone do not establish bounded memory or higher throughput; add concurrency only after measuring your own workload and available resources.
Security considerations
Secure PDF handling
PDFtk’s encrypt_128bit mode
uses legacy RC4, not modern AES. Do not use it to protect confidential documents. An owner password
alone does not require a password to open a PDF; a user password is
the separate password-on-open mechanism. Consult the
password options when
maintaining a legacy workflow, rather than copying passwords into command arguments or scripts.
PROMPT is available for interactive password entry; this noninteractive merger does not handle
password-protected inputs.
Manage file permissions
PDF printing and modification restrictions depend on the reader honoring them. They are not an access-control boundary. Restrict access to the files themselves; the merge script does not remove sensitive text or metadata, or set an organization’s storage and sharing policy.
Conclusion
Use explicit page ranges when order matters, inspect the resulting document, and decide whether replacing an output is acceptable before automating the command. The PDFtk Java project is the place to explore additional operations once this small workflow fits your needs.
