By Sagar Shankaran, Founder of CallSphere
Build a PDF processing agent that extracts text, tables, and charts from documents using Python. Covers page-level parsing, table detection with pdfplumber, chart analysis with vision models, and structured output generation.
Key takeaways
PDFs are the most common format for business documents, yet they are notoriously difficult to process programmatically. A single PDF might contain flowing paragraphs, multi-column layouts, embedded tables, charts rendered as vector graphics, and scanned images of handwritten notes. An effective PDF processing agent must detect and handle each of these content types with the right tool.
The agent follows a three-stage pipeline:
flowchart LR
PDF(["PDF or image"])
OCR["OCR plus layout<br/>LayoutLM or Donut"]
DETECT["Table detector<br/>bounding boxes"]
STRUCT["Cell structure<br/>rows and columns"]
LLM["LLM normalization<br/>headers and types"]
VAL["Schema validation<br/>Pydantic"]
DB[(Structured store)]
OUT(["Clean rows"])
PDF --> OCR --> DETECT --> STRUCT --> LLM --> VAL --> DB --> OUT
style LLM fill:#4f46e5,stroke:#4338ca,color:#fff
style VAL fill:#f59e0b,stroke:#d97706,color:#1f2937
style OUT fill:#059669,stroke:#047857,color:#fff
Install the required dependencies:
Hear it before you finish reading
Talk to a live CallSphere AI voice agent in your browser — 60 seconds, no signup.
pip install pdfplumber pymupdf pillow openai
Start by extracting both text and rendered images from each page. Having both representations lets the agent fall back to vision-based analysis when text extraction fails:
import pdfplumber
import fitz # PyMuPDF
from dataclasses import dataclass, field
from PIL import Image
import io
@dataclass
class PageContent:
page_number: int
raw_text: str
image: Image.Image
tables: list[list[list[str]]] = field(default_factory=list)
has_charts: bool = False
def extract_pages(pdf_path: str) -> list[PageContent]:
"""Extract text and images from every page of a PDF."""
pages = []
# Use pdfplumber for text and tables
with pdfplumber.open(pdf_path) as pdf:
plumber_pages = list(pdf.pages)
# Use PyMuPDF for page images
doc = fitz.open(pdf_path)
for i, plumber_page in enumerate(plumber_pages):
# Extract raw text
raw_text = plumber_page.extract_text() or ""
# Extract tables
tables = plumber_page.extract_tables() or []
cleaned_tables = []
for table in tables:
cleaned = [
[cell or "" for cell in row]
for row in table
if any(cell for cell in row)
]
if cleaned:
cleaned_tables.append(cleaned)
# Render page as image
mupdf_page = doc[i]
mat = fitz.Matrix(2.0, 2.0) # 2x zoom for clarity
pix = mupdf_page.get_pixmap(matrix=mat)
img = Image.open(io.BytesIO(pix.tobytes("png")))
pages.append(PageContent(
page_number=i + 1,
raw_text=raw_text,
image=img,
tables=cleaned_tables,
))
doc.close()
return pages
Tables are extracted directly by pdfplumber, but charts — bar graphs, pie charts, line plots — are rendered as graphics with no extractable text. Detect them by checking for visual elements without corresponding text:
def detect_charts(page: PageContent) -> bool:
"""Heuristic: a page likely has charts if it has
little text but significant visual content."""
text_density = len(page.raw_text.strip())
# Pages with tables already accounted for
if page.tables:
text_in_tables = sum(
len(cell)
for table in page.tables
for row in table
for cell in row
)
non_table_text = text_density - text_in_tables
else:
non_table_text = text_density
# If page has very little non-table text, likely has
# charts or figures
return non_table_text < 200 and text_density < 500
For robust chart detection, send the page image to a vision model:
import openai
import base64
async def analyze_chart(
img: Image.Image, client: openai.AsyncOpenAI
) -> dict:
"""Use GPT-4o to extract data from a chart image."""
buf = io.BytesIO()
img.save(buf, format="PNG")
b64 = base64.b64encode(buf.getvalue()).decode()
response = await client.chat.completions.create(
model="gpt-4o",
messages=[{
"role": "user",
"content": [
{
"type": "text",
"text": (
"Analyze this chart. Return a JSON object with: "
"chart_type, title, x_axis_label, y_axis_label, "
"and data_points as a list of {label, value} objects."
),
},
{
"type": "image_url",
"image_url": {"url": f"data:image/png;base64,{b64}"},
},
],
}],
response_format={"type": "json_object"},
)
import json
return json.loads(response.choices[0].message.content)
Combine everything into an agent that answers questions about PDF content:
Still reading? Stop comparing — try CallSphere live.
CallSphere ships complete AI voice agents per industry — 14 tools for healthcare, 10 agents for real estate, 4 specialists for salons. See how it actually handles a call before you book a demo.
class PDFProcessingAgent:
def __init__(self):
self.client = openai.AsyncOpenAI()
self.pages: list[PageContent] = []
def load(self, pdf_path: str) -> int:
"""Load a PDF and return the page count."""
self.pages = extract_pages(pdf_path)
for page in self.pages:
page.has_charts = detect_charts(page)
return len(self.pages)
def _format_tables(self, tables: list[list[list[str]]]) -> str:
"""Convert tables to markdown format."""
parts = []
for table in tables:
if not table:
continue
header = "| " + " | ".join(table[0]) + " |"
sep = "| " + " | ".join("---" for _ in table[0]) + " |"
rows = [
"| " + " | ".join(row) + " |"
for row in table[1:]
]
parts.append("\n".join([header, sep] + rows))
return "\n\n".join(parts)
async def query(self, question: str) -> str:
"""Answer a question about the loaded PDF."""
context_parts = []
for page in self.pages:
parts = [f"--- Page {page.page_number} ---"]
if page.raw_text.strip():
parts.append(page.raw_text.strip())
if page.tables:
parts.append(
"Tables:\n" + self._format_tables(page.tables)
)
if page.has_charts:
chart_data = await analyze_chart(
page.image, self.client
)
parts.append(f"Chart data: {chart_data}")
context_parts.append("\n".join(parts))
full_context = "\n\n".join(context_parts)
response = await self.client.chat.completions.create(
model="gpt-4o",
messages=[
{
"role": "system",
"content": (
"You are a document analysis agent. Answer "
"questions based on the extracted PDF content."
),
},
{
"role": "user",
"content": (
f"Document content:\n{full_context}\n\n"
f"Question: {question}"
),
},
],
)
return response.choices[0].message.content
import asyncio
async def main():
agent = PDFProcessingAgent()
page_count = agent.load("quarterly_report.pdf")
print(f"Loaded {page_count} pages")
answer = await agent.query(
"What was the revenue growth rate in Q3?"
)
print(answer)
asyncio.run(main())
For scanned PDFs, pdfplumber returns empty text. In that case, fall back to OCR by running Tesseract on the rendered page image. Add a check in the extraction stage: if raw_text is empty or very short, apply pytesseract.image_to_string(page.image) and use that as the text content.
Pdfplumber handles simple tables well but struggles with merged cells, nested headers, and spanning rows. For complex tables, send the page image to GPT-4o with a prompt asking it to extract the table as a JSON array. The vision model understands visual table structure better than rule-based parsers for complex layouts.
Process pages in batches rather than loading the entire document at once. Modify extract_pages to yield pages lazily using a generator. For the agent query step, first identify which pages are relevant to the question using a lightweight text search or embedding-based retrieval, then only process those pages in detail.
#PDFProcessing #DocumentAI #TableExtraction #ChartAnalysis #Python #AgenticAI #LearnAI #AIEngineering

