Manipulate PDFs one file or a small batch at a time with pypdf and pdfplumber: merge, split, rotate, reorder, extract text and tables, fill AcroForm fields, watermark, and compress; use when a PDF needs a concrete edit or extraction and the job fits in a script, not a processing pipeline.
./install.sh pdf-processing-automationname: pdf_processing_automation description: "Manipulate PDFs one file or a small batch at a time with pypdf and pdfplumber: merge, split, rotate, reorder, extract text and tables, fill AcroForm fields, watermark, and compress; use when a PDF needs a concrete edit or extraction and the job fits in a script, not a processing pipeline." metadata: { "includeInPrompt": true }
Do the everyday PDF jobs directly: join invoices into one file, pull pages 3 to 5 out as a separate file, fix a sideways scan rotation, pull the text or a table out of a report, stamp DRAFT on each page, fill the fields in a form PDF, or shrink a file before sending it. Each job is one input file or a short list of files, one output file, and a check that the output page count and content match the request.
Scope line: document-processing-pipeline is document processing at scale (ingest, OCR, PII redaction, archival). This skill owns everyday single-file and small-batch PDF manipulation: merge, split, rotate, reorder, extract text and tables, fill forms, watermark, compress, with the libraries pypdf and pdfplumber.
Verified at drafting time (2026-10-13): pypdf 6.20.1 and pdfplumber 0.11.10 on PyPI, both installed in a clean venv and exercised. Method names quoted below were confirmed by introspection on the installed packages: pypdf PdfReader, PdfWriter, writer.add_page, writer.append, page.extract_text, page.rotate, page.merge_page, reader.get_fields, writer.update_page_form_field_values, page.compress_content_streams; pdfplumber pdfplumber.open, page.extract_text, page.extract_tables. Re-check versions and names before relying on them in a new environment.
info command from the tool in step 3 on every input. Record page count, per-page rotation, whether the file is encrypted, and the form field names if any. Most bad outputs come from a wrong assumption at this step: a file with 4 pages when you expected 3, a page already rotated 90 degrees, or a form with no fillable fields.extract_text returns empty or near-empty text for a page that visibly has words, the file is a scanned image. Stop and route it through OCR first; that work belongs to document-processing-pipeline.python
#!/usr/bin/env python3
"""Small-batch PDF tool built on pypdf. No OCR, no image recompression.
Usage:
python3 pdf_tool.py make-fixture OUT.pdf "Page one text" "Page two text"
python3 pdf_tool.py merge OUT.pdf IN1.pdf IN2.pdf [IN3.pdf ...]
python3 pdf_tool.py split IN.pdf OUT_DIR
python3 pdf_tool.py rotate IN.pdf OUT.pdf --page 0 --angle 90
python3 pdf_tool.py reorder IN.pdf OUT.pdf --order 2,0,1
python3 pdf_tool.py extract-text IN.pdf
python3 pdf_tool.py info IN.pdf
python3 pdf_tool.py compress IN.pdf OUT.pdf
Page numbers on the command line are 0-based. Angles must be a multiple
of 90. Requires pypdf (tested with 6.20.1).
"""
import argparse
import os
import sys
from pypdf import PdfReader, PdfWriter
from pypdf.generic import DecodedStreamObject, DictionaryObject, NameObject
def make_fixture(args):
writer = PdfWriter()
for text in args.texts:
page = writer.add_blank_page(width=612, height=792)
escaped = text.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)")
content = DecodedStreamObject()
content.set_data(f"BT /F1 18 Tf 72 720 Td ({escaped}) Tj ET".encode())
font = DictionaryObject()
font.update({
NameObject("/Type"): NameObject("/Font"),
NameObject("/Subtype"): NameObject("/Type1"),
NameObject("/BaseFont"): NameObject("/Helvetica"),
})
fonts = DictionaryObject()
fonts[NameObject("/F1")] = font
resources = DictionaryObject()
resources[NameObject("/Font")] = fonts
page[NameObject("/Resources")] = resources
page[NameObject("/Contents")] = content
with open(args.out, "wb") as fh:
writer.write(fh)
print(f"wrote {args.out} pages={len(args.texts)}")
return 0
def merge(args):
writer = PdfWriter()
total = 0
for path in args.inputs:
reader = PdfReader(path)
for page in reader.pages:
writer.add_page(page)
total += 1
with open(args.out, "wb") as fh:
writer.write(fh)
print(f"wrote {args.out} pages={total}")
return 0
def split(args):
reader = PdfReader(args.input)
os.makedirs(args.out_dir, exist_ok=True)
base = os.path.splitext(os.path.basename(args.input))[0]
for i, page in enumerate(reader.pages):
writer = PdfWriter()
writer.add_page(page)
out = os.path.join(args.out_dir, f"{base}-p{i}.pdf")
with open(out, "wb") as fh:
writer.write(fh)
print(f"wrote {out} pages=1")
print(f"split {args.input} into {len(reader.pages)} files")
return 0
def rotate(args):
if args.angle % 90 != 0:
print("angle must be a multiple of 90", file=sys.stderr)
return 2
reader = PdfReader(args.input)
if not 0 <= args.page < len(reader.pages):
print(f"page {args.page} out of range 0..{len(reader.pages) - 1}", file=sys.stderr)
return 2
writer = PdfWriter()
for i, page in enumerate(reader.pages):
if i == args.page:
page.rotate(args.angle)
writer.add_page(page)
with open(args.out, "wb") as fh:
writer.write(fh)
print(f"wrote {args.out} pages={len(reader.pages)} rotated_page={args.page} angle={args.angle}")
return 0
def reorder(args):
order = [int(x) for x in args.order.split(",") if x.strip() != ""]
reader = PdfReader(args.input)
if sorted(order) != list(range(len(reader.pages))):
print(f"order must be a permutation of 0..{len(reader.pages) - 1}", file=sys.stderr)
return 2
writer = PdfWriter()
for idx in order:
writer.add_page(reader.pages[idx])
with open(args.out, "wb") as fh:
writer.write(fh)
print(f"wrote {args.out} pages={len(order)} order={order}")
return 0
def extract_text(args):
reader = PdfReader(args.input)
for i, page in enumerate(reader.pages):
text = page.extract_text() or ""
print(f"--- page {i} ---")
print(text)
return 0
def info(args):
reader = PdfReader(args.input)
print(f"file={args.input} pages={len(reader.pages)} encrypted={reader.is_encrypted}")
for i, page in enumerate(reader.pages):
print(f"page {i}: rotation={page.rotation} mediabox={list(page.mediabox)}")
fields = reader.get_fields()
print(f"form_fields={sorted(fields.keys()) if fields else []}")
return 0
def compress(args):
reader = PdfReader(args.input)
writer = PdfWriter()
for page in reader.pages:
added = writer.add_page(page)
added.compress_content_streams()
with open(args.out, "wb") as fh:
writer.write(fh)
print(f"in_bytes={os.path.getsize(args.input)} out_bytes={os.path.getsize(args.out)}")
print(f"wrote {args.out} pages={len(reader.pages)}")
return 0
def main():
ap = argparse.ArgumentParser()
sub = ap.add_subparsers(dest="cmd", required=True)
p = sub.add_parser("make-fixture")
p.add_argument("out")
p.add_argument("texts", nargs="+")
p.set_defaults(fn=make_fixture)
p = sub.add_parser("merge")
p.add_argument("out")
p.add_argument("inputs", nargs="+")
p.set_defaults(fn=merge)
p = sub.add_parser("split")
p.add_argument("input")
p.add_argument("out_dir")
p.set_defaults(fn=split)
p = sub.add_parser("rotate")
p.add_argument("input")
p.add_argument("out")
p.add_argument("--page", type=int, default=0)
p.add_argument("--angle", type=int, default=90)
p.set_defaults(fn=rotate)
p = sub.add_parser("reorder")
p.add_argument("input")
p.add_argument("out")
p.add_argument("--order", required=True, help="comma list, 0-based, e.g. 2,0,1")
p.set_defaults(fn=reorder)
p = sub.add_parser("extract-text")
p.add_argument("input")
p.set_defaults(fn=extract_text)
p = sub.add_parser("info")
p.add_argument("input")
p.set_defaults(fn=info)
p = sub.add_parser("compress")
p.add_argument("input")
p.add_argument("out")
p.set_defaults(fn=compress)
args = ap.parse_args()
return args.fn(args)
if __name__ == "__main__":
sys.exit(main())
Install and smoke test:
pip install pypdf pdfplumber python3 pdf_tool.py make-fixture a.pdf "Alpha page one" "Bravo page two" python3 pdf_tool.py make-fixture b.pdf "Charlie page three" python3 pdf_tool.py merge merged.pdf a.pdf b.pdf python3 pdf_tool.py extract-text merged.pdf
Real output from the drafting run, pypdf 6.20.1: the fixtures wrote 2 pages and 1 page, merge wrote merged.pdf with 3 pages, and extract-text printed Alpha page one, Bravo page two, and Charlie page three, one per page, in order. Split of merged.pdf produced 3 files with 1 page each. Rotate with page 0 and angle 90 produced a file where info reported page 0 rotation=90 and pages 1 and 2 at rotation=0. Reorder with order 2,0,1 produced a file whose extracted text ran Charlie page three, Alpha page one, Bravo page two.
python
import pdfplumber
with pdfplumber.open("input.pdf") as pdf:
for i, page in enumerate(pdf.pages):
print(f"--- page {i} ---")
print(page.extract_text() or "")
for table in page.extract_tables():
for row in table:
print(row)
On the drafting fixture this printed the same three page texts as pypdf and returned an empty table list for every page, because the fixture pages hold a single line of text with no table structure. On real documents, check the first table by eye against the page: pdfplumber infers cell boundaries from lines and text positions, so borderless tables, merged cells, and multi-line cell text can come back split or joined wrong. Report the page and the mismatch instead of silently cleaning it up.
merge_page. The stamp is a one-page PDF that holds the mark. pypdf merges the stamp content onto each target page; page count does not change.python
from pypdf import PdfReader, PdfWriter
stamp = PdfReader("stamp.pdf").pages[0]
reader = PdfReader("input.pdf")
writer = PdfWriter()
for page in reader.pages:
page.merge_page(stamp)
writer.add_page(page)
with open("watermarked.pdf", "wb") as fh:
writer.write(fh)
In the drafting run, overlaying the one-page fixture b.pdf onto both pages of a.pdf produced a 2-page file whose extracted text combined both strings per page (page 0 read "Alpha page oneCharlie page three"). That confirms the overlay lands on every page. For a placed mark instead of a full-page overlay, position and size the stamp page before merging, or build the stamp at the target page size with the mark in the right spot. pypdf does not draw new text by itself; creating a text stamp needs a content stream (the make-fixture code in step 3 shows the pattern) or a stamp PDF produced elsewhere.
info first. If form_fields prints an empty list, the PDF has no AcroForm fields to fill; the values would have nowhere to go, and the usual cause is a form that was printed to PDF or built as flat layout. When fields do exist, load the file with writer.append(reader), then call writer.update_page_form_field_values(page, {field_name: value}) for the pages that hold the fields, then write to a new file. Both method names were confirmed by introspection at drafting time; the drafting fixture had no fields, so the fill path itself was not exercised end to end. Test the fill on a copy, reopen the output, and read the field values back with reader.get_fields() before delivering it.compress_content_streams() on each page after it is added to the writer. Calling it on a reader page before adding fails with "Page must be part of a PdfWriter"; the order in the tool is deliberate. This compresses content streams and can drop duplicate objects on write. It does not recompress embedded images, which are usually most of a large PDF file size. On the tiny drafting fixture the output grew, from 1284 bytes in to 1365 bytes out, because a 3-page text file has almost nothing to save. On image-heavy files expect little change from pypdf alone and say so; image recompression needs a different tool and a quality decision.info match the request on the pages that were meant to change, and only those pages.info reports encrypted=True, you need the password before any edit; pypdf can open a password-protected file with reader.decrypt(password) when the password is known. If PdfReader raises on open, the file is damaged or not a PDF at all. Report the error text and the file name, and stop. Do not loop through repair guesses on a file the user needs intact.A PDF job delivered with this skill includes:
reader.get_fields() first; an empty result means there is nothing to fill, whatever the page looks like.publisher key 2e43c913833850e6d80f939677e3de76e806d47785c0f1caaca90f9d18c98e1e
No ratings yet — be the first to rate it.