PDF Merge and Split skill for Codex
Right pages, right order, right size. Codex plans page-level PDF jobs before running them: it inspects encryption, forms and outlines, writes an explicit page plan, picks qpdf or pypdf for lossless work and Ghostscript only when you accept lossy downsampling, and rebuilds bookmarks and page labels. It then proves the result with page counts, qpdf --check and spot checks of the first and last page from each input.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- pdf-merge-split Installs to ~/.agents/skills/pdf-merge-split/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install pdf-merge-split The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project pdf-merge-split Installs to .agents/skills/pdf-merge-split/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/pdf-merge-split.zipunzip -o pdf-merge-split.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) pdf-merge-split PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $pdf-merge-split, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Performs page-level PDF surgery with qpdf and pypdf, covering merge, split by range, size or bookmark, reorder, rotate, insert and delete pages, rebuild bookmarks and page labels, and shrink files by lossless restructuring or Ghostscript downsampling, then verifies page counts and integrity. Use when asked to combine PDFs, split a large PDF into chapters or parts, extract or reorder pages, fix sideways pages, add a bookmark outline, or make a PDF small enough to email or upload.
Use it when
- PDFs must be combined, split, reordered or have pages removed
- Pages are sideways or a duplex scan needs interleaving
- A merged file needs a bookmark outline or proper page labels
- A PDF exceeds an upload or email size limit
Use something else when
- You need the text, tables or metadata inside the PDF: use pdf-processing
- The PDF must be tagged and accessible after the change: use pdf-accessibility
How to check the result
Page count equals the page plan, qpdf --check reports no errors, bookmark targets match the plan, and the first and last page of each input appear in the expected position.
The mistake it prevents
Running Ghostscript to shrink a file silently strips form fields, accessibility tags and signatures that the user needed.
Done well means the output has exactly the pages expected, in the expected order and
orientation, bookmarks point to the right pages, forms and links still work where they
should, and the file passes qpdf --check. Lossy compression only happens when the user
accepted the quality trade-off.
Tool choice
| Job | Default | Why |
|---|---|---|
| Merge, split, reorder, rotate, extract pages from the shell | qpdf | Fast, lossless, keeps structure, handles damaged files well |
| Same jobs inside a Python app or with per-page logic | pypdf (PdfWriter) | Pure Python, no binary dependency |
| Build or rewrite bookmarks, page labels | pypdf | Outline API; qpdf keeps but does not author outlines |
| Lossless size reduction | qpdf object streams + recompression | No quality loss, often 5 to 30% |
| Big size reduction (scans, photos) | Ghostscript pdfwrite with -dPDFSETTINGS | Downsamples images; lossy |
| Text extraction, tables | Not this skill: see pdf-processing |
Check what is installed before planning: qpdf --version, gs --version,
python3 -c "import pypdf; print(pypdf.__version__)". Installing any of them needs
network; ask if the sandbox blocks it. Note that PdfMerger was removed in pypdf 5; old
snippets that use it must be ported to PdfWriter.append.
Workflow
- [ ] 1. Inspect inputs: page counts, encryption, forms, outlines, sizes
- [ ] 2. Write the page plan (which pages from which file, in which order)
- [ ] 3. Run the operation into a new file
- [ ] 4. Verify counts, order, rotation, bookmarks, integrity
- [ ] 5. Report
1. Inspect
for f in *.pdf; do printf '%s pages=%s ' "$f" "$(qpdf --show-npages "$f")"; qpdf --show-encryption "$f" | head -1; done
qpdf --check input.pdf | tail -1
qpdf --json --json-key=acroform input.pdf | head -20 # form fields present?
du -h *.pdf
Stop and ask when: a file is encrypted with a user password (never guess or crack it),
inputs contain AcroForm fields with the same names (merging makes them share values), or
one input is damaged (qpdf --check errors). For owner-password-only files that open
without a password, qpdf --decrypt in.pdf out.pdf removes restrictions; confirm the
user has the right to do that.
2. Page plan
Write it down before running anything, especially for reorder jobs:
out.pdf = cover.pdf[1] + report.pdf[1-12] + appendix.pdf[all] + report.pdf[13-z]
qpdf range syntax: 1-5, z = last page, r1 = last page counted from the end,
z-1 = all pages reversed, 1-10:odd, 1-10:even, 3,1,2 explicit order, x2 exclude.
3. Operations
qpdf (shell)
# Merge whole files
qpdf --empty --pages a.pdf b.pdf c.pdf -- merged.pdf
# Merge selected ranges, in plan order
qpdf --empty --pages cover.pdf 1 report.pdf 1-12 appendix.pdf report.pdf 13-z -- out.pdf
# Extract pages 2-4 of one file ("." means the primary input)
qpdf input.pdf --pages . 2-4 -- extract.pdf
# Split into files of 10 pages: part-01-10.pdf, part-11-20.pdf, ...
qpdf --split-pages=10 input.pdf part-%d.pdf
# Rotate pages 1-2 clockwise by 90 degrees (relative), all others untouched
qpdf --rotate=+90:1-2 input.pdf rotated.pdf
# Delete page 5 (keep everything except it)
qpdf input.pdf --pages . 1-4,6-z -- without-5.pdf
# Interleave odd/even scans (fronts.pdf and backs.pdf scanned in reverse)
qpdf --empty --collate --pages fronts.pdf backs.pdf z-1 -- duplex.pdf
Using --empty drops document-level data of the first file (outline, metadata,
form). To keep the first file’s outline and metadata, use it as the primary input:
qpdf a.pdf --pages . b.pdf -- merged.pdf.
pypdf (Python)
from pypdf import PdfReader, PdfWriter
from pypdf.constants import PageLabelStyle
writer = PdfWriter()
writer.append("cover.pdf") # all pages
writer.append("report.pdf", pages=(0, 12), outline_item="Report") # pages are 0-based, end-exclusive
writer.append("appendix.pdf", outline_item="Appendix", import_outline=True)
# Roman numerals for the cover, then 1, 2, 3 for the body
writer.set_page_label(0, 0, style=PageLabelStyle.LOWERCASE_ROMAN)
writer.set_page_label(1, len(writer.pages) - 1, style=PageLabelStyle.DECIMAL, start=1)
writer.compress_identical_objects() # dedupe fonts/images shared across inputs
with open("out.pdf", "wb") as fh:
writer.write(fh)
Split by bookmark (one file per top-level chapter):
reader = PdfReader("book.pdf")
tops = [o for o in reader.outline if not isinstance(o, list)] # nested lists are children
starts = [reader.get_destination_page_number(o) for o in tops] + [len(reader.pages)]
for i, item in enumerate(tops):
w = PdfWriter()
w.append(reader, pages=(starts[i], starts[i + 1]))
safe = "".join(c if c.isalnum() or c in " -_" else "_" for c in item.title).strip()
w.write(f"{i + 1:02d} {safe}.pdf")
Split by size (for upload limits): add pages to a writer until the serialized size would
pass the limit, then start a new file. Measure with io.BytesIO after write; shared fonts
mean each part carries its own copy, so parts do not sum to the original size.
Rotate one page: page.rotate(90) (multiples of 90, clockwise). Check page.rotation
first; a page that looks sideways may already carry /Rotate 270.
Build an outline from scratch:
parent = writer.add_outline_item("Part I", 0)
writer.add_outline_item("Chapter 1", 2, parent=parent)
writer.add_outline_item("Chapter 2", 9, parent=parent)
Compression decision
| Input | Command | Expected |
|---|---|---|
| Digital (text, vector) | Lossless qpdf below | 5 to 30% smaller, identical rendering |
| Merged from many files | pypdf compress_identical_objects() then qpdf | Removes duplicate fonts/images |
| Scans or photo-heavy | Ghostscript /ebook (150 dpi) | 50 to 90% smaller, visible softening |
| Must stay print quality | Ghostscript /printer (300 dpi) | Smaller only if images are above 300 dpi |
# Lossless
qpdf --object-streams=generate --compress-streams=y --recompress-flate \
--compression-level=9 input.pdf smaller.pdf
# Lossy (ask first): /screen 72 dpi, /ebook 150 dpi, /printer 300 dpi, /prepress 300 dpi colour-managed
gs -sDEVICE=pdfwrite -dCompatibilityLevel=1.7 -dPDFSETTINGS=/ebook \
-dNOPAUSE -dBATCH -dQUIET -sOutputFile=small.pdf input.pdf
Ghostscript rewrites the whole file: it can drop or alter form fields, tags (accessibility),
attachments, digital signatures and PDF/A conformance. Never use it on signed, tagged or
archival documents without telling the user what will be lost. Add --linearize to the
final qpdf step when the file will be viewed in browsers (“fast web view”).
Verify
qpdf --show-npages out.pdf # equals the sum in the page plan
qpdf --check out.pdf | tail -1 # no errors
from pypdf import PdfReader
r = PdfReader("out.pdf")
n = len(r.pages)
for i in sorted({0, 1, 2, n - 3, n - 2, n - 1} & set(range(n))):
p = r.pages[i]
print(i + 1, p.rotation, (p.extract_text() or "")[:60].replace("\n", " "))
print([o.title for o in r.outline if not isinstance(o, list)], r.page_labels[:5])
Spot-check the first and last page from each input in the output (text snippet or
rendered thumbnail via pdftoppm -r 40 -png -f N -l N out.pdf thumb). Click-through
bookmarks by checking get_destination_page_number against the plan. For compression,
report before and after sizes and render one image-heavy page at 100% to judge quality.
Gotchas
- Page numbers in qpdf ranges are 1-based; in pypdf they are 0-based with exclusive ends.
- Merging forms with identical field names links their values; rename fields first or flatten them (see pdf-forms).
- Bookmarks and internal links of non-primary inputs are lost with
qpdf --empty; use pypdfappend(..., import_outline=True)when outlines matter. - Mixed page sizes (A4 and Letter, landscape inserts) are valid; do not “fix” them unless asked.
- Splitting a signed PDF invalidates the signature in every part.
- Output file names from user data need sanitizing (slashes, colons in chapter titles).
Output
## PDF job: board-pack-2026-11.pdf
- Plan: cover[1] + minutes[1-6] + financials[all] + appendix[2-z]; 48 pages
- Bookmarks: 4 top-level, verified targets; page labels i, 1-47
- Size: 38.2 MB -> 6.1 MB (Ghostscript /ebook, approved); forms: none; tags: none
- Checks: qpdf --check clean, first/last page of each input confirmed What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "PDF Merge and Split"
short_description: "Right pages, right order, right size"
default_prompt: "$pdf-merge-split Merge these 14 signed contracts into one PDF with a bookmark per contract" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.