Written by
Sagar Shankaran· Founder, CallSphere
LinkedInSagar Shankaran is the founder of CallSphere, where he builds production AI voice and chat agents deployed across healthcare, hospitality, real estate, and home services. He writes about agentic AI, LLM engineering, and shipping voice agents that handle real calls in production.
See how AI voice agents work for your industry. Live demo available -- no signup required.
Step-by-step build of a working agent with the OpenAI Agents SDK — Agent class, tools, handoffs, tracing — plus an eval pipeline that catches regressions before merge.
Smolagents lets agents write Python instead of JSON. Why code-as-action reduces tool errors and where the security trade-offs are for production deployments.
Modal turns a Python function into autoscaling serverless compute with optional GPU. Deploy a LiveKit Agent with one command and get pay-per-second billing.
Pydantic AI's April release tightens the typed-agent loop and adds structured tool definitions. Why type-safe agents reduce production bugs and speed iteration.
Index a knowledge base with text-embedding-3-large into ChromaDB, expose a retrieve tool to your voice agent, and ground every answer in real documents — full Python tutorial.
Shrink an AI voice agent image from 950MB to 80MB with a Python 3.13 multi-stage build, uv for deps, and gcr.io/distroless/python3 nonroot. Real Dockerfile + benchmarks.
© 2026 CallSphere Inc. All rights reserved.
Made within San Francisco
Watch how CallSphere handles real customer calls, schedules appointments, and processes payments — live.