ProvenPin Install

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

Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.