--- name: pdf description: Read, extract, create, merge, split, and manipulate PDF files. Use whenever a .pdf file is involved — extracting text or tables from a PDF, creating a new PDF from content or data, merging multiple PDFs, splitting a PDF into pages, rotating pages, adding watermarks, filling PDF forms, encrypting or decrypting PDFs, extracting images, or running OCR on scanned PDFs. Trigger when the user mentions a .pdf file or asks to produce a PDF output. --- # PDF Processing PDF is the universal document format. It is also one of the most annoying formats to work with programmatically. This skill handles the full range of PDF tasks — reading, creating, editing, merging, splitting, and extracting — using the right tool for each job. | Task | Best tool | |---|---| | Extract text (digital PDF) | `pdfplumber` or `pdftotext` | | Extract tables | `pdfplumber` | | Create PDF from content | `reportlab` | | Merge / split / rotate | `pypdf` or `qpdf` | | Fill PDF forms | `pypdf` or `pdf-lib` (JS) | | OCR scanned PDFs | `pytesseract` + `pdf2image` | | Password protect / decrypt | `pypdf` or `qpdf` | | Extract images | `pdfimages` (poppler) | > `pypdf`, `pdfplumber`, and `reportlab` are typically pre-installed. Import directly. Only run `pip install` if an import fails. --- ## Reading & Extracting Content ### Extract All Text ```python # pdfplumber — preserves layout better than pypdf import pdfplumber with pdfplumber.open("document.pdf") as pdf: print(f"Pages: {len(pdf.pages)}") for i, page in enumerate(pdf.pages, 1): text = page.extract_text() if text: print(f"\n--- Page {i} ---") print(text) ``` ```bash # Command line — fastest for quick extraction pdftotext document.pdf output.txt # basic pdftotext -layout document.pdf output.txt # preserve column layout pdftotext -f 1 -l 5 document.pdf - # pages 1–5, stdout ``` ### Extract Specific Pages ```python from pypdf import PdfReader reader = PdfReader("document.pdf") # Single page text = reader.pages[0].extract_text() # Page range (0-indexed) for page in reader.pages[2:7]: # pages 3–7 print(page.extract_text()) ``` ### Extract Tables ```python import pdfplumber import pandas as pd with pdfplumber.open("report.pdf") as pdf: all_tables = [] for page_num, page in enumerate(pdf.pages, 1): tables = page.extract_tables() for table_num, table in enumerate(tables, 1): if not table or not table[0]: continue print(f"Page {page_num}, Table {table_num}: {len(table)} rows") # Convert to DataFrame — first row as headers df = pd.DataFrame(table[1:], columns=table[0]) # Clean: strip whitespace, drop empty rows df = df.applymap(lambda x: x.strip() if isinstance(x, str) else x) df = df.dropna(how="all") all_tables.append(df) # Combine and export if all_tables: combined = pd.concat(all_tables, ignore_index=True) combined.to_excel("extracted_tables.xlsx", index=False) combined.to_csv("extracted_tables.csv", index=False) print(f"Extracted {len(combined)} rows across {len(all_tables)} tables") ``` ### Extract Metadata ```python from pypdf import PdfReader reader = PdfReader("document.pdf") meta = reader.metadata print(f"Title: {meta.title}") print(f"Author: {meta.author}") print(f"Subject: {meta.subject}") print(f"Creator: {meta.creator}") print(f"Producer: {meta.producer}") print(f"Created: {meta.creation_date}") print(f"Pages: {len(reader.pages)}") print(f"Encrypted:{reader.is_encrypted}") ``` --- ## Creating PDFs ### Simple PDF with reportlab ```python from reportlab.lib.pagesizes import letter, A4 from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle from reportlab.lib.units import inch from reportlab.lib import colors from reportlab.platypus import ( SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, PageBreak, HRFlowable ) doc = SimpleDocTemplate( "report.pdf", pagesize=letter, rightMargin=inch, leftMargin=inch, topMargin=inch, bottomMargin=inch, ) styles = getSampleStyleSheet() # Custom styles title_style = ParagraphStyle( "CustomTitle", parent=styles["Title"], fontSize=24, spaceAfter=12, textColor=colors.HexColor("#1A1A2E"), ) heading_style = ParagraphStyle( "CustomHeading", parent=styles["Heading1"], fontSize=16, spaceBefore=16, spaceAfter=8, textColor=colors.HexColor("#2563EB"), ) body_style = ParagraphStyle( "CustomBody", parent=styles["Normal"], fontSize=11, leading=16, # line height spaceAfter=8, ) story = [] # Title story.append(Paragraph("Q3 2025 Engineering Report", title_style)) story.append(HRFlowable(width="100%", thickness=1, color=colors.HexColor("#E5E7EB"))) story.append(Spacer(1, 12)) # Section heading + body story.append(Paragraph("Executive Summary", heading_style)) story.append(Paragraph( "This quarter the engineering team shipped 12 features, resolved 47 bugs, " "and improved API response time by 34% across all endpoints.", body_style )) story.append(Spacer(1, 8)) # Table table_data = [ ["Metric", "Q2 2025", "Q3 2025", "Change"], ["Features shipped", "8", "12", "+50%"], ["Bugs resolved", "31", "47", "+52%"], ["API P95 latency", "420ms", "278ms", "-34%"], ["Test coverage", "71%", "81%", "+10pp"], ] table = Table(table_data, colWidths=[2.5*inch, 1.2*inch, 1.2*inch, 1.1*inch]) table.setStyle(TableStyle([ # Header row ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#1A1A2E")), ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), ("FONTSIZE", (0, 0), (-1, 0), 11), ("ALIGN", (0, 0), (-1, 0), "CENTER"), # Data rows ("FONTNAME", (0, 1), (-1, -1), "Helvetica"), ("FONTSIZE", (0, 1), (-1, -1), 10), ("ALIGN", (1, 1), (-1, -1), "CENTER"), ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#F9FAFB")]), # Borders ("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#E5E7EB")), ("BOX", (0, 0), (-1, -1), 1, colors.HexColor("#D1D5DB")), # Padding ("TOPPADDING", (0, 0), (-1, -1), 8), ("BOTTOMPADDING", (0, 0), (-1, -1), 8), ("LEFTPADDING", (0, 0), (-1, -1), 10), ("RIGHTPADDING", (0, 0), (-1, -1), 10), ])) story.append(table) story.append(PageBreak()) story.append(Paragraph("Appendix", heading_style)) doc.build(story) print("Created report.pdf") ``` ### Important: Subscripts and Superscripts **Never use Unicode subscript/superscript characters in reportlab** — they render as solid black boxes in built-in fonts. ```python # ❌ WRONG — renders as black boxes story.append(Paragraph("H₂O", styles["Normal"])) story.append(Paragraph("x²", styles["Normal"])) # ✅ CORRECT — use XML sub/sup tags in Paragraph text story.append(Paragraph("H2O", styles["Normal"])) story.append(Paragraph("x2 + y2", styles["Normal"])) ``` --- ## Merging PDFs ```python from pypdf import PdfWriter, PdfReader def merge_pdfs(input_files: list[str], output_file: str): writer = PdfWriter() for pdf_file in input_files: reader = PdfReader(pdf_file) print(f"Adding {pdf_file}: {len(reader.pages)} pages") for page in reader.pages: writer.add_page(page) with open(output_file, "wb") as f: writer.write(f) print(f"Merged {len(input_files)} files → {output_file}") merge_pdfs(["intro.pdf", "chapter1.pdf", "chapter2.pdf", "appendix.pdf"], "complete.pdf") ``` ```bash # Command line — faster for large files qpdf --empty --pages intro.pdf chapter1.pdf chapter2.pdf -- merged.pdf # With page ranges qpdf --empty --pages file1.pdf 1-5 file2.pdf 3-7 -- merged.pdf ``` --- ## Splitting PDFs ```python from pypdf import PdfReader, PdfWriter import os def split_pdf(input_file: str, output_dir: str = "."): os.makedirs(output_dir, exist_ok=True) reader = PdfReader(input_file) for i, page in enumerate(reader.pages, 1): writer = PdfWriter() writer.add_page(page) output_path = os.path.join(output_dir, f"page_{i:03d}.pdf") with open(output_path, "wb") as f: writer.write(f) print(f"Split {len(reader.pages)} pages → {output_dir}/") # Split by page range def extract_pages(input_file: str, start: int, end: int, output_file: str): """Extract pages start..end (1-indexed, inclusive)""" reader = PdfReader(input_file) writer = PdfWriter() for page in reader.pages[start-1:end]: writer.add_page(page) with open(output_file, "wb") as f: writer.write(f) ``` ```bash # Command line qpdf input.pdf --pages . 1-10 -- part1.pdf qpdf input.pdf --pages . 11-20 -- part2.pdf ``` --- ## Rotating Pages ```python from pypdf import PdfReader, PdfWriter reader = PdfReader("document.pdf") writer = PdfWriter() for i, page in enumerate(reader.pages): if i in [0, 2]: # rotate specific pages page.rotate(90) # 90, 180, or 270 degrees clockwise writer.add_page(page) with open("rotated.pdf", "wb") as f: writer.write(f) ``` --- ## Watermarking ```python from pypdf import PdfReader, PdfWriter # Assume watermark.pdf exists with the watermark on page 1 watermark_page = PdfReader("watermark.pdf").pages[0] reader = PdfReader("document.pdf") writer = PdfWriter() for page in reader.pages: page.merge_page(watermark_page) # overlay watermark writer.add_page(page) with open("watermarked.pdf", "wb") as f: writer.write(f) ``` --- ## Password Protection ```python from pypdf import PdfReader, PdfWriter # Encrypt a PDF def encrypt_pdf(input_file: str, output_file: str, password: str): reader = PdfReader(input_file) writer = PdfWriter() for page in reader.pages: writer.add_page(page) writer.encrypt(user_password=password, owner_password=password + "_owner") with open(output_file, "wb") as f: writer.write(f) # Decrypt a PDF def decrypt_pdf(input_file: str, output_file: str, password: str): reader = PdfReader(input_file) if reader.is_encrypted: reader.decrypt(password) writer = PdfWriter() for page in reader.pages: writer.add_page(page) with open(output_file, "wb") as f: writer.write(f) ``` ```bash # Command line qpdf --password=mypassword --decrypt encrypted.pdf decrypted.pdf qpdf --encrypt userpass ownerpass 256 -- plain.pdf encrypted.pdf ``` --- ## OCR (Scanned PDFs) When a PDF contains images of text (not selectable text), OCR is required: ```python # pip install pytesseract pdf2image # Also requires: brew install tesseract poppler (macOS) import pytesseract from pdf2image import convert_from_path def ocr_pdf(input_file: str, output_file: str = None) -> str: # Convert PDF pages to images (300 DPI for accuracy) images = convert_from_path(input_file, dpi=300) print(f"OCR processing {len(images)} pages...") full_text = "" for i, image in enumerate(images, 1): page_text = pytesseract.image_to_string(image, lang="eng") full_text += f"\n--- Page {i} ---\n{page_text}\n" print(f" Page {i}: {len(page_text)} characters") if output_file: with open(output_file, "w") as f: f.write(full_text) return full_text text = ocr_pdf("scanned_contract.pdf", "contract_text.txt") ``` --- ## Convert PDF to Other Formats ```bash # PDF → Word (requires LibreOffice) libreoffice --headless --convert-to docx document.pdf # PDF → images (one image per page) pdfimages -j document.pdf page # → page-000.jpg, page-001.jpg... pdftoppm -r 150 document.pdf page # → page-1.ppm (150 DPI) # PDF → text (preserving layout) pdftotext -layout document.pdf output.txt # HTML/Markdown → PDF (via reportlab or weasyprint) # pip install weasyprint from weasyprint import HTML HTML(filename="page.html").write_pdf("output.pdf") ``` --- ## Quick Reference | Task | Code / Command | |---|---| | Read text | `pdfplumber.open(f).pages[0].extract_text()` | | Read tables | `page.extract_tables()` → `pd.DataFrame` | | Create PDF | `reportlab` `SimpleDocTemplate` + `Paragraph` | | Merge | `PdfWriter` + `add_page()` per file | | Split | One `PdfWriter` per page | | Rotate | `page.rotate(90)` | | Watermark | `page.merge_page(watermark)` | | Encrypt | `writer.encrypt(password)` | | Decrypt | `reader.decrypt(password)` → new `PdfWriter` | | OCR | `pdf2image` convert → `pytesseract.image_to_string()` | | CLI merge | `qpdf --empty --pages f1.pdf f2.pdf -- out.pdf` | | CLI split | `qpdf input.pdf --pages . 1-5 -- part.pdf` | --- ## Definition of Done — PDF - [ ] Correct tool selected for the task (pdfplumber for extraction, reportlab for creation, pypdf for manipulation) - [ ] Text extraction verified against known content — no garbled characters - [ ] Tables exported to DataFrame and validated row/column counts match visual inspection - [ ] Created PDFs: all text renders correctly (no black boxes from Unicode subscripts) - [ ] Created PDFs: all pages have consistent margins and formatting - [ ] Merged/split files: page count verified (sum of inputs = merged output) - [ ] Encrypted files: verified password opens correctly and wrong password fails - [ ] OCR output: spot-checked for accuracy on 3+ pages - [ ] Output file opens without errors in PDF reader (Adobe, Preview, Chrome) - [ ] No intermediate temp files left on disk