A vendor sent me a pricing PDF this week: 40 rows of part numbers and
quantities, no borders, no shading, just columns of text floating on the
page. Extracting tables from PDFs is a solved problem, right? There are
two famous Python libraries for it and a manual route everyone falls back
on.
So I ran the full stack on it. pdfplumber's extract_tables() returned an
empty list. Camelot's lattice flavor returned zero tables. Neither printed
a warning. Same PDF, opened in any viewer, obviously contains a table —
two of my four routes looked at it and found nothing.
That's the kind of failure that costs an afternoon, so I built a small
benchmark to find out exactly which route fails on which shape of table.
Three PDF archetypes, four extraction routes, cell-by-cell scoring against
known ground truth.
The three table shapes
I generated the PDFs myself with reportlab — synthetic files, but each one
reproduces a shape I actually meet in the wild, and generating them means
I have the exact ground truth to score against, no hand-labeling:
- Bordered invoice — full grid lines, 30 rows × 6 columns (SKU, description, qty, prices). The easy case.
- Borderless financial statement — 40 rows × 5 columns, columns aligned by whitespace only, with deliberately tight spacing so adjacent values nearly touch. Bank statements and management reports look exactly like this.
- Messy catalog — 3 pages, 45 data rows, merged header cells ("Pricing (USD)" spanning two columns), multi-line wrapped text inside cells, and some empty cells. Exported-from-somewhere reality.
The four routes (plus one folk fix)
# Route 1: pdfplumber, default settings
with pdfplumber.open(path) as pdf:
tables = [t for page in pdf.pages for t in page.extract_tables()]
# Route 2: the folk fix — switch to text-based strategies
settings = {"vertical_strategy": "text", "horizontal_strategy": "text"}
tables = [t for page in pdf.pages for t in page.extract_tables(settings)]
# Routes 3 & 4: Camelot, both flavors
camelot.read_pdf(path, pages="all", flavor="lattice") # finds ruling lines
camelot.read_pdf(path, pages="all", flavor="stream") # infers from word positions
# Route 5: the manual route, automated — pdftotext -layout,
# then split each line on 2+ spaces (this is what "copy the text out,
# paste into a sheet, text-to-columns" actually does to your data)
Scoring, after normalizing whitespace in every cell:
- Row accuracy — the share of ground-truth rows that appear in the output with every cell correct and in order. Strict, and the one that matters: a row you can't trust is a row you'll verify by hand.
- Cell accuracy — share of ground-truth cells present anywhere in the output. Loose. Keep an eye on the gap between the two.
Results
| Shape | pdfplumber default | pdfplumber text-strategy | Camelot lattice | Camelot stream | Copy-paste + split |
|---|---|---|---|---|---|
| Bordered invoice | 100% | 0% rows / 91% cells | 100% | 100% | 100% |
| Borderless statement | 0% — no tables found | 0% / 77% | 0% — no tables found | 100% | 80% rows |
| Merged headers + wrapped cells | 100% | 0% / 76% | 100% | 100% | 26% rows |
(All routes at 100% also had 100% cell accuracy. Single-run timings on
these 1–3 page files: everything 0.02–0.13s except Camelot lattice at
0.4–0.7s — it renders pages to images and runs OpenCV over them, so it's
5–15× slower and the gap grows with page count.)
Finding 1: bordered tables are a solved problem
Everything scored 100% on the invoice — including copy-paste. If your
table has full grid lines and you only have one file, don't write code at
all. Any route works.
Finding 2: borderless tables fail silently, and the folk fix doesn't fix it
This is the one that bit me. pdfplumber's default strategy looks for
ruling lines to infer table structure; with no lines, it finds no table
and returns an empty list. Camelot's lattice flavor has the same
dependency — it exists to detect ruled tables. Two of four routes, zero
output, zero errors.
The internet's standard fix for pdfplumber is switching to
vertical_strategy: "text", horizontal_strategy: "text". It's true that
it finds the words — 77% of cells on the statement. But look at the row
accuracy: 0%. It shreds the layout: my 41-row statement came back as
80 fragments, with header text fused into data rows. You get the words
without the table, and now you're hand-reassembling rows in a
spreadsheet — which is the manual route with extra steps.
Camelot's stream flavor was the only library route that nailed the
borderless statement: 100% of rows, because it clusters words by x-position
instead of looking for lines. And the copy-paste route got 80% — the
tight columns merged in 8 of 41 rows ("Segment A-04 EMEA" fused with the
period column), which is exactly the kind of corruption you don't notice
until a pivot table sums wrong.
Finding 3: wrapped cells are where copy-paste quietly dies
On the catalog, all library routes scored 100% again. Copy-paste got
26% of rows — while getting 100% of cells.
That pair of numbers is the whole story of why cell accuracy is a vanity
metric. pdftotext -layout puts every visual line of a wrapped cell on
its own output line, so a row whose description wraps to 3 lines becomes
3 rows. The data is all there; the structure is gone. On a 45-row catalog
you'd be rejoining rows by hand for half an hour, and it's invisible
until you count the rows and notice you have 51 where 47 should be.
Which route to pick
- Full grid lines, one-off file → copy-paste, honestly.
-
Borderless / whitespace-aligned → Camelot
stream. Test it on page one before trusting the whole file. - Wrapped text, merged cells, multi-page → any library route; manual will cost you an hour and your row count will lie about it.
-
Scanned pages → none of these. That's OCR territory (Tabula's
warpdemic cousin, or
pdfplumberon top of OCR text) — different benchmark, different pain. - Someone non-technical just needs the spreadsheet → an online converter handles all three shapes without a Python environment. I keep one bookmarked for exactly this: iLoveConvert's PDF to Excel converter — no signup, no watermark, files auto-deleted within 24 hours, one free conversion per day as a guest.
The meta-lesson from the benchmark: know which shape you're holding
before picking a route. The failures aren't random — each route has one
shape it can't see, and none of them warn you. An empty result and a
wrong result look identical until you count rows.
Reproduce it
Generator + scorer is ~150 lines total:
reportlab builds the three PDFs and dumps ground truth as JSON; each
route returns rows; scoring is a set lookup for rows and a Counter
intersection for cells. The scoring core:
rows = [tuple(" ".join(str(c).split()) for c in r)
for t in tables for r in t] # normalize every cell
gt = {tuple(r) for r in ground_truth_rows}
row_acc = sum(r in set(rows) for r in gt) / len(gt)
Run it before your PDF of the week does it to you.
Top comments (0)