Sponsored Content

DEV Community

YECHI URIEL
YECHI URIEL

Posted on

Building a PDF Table Extractor with FastAPI, pdfplumber and Tesseract OCR

The problem: two kinds of PDFs

Before extracting anything, the tool has to answer one question: does this PDF have real text, or is it just a picture?

def is_scanned_pdf(pdf_bytes: bytes) -> bool:
    """Check if PDF is scanned (no extractable text)."""
    with pdfplumber.open(io.BytesIO(pdf_bytes)) as pdf:
        for page in pdf.pages[:3]:
            text = page.extract_text() or ""
            if len(text.strip()) > 50:
                return False
    return True
Enter fullscreen mode Exit fullscreen mode

It's a cheap heuristic: check the first three pages for any meaningful text layer. If there's essentially nothing, treat it as scanned. This single check decides which of two very different extraction pipelines gets used.

Path 1: native PDFs with pdfplumber

For PDFs with a real text layer, pdfplumber can detect tables directly from the underlying vector lines and text positions. But its default table-detection strategy isn't always reliable, so I run it twice:

tables = page.extract_tables({
    "vertical_strategy": "lines_strict",
    "horizontal_strategy": "lines_strict",
    "snap_tolerance": 3,
    "join_tolerance": 3,
    "edge_min_length": 3,
    "min_words_vertical": 3,
    "min_words_horizontal": 1,
})

# Fallback strategy if no tables found
if not tables:
    tables = page.extract_tables()
Enter fullscreen mode Exit fullscreen mode

First pass: lines_strict, which only trusts actual ruled lines in the PDF — precise, but fails on tables with no visible borders. Second pass: pdfplumber's default strategy, which infers structure from text alignment instead. Lattice first, stream as fallback.

Path 2: scanned PDFs with Tesseract

No text layer means no shortcuts — the page has to be rendered as an image and read with OCR.

images = convert_from_bytes(pdf_bytes, dpi=300)

for page_num, image in enumerate(images, 1):
    data = pytesseract.image_to_data(
        image, lang=lang, output_type=pytesseract.Output.DATAFRAME
    )
    data = data[data.conf > 30].copy()
Enter fullscreen mode Exit fullscreen mode

pytesseract.image_to_data doesn't just return text — it returns every recognized word with its bounding box and a confidence score, as a DataFrame. That's the raw material for reconstructing table structure, since Tesseract has no concept of "this is a table" on its own.

The reconstruction is a simple clustering heuristic: words whose bounding boxes fall within the same horizontal band are grouped into a row, then sorted left-to-right into columns:

data["top_group"] = (data["top"] // 15) * 15
rows_grouped = data.groupby("top_group")

table_rows = []
for _, row_data in rows_grouped:
    row_data = row_data.sort_values("left")
    table_rows.append(row_data["text"].tolist())
Enter fullscreen mode Exit fullscreen mode

This is the part I'm least happy with. Bucketing by a fixed 15px band works fine on clean, well-aligned scans, but breaks down on rotated pages, tight row spacing, or noisy scans where two rows blur into one bucket. It's an open invitation for anyone who's solved this more robustly to send a PR.

Making the Excel output actually usable

A pile of raw strings in a spreadsheet isn't a deliverable. Two details matter more than they seem:

Numeric casting. OCR and pdfplumber both return everything as text, including numbers with currency symbols or European decimal commas. Before writing to Excel, every cell goes through a cast attempt:

def _try_cast(value: str):
    v = str(value).strip()
    cleaned = re.sub(r"[€$£\s]", "", v).replace(",", ".").replace(" ", "")
    try:
        if "." in cleaned:
            return float(cleaned)
        return int(cleaned)
    except Exception:
        return v
Enter fullscreen mode Exit fullscreen mode

So "1 200,50 €" becomes an actual float in the spreadsheet, not a string Excel can't sum.

A summary sheet. When a PDF has several tables spread across pages, dumping them into separate sheets named Sheet1, Sheet2... is disorienting. Instead, sheets are named Page{n}_T{i}, and a "Résumé" sheet is generated first, listing every extracted table with its page, row/column count, and extraction method — so you know what you're looking at before diving into the data.

Shipping it

The whole thing is a FastAPI app with three routes (/, /extract, /download/{job_id}) and a vanilla JS drag-and-drop frontend — no frontend framework needed for something this small.

It's Dockerized, with Tesseract and Poppler baked into the image so there's no "works on my machine" install step:

FROM python:3.11-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
    tesseract-ocr tesseract-ocr-fra poppler-utils \
    && rm -rf /var/lib/apt/lists/*
Enter fullscreen mode Exit fullscreen mode

And it deploys to Render with a single render.yaml, which is what powers the live demo.

What's next

The OCR row-clustering heuristic is the obvious next thing to improve — better handling of skewed scans, merged cells, and multi-line cell content would make the scanned-PDF path much more reliable. If you've tackled this kind of problem before, I'd love to hear how, or see a PR.

Repo: https://github.com/Yedmithra/pdf-table-extractor — MIT licensed, contributions welcome.

Top comments (1)

Collapse
 
reidmarlow profile image
Reid Marlow

For the OCR row clustering step, fixed pixel bucketing like integer division on the y-coordinate almost always trips over slight page rotation and multi-line cells.

One lightweight step that helps before clustering is running a quick deskew pass on the rasterized image before handing it to Tesseract. Even a half-degree tilt drifts bounding boxes across a fixed pixel bucket over the width of a page.

For the grouping itself, sorting words vertically and merging boxes when vertical intersection over height exceeds thirty or forty percent avoids hardcoding a pixel constant. It adapts when font sizes vary between header lines and body rows.