| name | pdf |
| description | Read, extract (text/tables), create, merge/split/rotate, watermark, encrypt, fill, and render-to-image .pdf files. Use whenever the user uploads a .pdf or asks to produce, edit, or pull data out of one. |
| tags | ["tool","office"] |
| requires | {"sandbox":"shell"} |
PDF
Work PDFs in the sandbox with preinstalled Python libs. Pick the library by task:
- Extract text/tables/layout/word-coordinates →
pdfplumber; quick raw text or page ops → pypdf.
- Merge / split / rotate / crop / watermark / encrypt / metadata →
pypdf.
- Fill forms →
pypdf (fillable AcroForm fields) or annotation overlay (flat forms).
- Create from scratch →
reportlab.
Write complete Python source and run it via code_execution. Save outputs to the workspace dir.
After execution, refer to the PDF exactly as the Generated artifacts list names it. Use exec only for a genuinely shell-only command; never put this source in python -c or a heredoc.
Extract text and tables (pdfplumber)
import pdfplumber
with pdfplumber.open("in.pdf") as pdf:
for i, page in enumerate(pdf.pages, 1):
print(f"--- page {i} ---")
print(page.extract_text() or "")
for t in page.extract_tables():
for row in t:
print(row)
Tables → DataFrame/Excel:
import pdfplumber, pandas as pd
frames = []
with pdfplumber.open("in.pdf") as pdf:
for page in pdf.pages:
for t in page.extract_tables():
if t and len(t) > 1:
frames.append(pd.DataFrame(t[1:], columns=t[0]))
if frames:
pd.concat(frames, ignore_index=True).to_excel("tables.xlsx", index=False)
Messy tables: pass strategies, or crop a region with page.within_bbox((x0, top, x1, bottom)) first:
ts = {
"vertical_strategy": "lines",
"horizontal_strategy": "lines",
"snap_tolerance": 3,
"intersection_tolerance": 15,
}
page.extract_tables(ts)
For very large PDFs where you only need raw text, pypdf's page.extract_text() is lighter.
Scanned / image-only PDFs (be honest)
If extract_text() returns empty or garbage (e.g. (cid:NN) runs) the page is scanned. No OCR engine (tesseract) is installed and network is off, so you cannot recover that text. Say so plainly and stop — do not fabricate content or attempt pip install.
Merge / split / rotate / crop / metadata (pypdf)
from pypdf import PdfReader, PdfWriter
w = PdfWriter()
for f in ["a.pdf", "b.pdf"]:
for p in PdfReader(f).pages:
w.add_page(p)
w.write("merged.pdf")
r = PdfReader("in.pdf")
for i, p in enumerate(r.pages, 1):
w = PdfWriter()
w.add_page(p)
w.write(f"page_{i}.pdf")
r = PdfReader("in.pdf")
w = PdfWriter()
r.pages[0].rotate(90)
w.add_page(r.pages[0])
w.write("rotated.pdf")
- Metadata:
PdfReader("in.pdf").metadata (.title, .author, ...).
- Crop: set
page.mediabox.left/bottom/right/top (points, origin y=0 at bottom).
- Encrypt:
w = PdfWriter(clone_from=PdfReader("in.pdf")); w.encrypt("userpw", "ownerpw"); w.write("enc.pdf").
- Decrypt:
r = PdfReader("enc.pdf"); r.decrypt("pw") if r.is_encrypted, then read/copy pages.
Watermark (stamp one page over every page):
from pypdf import PdfReader, PdfWriter
wm = PdfReader("stamp.pdf").pages[0]
r = PdfReader("in.pdf")
w = PdfWriter()
for p in r.pages:
p.merge_page(wm)
w.add_page(p)
w.write("stamped.pdf")
Create PDFs (reportlab)
Flowing document (preferred for text/reports/tables — handles pagination):
from reportlab.lib.pagesizes import letter
from reportlab.lib.styles import getSampleStyleSheet
from reportlab.lib import colors
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle
styles = getSampleStyleSheet()
story = [
Paragraph("Report Title", styles["Title"]),
Spacer(1, 12),
Paragraph("Body text. " * 20, styles["Normal"]),
]
data = [["Product", "Q1", "Q2"], ["Widgets", "120", "135"]]
tbl = Table(data)
tbl.setStyle(
TableStyle(
[
("BACKGROUND", (0, 0), (-1, 0), colors.grey),
("TEXTCOLOR", (0, 0), (-1, 0), colors.whitesmoke),
("GRID", (0, 0), (-1, -1), 0.5, colors.black),
]
)
)
story += [Spacer(1, 12), tbl]
SimpleDocTemplate("out.pdf", pagesize=letter).build(story)
Absolute placement (labels at fixed coordinates): use canvas.Canvas("out.pdf", pagesize=letter), c.drawString(x, y, "...") (origin bottom-left, points), c.showPage() per page, c.save().
Non-Latin text (Chinese / Japanese / Korean, Cyrillic, …)
reportlab's built-in fonts (Helvetica/Times/Courier) carry zero CJK glyphs, so any 中文/日本語/한국어 renders as empty boxes (□) baked permanently into the PDF. reportlab never auto-discovers system fonts — you MUST register a font that has the glyphs and set it on every style. Whenever the document may contain non-Latin text, register a CJK font first (it also covers Latin, so it is safe to use as the only font):
import os
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
def register_cjk_font(name="CJK"):
for path in [
"/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
"/usr/share/fonts/truetype/wqy/wqy-microhei.ttc",
"/System/Library/Fonts/STHeiti Light.ttc",
"/System/Library/Fonts/Hiragino Sans GB.ttc",
"/System/Library/Fonts/Supplemental/Songti.ttc",
"/System/Library/Fonts/Supplemental/Arial Unicode.ttf",
"C:/Windows/Fonts/msyh.ttc",
]:
if os.path.exists(path):
try:
pdfmetrics.registerFont(TTFont(name, path, subfontIndex=0))
return name
except Exception:
continue
raise RuntimeError("No CJK-capable TrueType font found — do not emit tofu; say so.")
font = register_cjk_font()
styles = getSampleStyleSheet()
for s in styles.byName.values():
s.fontName = font
If register_cjk_font raises (no font on the host), do not ship a tofu PDF — tell the user the sandbox lacks a CJK font instead of producing garbage.
Gotcha: even with a good font, reportlab still needs markup for subscripts/superscripts. In Paragraph use Paragraph("H<sub>2</sub>O", styles["Normal"]), x<super>2</super>.
Markdown/HTML → PDF needs an external converter (soffice/pandoc) that is usually absent — command -v soffice / command -v pandoc and degrade to building the PDF directly with reportlab if neither is present.
Fill forms (pypdf)
First detect whether the PDF has real fillable (AcroForm) fields:
from pypdf import PdfReader
fields = PdfReader("form.pdf").get_fields()
print("fillable" if fields else "flat (no fields)")
Fillable — inspect field names/types, then fill and write:
from pypdf import PdfReader, PdfWriter
r = PdfReader("form.pdf")
for name, f in r.get_fields().items():
print(name, f.get("/FT"), f.get("/_States_"))
w = PdfWriter(clone_from=r)
values = {"first_name": "Bart", "agree": "/Yes"}
for page in w.pages:
w.update_page_form_field_values(page, values, auto_regenerate=False)
w.set_need_appearances_writer(True)
w.write("filled.pdf")
Checkbox/radio values are on-state strings, not booleans — read the field's /_States_ (e.g. /Yes, /On); /Off clears it.
Flat form (no fields) — overlay text with FreeText annotations at PDF coordinates. Get real coordinates from the layout with pdfplumber instead of guessing:
import pdfplumber
with pdfplumber.open("form.pdf") as pdf:
pg = pdf.pages[0]
for wd in pg.extract_words():
print(wd["text"], wd["x0"], wd["top"])
for rc in pg.rects:
print("rect", rc["x0"], rc["top"], rc["x1"], rc["bottom"])
pdfplumber top is measured from the page top; pypdf rects are bottom-left, so convert: pdf_y = page_height - top. Place text just right of the matching label:
from pypdf import PdfReader, PdfWriter
from pypdf.annotations import FreeText
r = PdfReader("form.pdf")
w = PdfWriter()
w.append(r)
h = float(r.pages[0].mediabox.height)
top = 700
w.add_annotation(
page_number=0,
annotation=FreeText(
text="Smith",
rect=(255, h - top - 14, 720, h - top),
font="Helvetica",
font_size="10pt",
font_color="000000",
border_color=None,
background_color=None,
),
)
w.write("filled.pdf")
Verify: re-open the output and re-read get_fields() values (fillable) or re-extract text (overlay) to confirm the values landed.
Page → image rendering (PyMuPDF)
PyMuPDF (imported as fitz, preinstalled) rasterizes pages — useful to inspect a PDF visually or to hand a page to an image-capable step. No external tools needed (poppler / pdf2image are absent; don't reach for them).
import fitz
doc = fitz.open("in.pdf")
for i, page in enumerate(doc, 1):
page.get_pixmap(dpi=150).save(f"page_{i}.png")
fitz also extracts text (page.get_text()) and can render a sub-region via page.get_pixmap(clip=fitz.Rect(x0, y0, x1, y1)). It does not OCR — a rendered scanned page is still just pixels (see Scanned PDFs above).