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.

  • Documents & Office
  • $pdf-merge-split
  • 210 lines
  • 2 files
  • ~2,150 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- pdf-merge-split

Installs to ~/.agents/skills/pdf-merge-split/ for every project on this machine.

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

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.

~/.agents/skills/pdf-merge-split/SKILL.md

--- name: pdf-merge-split description: Performs page-level PDF surgery with qpdf and pypdf, covering merge, split by range, size or bookmark, reorder, rotate, insert and delete pages,… license: MIT ---

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

JobDefaultWhy
Merge, split, reorder, rotate, extract pages from the shellqpdfFast, lossless, keeps structure, handles damaged files well
Same jobs inside a Python app or with per-page logicpypdf (PdfWriter)Pure Python, no binary dependency
Build or rewrite bookmarks, page labelspypdfOutline API; qpdf keeps but does not author outlines
Lossless size reductionqpdf object streams + recompressionNo quality loss, often 5 to 30%
Big size reduction (scans, photos)Ghostscript pdfwrite with -dPDFSETTINGSDownsamples images; lossy
Text extraction, tablesNot 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

InputCommandExpected
Digital (text, vector)Lossless qpdf below5 to 30% smaller, identical rendering
Merged from many filespypdf compress_identical_objects() then qpdfRemoves duplicate fonts/images
Scans or photo-heavyGhostscript /ebook (150 dpi)50 to 90% smaller, visible softening
Must stay print qualityGhostscript /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 pypdf append(..., 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.yamlgenerated from this page

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.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem