pdf-master / pdf_manipulator.py
algorembrant's picture
Upload 5 files
55fbe5b verified
"""
================================================================================
PDF MANIPULATOR - Full-Featured PDF Page Manipulation Toolkit
================================================================================
Author : algorembrant
Version : 1.0.0
License : MIT
USAGE COMMANDS (run from terminal):
--------------------------------------------------------------------------------
MERGE
python pdf_manipulator.py merge -i file1.pdf file2.pdf file3.pdf -o merged.pdf
python pdf_manipulator.py merge -i file1.pdf file2.pdf -o out.pdf --interleave
SPLIT
python pdf_manipulator.py split -i input.pdf -o ./output_dir
python pdf_manipulator.py split -i input.pdf -o ./output_dir --range 1-5
python pdf_manipulator.py split -i input.pdf -o ./output_dir --range 2,4,6
REMOVE PAGES
python pdf_manipulator.py remove -i input.pdf -o output.pdf --pages 3
python pdf_manipulator.py remove -i input.pdf -o output.pdf --pages 1,3,5
python pdf_manipulator.py remove -i input.pdf -o output.pdf --pages 2-5
python pdf_manipulator.py remove -i input.pdf -o output.pdf --pages 1,3-5,7
EXTRACT PAGES
python pdf_manipulator.py extract -i input.pdf -o output.pdf --pages 1-3
python pdf_manipulator.py extract -i input.pdf -o output.pdf --pages 2,4,6
REORDER PAGES
python pdf_manipulator.py reorder -i input.pdf -o output.pdf --order 3,1,2,4
ROTATE PAGES
python pdf_manipulator.py rotate -i input.pdf -o output.pdf --angle 90
python pdf_manipulator.py rotate -i input.pdf -o output.pdf --angle 180 --pages 1,3
python pdf_manipulator.py rotate -i input.pdf -o output.pdf --angle 270 --pages 2-4
REVERSE
python pdf_manipulator.py reverse -i input.pdf -o output.pdf
DUPLICATE PAGES
python pdf_manipulator.py duplicate -i input.pdf -o output.pdf --pages 2 --times 3
INSERT BLANK PAGES
python pdf_manipulator.py insert-blank -i input.pdf -o output.pdf --after 2
python pdf_manipulator.py insert-blank -i input.pdf -o output.pdf --before 1
INSERT PDF PAGES
python pdf_manipulator.py insert -i base.pdf --insert-file extra.pdf -o output.pdf --after 3
python pdf_manipulator.py insert -i base.pdf --insert-file extra.pdf -o output.pdf --before 2
REPLACE PAGES
python pdf_manipulator.py replace -i base.pdf --replace-file new.pdf -o output.pdf --pages 2 --replace-pages 1
CROP PAGES
python pdf_manipulator.py crop -i input.pdf -o output.pdf --box "50,50,500,700"
python pdf_manipulator.py crop -i input.pdf -o output.pdf --box "50,50,500,700" --pages 1-3
SCALE / RESIZE
python pdf_manipulator.py scale -i input.pdf -o output.pdf --factor 0.5
python pdf_manipulator.py scale -i input.pdf -o output.pdf --to-size A4
python pdf_manipulator.py scale -i input.pdf -o output.pdf --to-size letter
WATERMARK
python pdf_manipulator.py watermark -i input.pdf -o output.pdf --text "CONFIDENTIAL"
python pdf_manipulator.py watermark -i input.pdf -o output.pdf --text "DRAFT" --opacity 0.3 --angle 45
python pdf_manipulator.py watermark -i input.pdf -o output.pdf --watermark-pdf wm.pdf
STAMP / OVERLAY
python pdf_manipulator.py stamp -i input.pdf -o output.pdf --stamp-pdf stamp.pdf
python pdf_manipulator.py stamp -i input.pdf -o output.pdf --stamp-pdf stamp.pdf --pages 1
ADD PAGE NUMBERS
python pdf_manipulator.py number -i input.pdf -o output.pdf
python pdf_manipulator.py number -i input.pdf -o output.pdf --position bottom-center --start 1
python pdf_manipulator.py number -i input.pdf -o output.pdf --position top-right --format "Page {n}"
ENCRYPT / DECRYPT
python pdf_manipulator.py encrypt -i input.pdf -o output.pdf --user-pass mypass --owner-pass ownerpass
python pdf_manipulator.py encrypt -i input.pdf -o output.pdf --user-pass mypass
python pdf_manipulator.py decrypt -i encrypted.pdf -o decrypted.pdf --password mypass
METADATA
python pdf_manipulator.py metadata -i input.pdf
python pdf_manipulator.py metadata -i input.pdf -o output.pdf --set-title "My Title" --set-author "algorembrant"
python pdf_manipulator.py metadata -i input.pdf -o output.pdf --set-subject "Report" --set-keywords "pdf,report"
BOOKMARKS / OUTLINE
python pdf_manipulator.py bookmarks -i input.pdf
python pdf_manipulator.py bookmarks -i input.pdf -o output.pdf --add "Chapter 1:1,Chapter 2:5"
EXTRACT TEXT
python pdf_manipulator.py text -i input.pdf
python pdf_manipulator.py text -i input.pdf --pages 1-3 -o extracted.txt
INFO / INSPECT
python pdf_manipulator.py info -i input.pdf
N-UP (multiple pages per sheet)
python pdf_manipulator.py nup -i input.pdf -o output.pdf --layout 2x1
python pdf_manipulator.py nup -i input.pdf -o output.pdf --layout 2x2
COMPRESS
python pdf_manipulator.py compress -i input.pdf -o output.pdf
BATCH OPERATIONS
python pdf_manipulator.py batch-remove --dir ./pdfs --pages 1 --suffix _no_cover
python pdf_manipulator.py batch-merge --dir ./pdfs -o merged_all.pdf
python pdf_manipulator.py batch-split --dir ./pdfs --out-dir ./split_output
--------------------------------------------------------------------------------
PAGE RANGE SYNTAX:
Single page : 3
Multiple pages: 1,3,5
Range : 2-5 (inclusive)
Mixed : 1,3-5,7,9-11
Pages are always 1-indexed (first page = 1)
--------------------------------------------------------------------------------
"""
from __future__ import annotations
import argparse
import io
import os
import re
import sys
import glob
from copy import deepcopy
from pathlib import Path
from typing import List, Optional, Tuple
from pypdf import PdfReader, PdfWriter
from pypdf.generic import NameObject, NumberObject
from reportlab.lib.pagesizes import A4, letter, A3, A5, LETTER
from reportlab.lib.units import mm, inch
from reportlab.pdfgen import canvas as rl_canvas
from reportlab.lib import colors
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
PAGE_SIZES = {
"a3": A3,
"a4": A4,
"a5": A5,
"letter": letter,
"LETTER": LETTER,
}
NUMBER_POSITIONS = {
"bottom-center": lambda w, h: (w / 2, 20),
"bottom-left": lambda w, h: (30, 20),
"bottom-right": lambda w, h: (w - 30, 20),
"top-center": lambda w, h: (w / 2, h - 20),
"top-left": lambda w, h: (30, h - 20),
"top-right": lambda w, h: (w - 30, h - 20),
}
# ---------------------------------------------------------------------------
# Utility helpers
# ---------------------------------------------------------------------------
def parse_page_range(spec: str, total: int) -> List[int]:
"""
Parse a page-range string into a sorted list of 0-based indices.
Input is 1-based, e.g. "1,3-5,7" -> [0, 2, 3, 4, 6]
"""
indices: set[int] = set()
for part in spec.split(","):
part = part.strip()
if "-" in part:
a, b = part.split("-", 1)
a_i, b_i = int(a.strip()), int(b.strip())
if a_i < 1 or b_i > total or a_i > b_i:
raise ValueError(
f"Range {a_i}-{b_i} is out of bounds (document has {total} pages)."
)
indices.update(range(a_i - 1, b_i))
else:
n = int(part)
if n < 1 or n > total:
raise ValueError(
f"Page {n} is out of bounds (document has {total} pages)."
)
indices.add(n - 1)
return sorted(indices)
def open_pdf(path: str, password: Optional[str] = None) -> PdfReader:
reader = PdfReader(path)
if reader.is_encrypted:
if password is None:
password = ""
reader.decrypt(password)
return reader
def save_pdf(writer: PdfWriter, output_path: str) -> None:
out = Path(output_path)
out.parent.mkdir(parents=True, exist_ok=True)
with open(out, "wb") as f:
writer.write(f)
print(f"[OK] Saved -> {out.resolve()}")
def page_count(path: str) -> int:
return len(open_pdf(path).pages)
def make_watermark_pdf(
text: str,
page_width: float,
page_height: float,
opacity: float = 0.15,
angle: float = 45,
font_size: int = 60,
) -> io.BytesIO:
buf = io.BytesIO()
c = rl_canvas.Canvas(buf, pagesize=(page_width, page_height))
c.setFont("Helvetica-Bold", font_size)
c.setFillColor(colors.red, alpha=opacity)
c.saveState()
c.translate(page_width / 2, page_height / 2)
c.rotate(angle)
c.drawCentredString(0, 0, text)
c.restoreState()
c.save()
buf.seek(0)
return buf
def make_page_number_pdf(
number_str: str,
page_width: float,
page_height: float,
position: str = "bottom-center",
font_size: int = 10,
) -> io.BytesIO:
buf = io.BytesIO()
c = rl_canvas.Canvas(buf, pagesize=(page_width, page_height))
c.setFont("Helvetica", font_size)
c.setFillColor(colors.black)
pos_func = NUMBER_POSITIONS.get(position, NUMBER_POSITIONS["bottom-center"])
x, y = pos_func(page_width, page_height)
c.drawCentredString(x, y, number_str)
c.save()
buf.seek(0)
return buf
# ---------------------------------------------------------------------------
# Core operations
# ---------------------------------------------------------------------------
def cmd_merge(args: argparse.Namespace) -> None:
"""Merge multiple PDFs into one."""
writer = PdfWriter()
files = args.inputs
if args.interleave:
readers = [open_pdf(f) for f in files]
max_pages = max(len(r.pages) for r in readers)
for i in range(max_pages):
for r in readers:
if i < len(r.pages):
writer.add_page(r.pages[i])
else:
for f in files:
reader = open_pdf(f)
for page in reader.pages:
writer.add_page(page)
save_pdf(writer, args.output)
def cmd_split(args: argparse.Namespace) -> None:
"""Split a PDF into individual pages or ranges."""
reader = open_pdf(args.input)
total = len(reader.pages)
out_dir = Path(args.output)
out_dir.mkdir(parents=True, exist_ok=True)
stem = Path(args.input).stem
if args.range:
indices = parse_page_range(args.range, total)
writer = PdfWriter()
for idx in indices:
writer.add_page(reader.pages[idx])
out_path = out_dir / f"{stem}_pages_{args.range.replace(',', '_')}.pdf"
save_pdf(writer, str(out_path))
else:
for i, page in enumerate(reader.pages):
writer = PdfWriter()
writer.add_page(page)
out_path = out_dir / f"{stem}_page_{i + 1:04d}.pdf"
save_pdf(writer, str(out_path))
def cmd_remove(args: argparse.Namespace) -> None:
"""Remove specified pages from a PDF."""
reader = open_pdf(args.input)
total = len(reader.pages)
to_remove = set(parse_page_range(args.pages, total))
writer = PdfWriter()
for i, page in enumerate(reader.pages):
if i not in to_remove:
writer.add_page(page)
if len(writer.pages) == 0:
print("[WARN] All pages removed - output file will have 0 pages.")
save_pdf(writer, args.output)
def cmd_extract(args: argparse.Namespace) -> None:
"""Extract specific pages into a new PDF."""
reader = open_pdf(args.input)
total = len(reader.pages)
indices = parse_page_range(args.pages, total)
writer = PdfWriter()
for idx in indices:
writer.add_page(reader.pages[idx])
save_pdf(writer, args.output)
def cmd_reorder(args: argparse.Namespace) -> None:
"""Reorder pages according to a specified order."""
reader = open_pdf(args.input)
total = len(reader.pages)
order = [int(x.strip()) - 1 for x in args.order.split(",")]
for idx in order:
if idx < 0 or idx >= total:
raise ValueError(f"Page {idx + 1} is out of bounds (document has {total} pages).")
writer = PdfWriter()
for idx in order:
writer.add_page(reader.pages[idx])
save_pdf(writer, args.output)
def cmd_rotate(args: argparse.Namespace) -> None:
"""Rotate pages by a given angle (90, 180, 270)."""
if args.angle not in (90, 180, 270):
raise ValueError("Rotation angle must be 90, 180, or 270.")
reader = open_pdf(args.input)
total = len(reader.pages)
indices = set(parse_page_range(args.pages, total)) if args.pages else set(range(total))
writer = PdfWriter()
for i, page in enumerate(reader.pages):
if i in indices:
page.rotate(args.angle)
writer.add_page(page)
save_pdf(writer, args.output)
def cmd_reverse(args: argparse.Namespace) -> None:
"""Reverse the page order of a PDF."""
reader = open_pdf(args.input)
writer = PdfWriter()
for page in reversed(reader.pages):
writer.add_page(page)
save_pdf(writer, args.output)
def cmd_duplicate(args: argparse.Namespace) -> None:
"""Duplicate specific pages N times and insert them consecutively."""
reader = open_pdf(args.input)
total = len(reader.pages)
indices = set(parse_page_range(args.pages, total))
times = args.times
writer = PdfWriter()
for i, page in enumerate(reader.pages):
if i in indices:
for _ in range(times):
writer.add_page(deepcopy(page))
else:
writer.add_page(page)
save_pdf(writer, args.output)
def cmd_insert_blank(args: argparse.Namespace) -> None:
"""Insert one or more blank pages into a PDF."""
reader = open_pdf(args.input)
total = len(reader.pages)
pages_list = list(reader.pages)
# Determine reference page dimensions
ref_page = pages_list[0]
width = float(ref_page.mediabox.width)
height = float(ref_page.mediabox.height)
# Build blank page
blank_buf = io.BytesIO()
c = rl_canvas.Canvas(blank_buf, pagesize=(width, height))
c.save()
blank_buf.seek(0)
blank_reader = PdfReader(blank_buf)
blank_page = blank_reader.pages[0]
if args.after is not None:
insert_idx = args.after # 0-based: insert after this index
if insert_idx < 0 or insert_idx > total:
raise ValueError(f"--after {args.after} is out of range.")
pages_list.insert(insert_idx, blank_page)
elif args.before is not None:
insert_idx = args.before - 1 # convert to 0-based
if insert_idx < 0 or insert_idx > total:
raise ValueError(f"--before {args.before} is out of range.")
pages_list.insert(insert_idx, blank_page)
else:
raise ValueError("Specify --after N or --before N.")
writer = PdfWriter()
for p in pages_list:
writer.add_page(p)
save_pdf(writer, args.output)
def cmd_insert_pdf(args: argparse.Namespace) -> None:
"""Insert pages from another PDF into the base PDF."""
base_reader = open_pdf(args.input)
ins_reader = open_pdf(args.insert_file)
base_pages = list(base_reader.pages)
ins_pages = list(ins_reader.pages)
if args.after is not None:
pos = args.after
elif args.before is not None:
pos = args.before - 1
else:
raise ValueError("Specify --after N or --before N.")
result = base_pages[:pos] + ins_pages + base_pages[pos:]
writer = PdfWriter()
for p in result:
writer.add_page(p)
save_pdf(writer, args.output)
def cmd_replace(args: argparse.Namespace) -> None:
"""Replace specific pages in the base PDF with pages from another PDF."""
base_reader = open_pdf(args.input)
rep_reader = open_pdf(args.replace_file)
total_base = len(base_reader.pages)
total_rep = len(rep_reader.pages)
base_indices = parse_page_range(args.pages, total_base)
rep_indices = parse_page_range(args.replace_pages, total_rep)
if len(base_indices) != len(rep_indices):
raise ValueError(
f"Number of pages to replace ({len(base_indices)}) must match "
f"number of replacement pages ({len(rep_indices)})."
)
replace_map = dict(zip(base_indices, rep_indices))
writer = PdfWriter()
for i, page in enumerate(base_reader.pages):
if i in replace_map:
writer.add_page(rep_reader.pages[replace_map[i]])
else:
writer.add_page(page)
save_pdf(writer, args.output)
def cmd_crop(args: argparse.Namespace) -> None:
"""Crop pages to a specific bounding box (left,bottom,right,top)."""
box_vals = [float(v) for v in args.box.split(",")]
if len(box_vals) != 4:
raise ValueError("--box must be 'left,bottom,right,top'.")
left, bottom, right, top = box_vals
reader = open_pdf(args.input)
total = len(reader.pages)
indices = set(parse_page_range(args.pages, total)) if args.pages else set(range(total))
writer = PdfWriter()
for i, page in enumerate(reader.pages):
if i in indices:
page.mediabox.lower_left = (left, bottom)
page.mediabox.upper_right = (right, top)
writer.add_page(page)
save_pdf(writer, args.output)
def cmd_scale(args: argparse.Namespace) -> None:
"""Scale pages by a factor or resize to a standard page size."""
reader = open_pdf(args.input)
writer = PdfWriter()
for page in reader.pages:
orig_w = float(page.mediabox.width)
orig_h = float(page.mediabox.height)
if args.factor:
f = args.factor
page.scale(f, f)
elif args.to_size:
target = PAGE_SIZES.get(args.to_size.lower())
if target is None:
raise ValueError(f"Unknown page size: {args.to_size}. Choose from {list(PAGE_SIZES.keys())}")
tw, th = target
fx = tw / orig_w
fy = th / orig_h
page.scale(fx, fy)
writer.add_page(page)
save_pdf(writer, args.output)
def cmd_watermark(args: argparse.Namespace) -> None:
"""Add a text or PDF watermark to each page."""
reader = open_pdf(args.input)
writer = PdfWriter()
for page in reader.pages:
w = float(page.mediabox.width)
h = float(page.mediabox.height)
if args.watermark_pdf:
wm_reader = open_pdf(args.watermark_pdf)
wm_page = wm_reader.pages[0]
else:
text = args.text or "WATERMARK"
opacity = args.opacity if args.opacity else 0.15
angle = args.angle if args.angle else 45
wm_buf = make_watermark_pdf(text, w, h, opacity=opacity, angle=angle)
wm_reader = PdfReader(wm_buf)
wm_page = wm_reader.pages[0]
page.merge_page(wm_page)
writer.add_page(page)
save_pdf(writer, args.output)
def cmd_stamp(args: argparse.Namespace) -> None:
"""Overlay a stamp PDF on top of pages."""
reader = open_pdf(args.input)
stamp_reader = open_pdf(args.stamp_pdf)
stamp_page = stamp_reader.pages[0]
total = len(reader.pages)
indices = set(parse_page_range(args.pages, total)) if args.pages else set(range(total))
writer = PdfWriter()
for i, page in enumerate(reader.pages):
if i in indices:
page.merge_page(stamp_page)
writer.add_page(page)
save_pdf(writer, args.output)
def cmd_number(args: argparse.Namespace) -> None:
"""Add page numbers to each page."""
reader = open_pdf(args.input)
writer = PdfWriter()
position = args.position or "bottom-center"
start = args.start if args.start else 1
fmt = args.format or "{n}"
for i, page in enumerate(reader.pages):
w = float(page.mediabox.width)
h = float(page.mediabox.height)
number_str = fmt.replace("{n}", str(i + start))
num_buf = make_page_number_pdf(number_str, w, h, position=position)
num_reader = PdfReader(num_buf)
page.merge_page(num_reader.pages[0])
writer.add_page(page)
save_pdf(writer, args.output)
def cmd_encrypt(args: argparse.Namespace) -> None:
"""Encrypt a PDF with user and owner passwords."""
reader = open_pdf(args.input)
writer = PdfWriter()
for page in reader.pages:
writer.add_page(page)
user_pw = args.user_pass or ""
owner_pw = args.owner_pass or args.user_pass or ""
writer.encrypt(user_pw, owner_pw)
save_pdf(writer, args.output)
def cmd_decrypt(args: argparse.Namespace) -> None:
"""Decrypt / remove password from a PDF."""
reader = open_pdf(args.input, password=args.password)
if not reader.is_encrypted and not args.password:
print("[INFO] File is not encrypted.")
writer = PdfWriter()
for page in reader.pages:
writer.add_page(page)
save_pdf(writer, args.output)
def cmd_metadata(args: argparse.Namespace) -> None:
"""View or set PDF metadata."""
reader = open_pdf(args.input)
meta = reader.metadata
print("\n--- PDF Metadata ---")
print(f" Title : {meta.title}")
print(f" Author : {meta.author}")
print(f" Subject : {meta.subject}")
print(f" Keywords : {meta.get('/Keywords', '')}")
print(f" Creator : {meta.creator}")
print(f" Producer : {meta.producer}")
print(f" Created : {meta.get('/CreationDate', '')}")
print(f" Modified : {meta.get('/ModDate', '')}")
print()
if args.output and any([args.set_title, args.set_author, args.set_subject, args.set_keywords]):
writer = PdfWriter()
for page in reader.pages:
writer.add_page(page)
new_meta = {}
if args.set_title:
new_meta["/Title"] = args.set_title
if args.set_author:
new_meta["/Author"] = args.set_author
if args.set_subject:
new_meta["/Subject"] = args.set_subject
if args.set_keywords:
new_meta["/Keywords"] = args.set_keywords
writer.add_metadata(new_meta)
save_pdf(writer, args.output)
def cmd_bookmarks(args: argparse.Namespace) -> None:
"""List or add bookmarks/outline entries."""
reader = open_pdf(args.input)
outlines = reader.outline
def _print_outline(items, indent=0):
for item in items:
if isinstance(item, list):
_print_outline(item, indent + 2)
else:
try:
title = item.title
page_obj = reader.get_destination_page_number(item)
print(f"{' ' * indent} {title} (page {page_obj + 1})")
except Exception:
pass
print("\n--- Bookmarks / Outline ---")
if outlines:
_print_outline(outlines)
else:
print(" (none)")
print()
if args.output and args.add:
writer = PdfWriter()
for page in reader.pages:
writer.add_page(page)
for entry in args.add.split(","):
title, pg = entry.strip().split(":")
writer.add_outline_item(title.strip(), int(pg.strip()) - 1)
save_pdf(writer, args.output)
def cmd_text(args: argparse.Namespace) -> None:
"""Extract text from PDF pages."""
reader = open_pdf(args.input)
total = len(reader.pages)
indices = parse_page_range(args.pages, total) if args.pages else list(range(total))
lines = []
for idx in indices:
text = reader.pages[idx].extract_text() or ""
lines.append(f"=== Page {idx + 1} ===\n{text}\n")
full_text = "\n".join(lines)
if args.output:
with open(args.output, "w", encoding="utf-8") as f:
f.write(full_text)
print(f"[OK] Text saved -> {args.output}")
else:
print(full_text)
def cmd_info(args: argparse.Namespace) -> None:
"""Display detailed information about a PDF."""
reader = open_pdf(args.input)
total = len(reader.pages)
meta = reader.metadata
print("\n--- PDF Info ---")
print(f" File : {args.input}")
print(f" Pages : {total}")
print(f" Encrypted : {reader.is_encrypted}")
print(f" Title : {meta.title}")
print(f" Author : {meta.author}")
print()
print(" Page Dimensions:")
for i, page in enumerate(reader.pages):
w = float(page.mediabox.width)
h = float(page.mediabox.height)
print(f" Page {i + 1:4d}: {w:.1f} x {h:.1f} pt ({w/72:.2f} x {h/72:.2f} in)")
print()
def cmd_nup(args: argparse.Namespace) -> None:
"""Arrange N pages per output sheet (e.g. 2x1, 2x2)."""
layout = args.layout.lower()
try:
cols, rows = [int(x) for x in layout.split("x")]
except ValueError:
raise ValueError("--layout must be CxR, e.g. 2x1 or 2x2")
reader = open_pdf(args.input)
per_sheet = cols * rows
total = len(reader.pages)
# Use first page dimensions for output sheet
first_page = reader.pages[0]
pw = float(first_page.mediabox.width)
ph = float(first_page.mediabox.height)
cell_w = pw / cols
cell_h = ph / rows
sheet_w = pw
sheet_h = ph
writer = PdfWriter()
i = 0
while i < total:
buf = io.BytesIO()
c = rl_canvas.Canvas(buf, pagesize=(sheet_w, sheet_h))
for slot in range(per_sheet):
if i + slot >= total:
break
col = slot % cols
row = slot // cols
x_off = col * cell_w
y_off = sheet_h - (row + 1) * cell_h
# Render sub-page
sub_buf = io.BytesIO()
sub_writer = PdfWriter()
sub_writer.add_page(reader.pages[i + slot])
sub_writer.write(sub_buf)
sub_buf.seek(0)
from reportlab.lib.utils import ImageReader
from pdf2image import convert_from_bytes
imgs = convert_from_bytes(sub_buf.read(), dpi=72)
if imgs:
img = imgs[0]
c.drawInlineImage(img, x_off, y_off, width=cell_w, height=cell_h)
c.save()
buf.seek(0)
nup_reader = PdfReader(buf)
writer.add_page(nup_reader.pages[0])
i += per_sheet
save_pdf(writer, args.output)
def cmd_compress(args: argparse.Namespace) -> None:
"""Apply lossless compression to all page streams."""
reader = open_pdf(args.input)
writer = PdfWriter()
for page in reader.pages:
writer.add_page(page)
writer.compress_identical_objects(remove_identicals=True, remove_orphans=True)
save_pdf(writer, args.output)
def cmd_batch_remove(args: argparse.Namespace) -> None:
"""Remove pages from all PDFs in a directory."""
pdfs = sorted(glob.glob(os.path.join(args.dir, "*.pdf")))
suffix = args.suffix or "_modified"
for pdf_path in pdfs:
stem = Path(pdf_path).stem
out_path = os.path.join(args.dir, f"{stem}{suffix}.pdf")
reader = open_pdf(pdf_path)
total = len(reader.pages)
try:
to_remove = set(parse_page_range(args.pages, total))
except ValueError as e:
print(f"[SKIP] {pdf_path}: {e}")
continue
writer = PdfWriter()
for i, page in enumerate(reader.pages):
if i not in to_remove:
writer.add_page(page)
save_pdf(writer, out_path)
def cmd_batch_merge(args: argparse.Namespace) -> None:
"""Merge all PDFs in a directory into one."""
pdfs = sorted(glob.glob(os.path.join(args.dir, "*.pdf")))
writer = PdfWriter()
for pdf_path in pdfs:
reader = open_pdf(pdf_path)
for page in reader.pages:
writer.add_page(page)
save_pdf(writer, args.output)
def cmd_batch_split(args: argparse.Namespace) -> None:
"""Split all PDFs in a directory into individual pages."""
pdfs = sorted(glob.glob(os.path.join(args.dir, "*.pdf")))
out_dir = Path(args.out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
for pdf_path in pdfs:
stem = Path(pdf_path).stem
reader = open_pdf(pdf_path)
for i, page in enumerate(reader.pages):
writer = PdfWriter()
writer.add_page(page)
out_path = out_dir / f"{stem}_page_{i + 1:04d}.pdf"
save_pdf(writer, str(out_path))
# ---------------------------------------------------------------------------
# Argument parser
# ---------------------------------------------------------------------------
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="pdf_manipulator",
description="Full-featured PDF page manipulation toolkit by algorembrant",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
sub = parser.add_subparsers(dest="command", required=True)
# merge
p = sub.add_parser("merge", help="Merge multiple PDFs")
p.add_argument("-i", "--inputs", nargs="+", required=True, metavar="FILE")
p.add_argument("-o", "--output", required=True)
p.add_argument("--interleave", action="store_true", help="Interleave pages from each file")
# split
p = sub.add_parser("split", help="Split PDF into pages or a range")
p.add_argument("-i", "--input", required=True)
p.add_argument("-o", "--output", required=True, help="Output directory")
p.add_argument("--range", help="Page range to extract (e.g. 1-5 or 2,4,6)")
# remove
p = sub.add_parser("remove", help="Remove pages from a PDF")
p.add_argument("-i", "--input", required=True)
p.add_argument("-o", "--output", required=True)
p.add_argument("--pages", required=True, help="Pages to remove, e.g. 1 or 1,3-5")
# extract
p = sub.add_parser("extract", help="Extract pages to a new PDF")
p.add_argument("-i", "--input", required=True)
p.add_argument("-o", "--output", required=True)
p.add_argument("--pages", required=True, help="Pages to extract, e.g. 1-3")
# reorder
p = sub.add_parser("reorder", help="Reorder pages")
p.add_argument("-i", "--input", required=True)
p.add_argument("-o", "--output", required=True)
p.add_argument("--order", required=True, help="New order, e.g. 3,1,2,4")
# rotate
p = sub.add_parser("rotate", help="Rotate pages")
p.add_argument("-i", "--input", required=True)
p.add_argument("-o", "--output", required=True)
p.add_argument("--angle", required=True, type=int, choices=[90, 180, 270])
p.add_argument("--pages", help="Pages to rotate (all if omitted)")
# reverse
p = sub.add_parser("reverse", help="Reverse page order")
p.add_argument("-i", "--input", required=True)
p.add_argument("-o", "--output", required=True)
# duplicate
p = sub.add_parser("duplicate", help="Duplicate specified pages")
p.add_argument("-i", "--input", required=True)
p.add_argument("-o", "--output", required=True)
p.add_argument("--pages", required=True, help="Pages to duplicate")
p.add_argument("--times", type=int, default=2, help="Number of copies (default 2)")
# insert-blank
p = sub.add_parser("insert-blank", help="Insert blank page(s)")
p.add_argument("-i", "--input", required=True)
p.add_argument("-o", "--output", required=True)
p.add_argument("--after", type=int, help="Insert after page N (1-indexed)")
p.add_argument("--before", type=int, help="Insert before page N (1-indexed)")
# insert (pdf)
p = sub.add_parser("insert", help="Insert pages from another PDF")
p.add_argument("-i", "--input", required=True)
p.add_argument("-o", "--output", required=True)
p.add_argument("--insert-file", required=True)
p.add_argument("--after", type=int, help="Insert after page N")
p.add_argument("--before", type=int, help="Insert before page N")
# replace
p = sub.add_parser("replace", help="Replace pages with pages from another PDF")
p.add_argument("-i", "--input", required=True)
p.add_argument("-o", "--output", required=True)
p.add_argument("--replace-file", required=True)
p.add_argument("--pages", required=True, help="Pages in base to replace")
p.add_argument("--replace-pages", required=True, help="Pages in replacement file to use")
# crop
p = sub.add_parser("crop", help="Crop pages to a bounding box")
p.add_argument("-i", "--input", required=True)
p.add_argument("-o", "--output", required=True)
p.add_argument("--box", required=True, help="left,bottom,right,top in points")
p.add_argument("--pages", help="Pages to crop (all if omitted)")
# scale
p = sub.add_parser("scale", help="Scale or resize pages")
p.add_argument("-i", "--input", required=True)
p.add_argument("-o", "--output", required=True)
p.add_argument("--factor", type=float, help="Scale factor, e.g. 0.5")
p.add_argument("--to-size", help="Target page size: a4, a3, a5, letter")
# watermark
p = sub.add_parser("watermark", help="Add watermark to pages")
p.add_argument("-i", "--input", required=True)
p.add_argument("-o", "--output", required=True)
p.add_argument("--text", help="Watermark text")
p.add_argument("--opacity", type=float, default=0.15)
p.add_argument("--angle", type=float, default=45.0)
p.add_argument("--watermark-pdf", help="Use a PDF as watermark instead of text")
# stamp
p = sub.add_parser("stamp", help="Overlay a stamp PDF on pages")
p.add_argument("-i", "--input", required=True)
p.add_argument("-o", "--output", required=True)
p.add_argument("--stamp-pdf", required=True)
p.add_argument("--pages", help="Pages to stamp (all if omitted)")
# number
p = sub.add_parser("number", help="Add page numbers")
p.add_argument("-i", "--input", required=True)
p.add_argument("-o", "--output", required=True)
p.add_argument("--position", default="bottom-center",
choices=list(NUMBER_POSITIONS.keys()))
p.add_argument("--start", type=int, default=1)
p.add_argument("--format", default="{n}", help="Number format, use {n} for page number")
# encrypt
p = sub.add_parser("encrypt", help="Encrypt a PDF")
p.add_argument("-i", "--input", required=True)
p.add_argument("-o", "--output", required=True)
p.add_argument("--user-pass", required=True)
p.add_argument("--owner-pass", default=None)
# decrypt
p = sub.add_parser("decrypt", help="Remove password from PDF")
p.add_argument("-i", "--input", required=True)
p.add_argument("-o", "--output", required=True)
p.add_argument("--password", required=True)
# metadata
p = sub.add_parser("metadata", help="View or edit PDF metadata")
p.add_argument("-i", "--input", required=True)
p.add_argument("-o", "--output", default=None)
p.add_argument("--set-title")
p.add_argument("--set-author")
p.add_argument("--set-subject")
p.add_argument("--set-keywords")
# bookmarks
p = sub.add_parser("bookmarks", help="List or add bookmarks")
p.add_argument("-i", "--input", required=True)
p.add_argument("-o", "--output", default=None)
p.add_argument("--add", help="Bookmarks to add: 'Title:page,Title2:page2'")
# text
p = sub.add_parser("text", help="Extract text from PDF")
p.add_argument("-i", "--input", required=True)
p.add_argument("-o", "--output", default=None, help="Save to file instead of printing")
p.add_argument("--pages", help="Pages to extract (all if omitted)")
# info
p = sub.add_parser("info", help="Display PDF information")
p.add_argument("-i", "--input", required=True)
# nup
p = sub.add_parser("nup", help="Arrange N pages per sheet")
p.add_argument("-i", "--input", required=True)
p.add_argument("-o", "--output", required=True)
p.add_argument("--layout", default="2x1", help="Layout e.g. 2x1, 2x2, 4x1")
# compress
p = sub.add_parser("compress", help="Compress PDF streams")
p.add_argument("-i", "--input", required=True)
p.add_argument("-o", "--output", required=True)
# batch-remove
p = sub.add_parser("batch-remove", help="Remove pages from all PDFs in a directory")
p.add_argument("--dir", required=True)
p.add_argument("--pages", required=True)
p.add_argument("--suffix", default="_modified")
# batch-merge
p = sub.add_parser("batch-merge", help="Merge all PDFs in a directory")
p.add_argument("--dir", required=True)
p.add_argument("-o", "--output", required=True)
# batch-split
p = sub.add_parser("batch-split", help="Split all PDFs in a directory into pages")
p.add_argument("--dir", required=True)
p.add_argument("--out-dir", required=True)
return parser
COMMANDS = {
"merge": cmd_merge,
"split": cmd_split,
"remove": cmd_remove,
"extract": cmd_extract,
"reorder": cmd_reorder,
"rotate": cmd_rotate,
"reverse": cmd_reverse,
"duplicate": cmd_duplicate,
"insert-blank": cmd_insert_blank,
"insert": cmd_insert_pdf,
"replace": cmd_replace,
"crop": cmd_crop,
"scale": cmd_scale,
"watermark": cmd_watermark,
"stamp": cmd_stamp,
"number": cmd_number,
"encrypt": cmd_encrypt,
"decrypt": cmd_decrypt,
"metadata": cmd_metadata,
"bookmarks": cmd_bookmarks,
"text": cmd_text,
"info": cmd_info,
"nup": cmd_nup,
"compress": cmd_compress,
"batch-remove": cmd_batch_remove,
"batch-merge": cmd_batch_merge,
"batch-split": cmd_batch_split,
}
def main() -> None:
parser = build_parser()
args = parser.parse_args()
handler = COMMANDS.get(args.command)
if handler is None:
parser.print_help()
sys.exit(1)
try:
handler(args)
except Exception as exc:
print(f"[ERROR] {exc}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()