Skip to main content

Case Study 3: PDF Merge Automation

Scenario​

You regularly receive multiple PDF documents that must be combined into a single PDF package for clients, vendors, or internal record keeping. Your task is to merge PDFs in a specific order and optionally generate a cover page automatically.

Suggested Libraries​

  • pypdf
  • reportlab

Sample Files You May Create​

  • pdf_batch/ — a folder of PDFs to merge, named so their order is deterministic (e.g. 01_cover.pdf, 02_terms.pdf)
  • pdf_merger.py — your solution script
  • merged_output.pdf — the combined result

Requirements​

  • Load multiple PDF files.
  • Merge files in a defined order.
  • Generate an optional cover page.
  • Handle missing files gracefully.
  • Skip unsupported or protected documents.
  • Produce a final merged PDF package.

Have a genuine attempt at this before revealing the solution below. Think carefully about what "gracefully" means here — a batch job should never crash entirely just because one input file is missing or encrypted.

💡 Show Answer
from pypdf import PdfWriter, PdfReader # pypdf is the actively maintained successor to PyPDF2
from reportlab.pdfgen import canvas
from reportlab.lib.pagesizes import A4
from pathlib import Path
from datetime import datetime
import io

def generate_cover_page(title, file_count):
"""Builds a simple cover page in memory using reportlab, returns it as a PdfReader."""
buffer = io.BytesIO()
c = canvas.Canvas(buffer, pagesize=A4)
width, height = A4

c.setFont("Helvetica-Bold", 24)
c.drawCentredString(width / 2, height - 150, title)

c.setFont("Helvetica", 12)
c.drawCentredString(width / 2, height - 190, f"Merged on {datetime.now().strftime('%d %b %Y, %H:%M')}")
c.drawCentredString(width / 2, height - 210, f"Contains {file_count} document(s)")

c.save()
buffer.seek(0)
return PdfReader(buffer)

def merge_pdfs(input_paths, output_path, add_cover=True, cover_title="Merged Document Set"):
writer = PdfWriter()

if add_cover:
cover_reader = generate_cover_page(cover_title, len(input_paths))
writer.add_page(cover_reader.pages[0])

for path in input_paths:
path = Path(path)
if not path.exists():
print(f"Skipping missing file: {path}")
continue
reader = PdfReader(str(path))
if reader.is_encrypted:
print(f"Warning: {path.name} is encrypted and was skipped (decrypt it first)")
continue
for page in reader.pages:
writer.add_page(page)

with open(output_path, "wb") as f_out:
writer.write(f_out)

print(f"Merged {len(input_paths)} file(s) into {output_path}")
return output_path

def merge_folder(folder_path, output_path="merged_output.pdf", pattern="*.pdf"):
"""Merges all PDFs in a folder, sorted by filename, so naming (e.g. 01_, 02_...) controls order."""
folder = Path(folder_path)
pdf_files = sorted(folder.glob(pattern))
if not pdf_files:
raise FileNotFoundError(f"No PDFs matching '{pattern}' found in {folder_path}")
return merge_pdfs(pdf_files, output_path)

if __name__ == "__main__":
merge_folder("./pdf_batch", output_path="./merged_output.pdf")

Notes:

  • pypdf (the maintained fork) is used instead of the archived PyPDF2 package — pip install pypdf is the current recommendation, and the API is compatible with both.
  • Sorting by filename to control merge order means naming your source files 01_cover.pdf, 02_terms.pdf, etc. gives deterministic output — don't rely on OS folder listing order, which isn't guaranteed alphabetical on all systems.
  • Encrypted PDFs are explicitly skipped with a warning rather than silently failing or crashing the whole batch — always fail one item, not the whole job, in batch automation.
  • If you need to split rather than merge, writer = PdfWriter(); writer.add_page(reader.pages[i]) per output file is the mirror-image operation.