reportlab in-memory PDFs merged, split and rotated with pypdf: working versions (tested 2026-10-16)
Generate PDFs in memory with reportlab, then merge, split, rotate and read metadata with pypdf, including the metadata and rotation gotchas.
passed6 passed · 0 failed2026-10-16 21:57 UTC- Status
- passed
- Last passed
- 2026-10-16 21:57 UTC
- Tests
- 6 / 6
- Language
- Python
- Runtime
- Python 3.13.16
pinned versions3
pypdf@6.21.0reportlab@5.0.1pytest@9.1.1
run it
uv venv -q .venv && uv pip install -q --python .venv/bin/python -r requirements.txt .venv/bin/python -m pytest -q
test environment
Python 3.13.16 linux 6.17.0-1022-azure x64 runner: github-actions 38972656245
The code
Exactly the files that passed. Your agent can pull them with get_recipe("pypdf-reportlab-merge-split") (install), or see them on GitHub.
requirements.txt
pypdf==6.21.0 reportlab==5.0.1 pytest==9.1.1
pdf_tools.py
from io import BytesIO
from pypdf import PdfReader, PdfWriter
from reportlab.lib.pagesizes import letter
from reportlab.pdfgen import canvas
def make_pdf(text: str, pages: int = 1, title: str | None = None, author: str | None = None) -> BytesIO:
buf = BytesIO()
c = canvas.Canvas(buf, pagesize=letter)
if title:
c.setTitle(title)
if author:
c.setAuthor(author)
for i in range(pages):
c.drawString(72, 720, f"{text} page {i + 1}")
c.showPage()
c.save()
return _rewind(buf)
def merge_pdfs(pdfs, keep_first_metadata: bool = True) -> BytesIO:
writer = PdfWriter()
readers = [PdfReader(_rewind(p)) for p in pdfs]
for reader in readers:
for page in reader.pages:
writer.add_page(page)
if keep_first_metadata and readers and readers[0].metadata:
src = readers[0].metadata
writer.add_metadata({"/Title": src.title or "", "/Author": src.author or ""})
out = BytesIO()
writer.write(out)
return _rewind(out)
def split_pdf(pdf, indices) -> BytesIO:
reader = PdfReader(_rewind(pdf))
writer = PdfWriter()
for i in indices:
writer.add_page(reader.pages[i])
out = BytesIO()
writer.write(out)
return _rewind(out)
def rotate_page(pdf, index: int, degrees: int) -> BytesIO:
reader = PdfReader(_rewind(pdf))
writer = PdfWriter()
writer.add_page(reader.pages[index])
writer.pages[0].rotate(degrees)
out = BytesIO()
writer.write(out)
return _rewind(out)
def read_metadata(pdf) -> dict:
meta = PdfReader(_rewind(pdf)).metadata
return {"title": meta.title, "author": meta.author} if meta else {"title": None, "author": None}
def page_count(pdf) -> int:
return len(PdfReader(_rewind(pdf)).pages)
def _rewind(buf: BytesIO) -> BytesIO:
buf.seek(0)
return buf
tests/test_pdf_tools.py
from io import BytesIO
from pypdf import PdfReader, PdfWriter
from pdf_tools import (
make_pdf,
merge_pdfs,
page_count,
read_metadata,
rotate_page,
split_pdf,
)
def test_merge_keeps_page_order_and_count():
merged = merge_pdfs([make_pdf("a", 2), make_pdf("b", 1)])
reader = PdfReader(merged)
assert page_count(merged) == 3
assert reader.pages[0].extract_text() == "a page 1\n"
assert reader.pages[2].extract_text() == "b page 1\n"
def test_add_page_drops_document_metadata():
src = make_pdf("a", 1, title="Report A", author="Ann")
writer = PdfWriter()
for page in PdfReader(src).pages:
writer.add_page(page)
out = BytesIO()
writer.write(out)
assert read_metadata(BytesIO(out.getvalue()))["title"] is None
def test_merge_preserves_first_metadata():
merged = merge_pdfs(
[make_pdf("a", 1, title="Report A", author="Ann"), make_pdf("b", 1, title="Report B")]
)
meta = read_metadata(merged)
assert meta["title"] == "Report A"
assert meta["author"] == "Ann"
def test_rotate_accumulates_on_the_page():
once = rotate_page(make_pdf("a", 1), 0, 90)
twice = rotate_page(once, 0, 90)
assert PdfReader(once).pages[0].rotation == 90
assert PdfReader(twice).pages[0].rotation == 180
def test_split_selects_indices_in_order():
parts = split_pdf(make_pdf("a", 3), [2, 0])
reader = PdfReader(parts)
assert page_count(parts) == 2
assert reader.pages[0].extract_text() == "a page 3\n"
assert reader.pages[1].extract_text() == "a page 1\n"
def test_reportlab_defaults_author_to_anonymous():
meta = read_metadata(make_pdf("a", 1, title="Only Title"))
assert meta["title"] == "Only Title"
assert meta["author"] == "anonymous"
pytest.ini
[pytest] pythonpath = .
Connect to ProvenPin
Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.
terminal
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp
~/.cursor/mcp.json
{ "mcpServers": { "provenpin": { "url": "https://mcp.provenpin.com/mcp" } } }.vscode/mcp.json
{ "servers": { "provenpin": { "type": "http", "url": "https://mcp.provenpin.com/mcp" } } }~/.codeium/windsurf/mcp_config.json
{ "mcpServers": { "provenpin": { "serverUrl": "https://mcp.provenpin.com/mcp" } } }MCP settings
{ "mcpServers": { "provenpin": { "type": "streamableHttp", "url": "https://mcp.provenpin.com/mcp" } } }Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.