Extract tables from technical PDFs into clean Excel / CSV / JSON — and get told exactly which cells to double-check.
Most "PDF to Excel" tools hand you a clean-looking sheet and hide their own uncertainty. This one does the opposite: it normalises what it can (EU/US numbers, currencies, dates), and for anything it couldn't confidently parse it keeps the original text, lowers the cell's confidence, and records why — so a human reviews a handful of cells instead of re-checking the whole document.
It handles three kinds of source: ruled tables, borderless tables (recovered by text alignment), and scanned/image-only pages (optional OCR).
Every run produces, alongside the data, a quality report — the trust feature:
.xlsx— one sheet per table, header styled, low-confidence cells highlighted amber with a comment explaining the flag, plus aQuality Reportsheet (overall confidence, scope advisory, per-table strategy, skipped pages)..csv— one file per table (UTF-8 BOM, opens cleanly in Excel)..json— fully structured: every cell carriesvalue,type,confidence,flags,raw; every table carries its extractionstrategy.*_quality.md— a human-readable summary.
This covers both freelance delivery models out of the box: hand the client the output files (done-for-you), or hand them this repo + CLI to run on their own recurring PDFs (build-a-tool).
pip install -r requirements.txt
make sample # generate the bundled sample PDF
make run # extract it to ./outOutput:
Extracted 2 table(s) — overall confidence 0.99
1 cell(s) flagged low-confidence — see the quality report
Wrote: out/sample_quotation.{json,xlsx,csv} + out/sample_quotation_quality.md
The sample deliberately contains the cases real client PDFs have:
| Cell in the PDF | Extracted | Note |
|---|---|---|
€1.234,50 (EU) |
1234.5 |
thousands/decimal resolved |
€1,500.00 (US) |
1500.0 |
same column, US format — still correct |
14/03/2026 |
2026-03-14 |
day-first → ISO |
Mounting bracket\n(stainless) |
Mounting bracket (stainless) |
multi-line cell joined |
| (blank Qty) | null |
flagged: "empty in an otherwise-filled column" |
pdf-table-extractor <pdf> [options]
-o, --out DIR output directory (default: ./out)
-f, --format LIST comma-separated: xlsx,csv,json (default: all three)
-p, --pages RANGE pages to extract, e.g. '1,3,5-8' (default: all)
--ocr OCR scanned/image-only pages (needs the [ocr] extra)
--ocr-dpi N OCR render resolution (default: 300)
-q, --quiet suppress the summary
Scanned pages have no text layer. Install the optional engine and pass --ocr:
pip install '.[ocr]' # rapidocr-onnxruntime
pdf-table-extractor scanned_invoice.pdf --ocrOCR text is reconstructed into a table grid from the recognised boxes, and the
OCR engine confidence is folded into each cell's confidence — so low-quality
reads are flagged exactly like everything else. (Adapted from the OCR fallback in
my schematic-extractor project.)
Accuracy is checked by an offline, deterministic benchmark with known ground
truth (benchmarks/, reusing the evaluation approach from my docchat-rag
project): cell-level precision/recall/F1, no LLM-as-judge.
make benchThe extractor is an ensemble — for each page it gathers candidates from pdfplumber (ruling lines, text alignment) and, when installed, Camelot (lattice, stream), then keeps the best one (segmentation-aware, weighted by Camelot's own reported accuracy). It also stitches tables that continue across a page break. Measured lift on the hard-PDF set:
| Stage | Overall cell-F1 |
|---|---|
| Baseline (pdfplumber only) | 0.83 |
| + stitching + heterogeneous-column normalization | 0.90 |
| + Camelot ensemble | 0.93 |
| + order-insensitive scoring + number recovery | 0.95 |
Read this honestly: it's an internal hard-set benchmark, not a public
leaderboard (PubTabNet/FinTabNet can't be fetched here). Perfect extraction from
PDF is an unsolved problem; even commercial services err. The remaining ~5% is
documented, not hidden — see benchmarks/README.md: the ultra-dense 30-column
header degrades in every engine (and is flagged out-of-scope rather than
trusted), and a couple of genuinely ambiguous numbers are flagged, not guessed.
The path beyond this (Microsoft Table Transformer) is noted there as an optional,
heavy dependency whose real gains are on visual/scanned tables.
from pdf_table_extractor import extract_pdf
from pdf_table_extractor.export import export_document
doc = extract_pdf("invoice.pdf") # add ocr=engine for scanned pages
print(doc.confidence, doc.scope.reason)
export_document(doc, "out", formats=["xlsx", "json"], stem="invoice")PDF page
-> has a text layer?
yes -> ruled tables (pdfplumber lines) strategy = "lines"
none found? -> text-alignment fallback strategy = "text" (borderless)
no -> optional OCR -> grid from boxes strategy = "ocr" (scanned)
-> per-column type inference (EU/US numbers, currency, %, dates)
-> per-cell normalize + confidence + flags
-> scope advisory (is this PDF inside the supported envelope?)
-> export: xlsx / csv / json + quality report
Confidence is a structural trust signal — does this cell look like clean, unambiguous data of its column's type — not an OCR probability. Penalties: type mismatch −0.5, ambiguous separator −0.25, blank-in-filled-column −0.4, multi-line −0.1; OCR cells are additionally capped at the engine's confidence. Cells below 0.7 are flagged in every output.
pip install -r requirements-dev.txt
make test # 31 tests; make lint for ruffThe parsing, confidence, OCR-reconstruction, scope and metric logic is all pure
Python and unit-tested with no PDF and no ONNX runtime needed (tests/).
Found and documented by stress-testing on hard PDFs (see make eval and the
test suite). The tool degrades honestly rather than failing silently:
- Scanned PDFs need
--ocr; OCR quality varies with scan resolution, and OCR'd cells are flagged with the engine's confidence rather than trusted blindly. - Heterogeneous columns (a column mixing numbers,
N/A, and odd formats) fall back to text, so numbers inside them are kept verbatim, not normalized. This is deliberate — better than coercing a column the tool isn't sure about — but it means mixed columns need a human pass. - Ultra-dense / very wide tables (tens of narrow columns) can misalign cells;
per-cell confidence stays high because each value parses, so the scope
advisory is what flags these up front (
in_scope: false). Trust the scope flag, not just per-cell confidence, on dense matrices. - Multi-page tables are stitched when a table continues onto the next page (same column count, header only on page 1). Unusual layouts where the continuation can't be confidently matched are left as separate tables rather than merged on a guess.
- Heavily merged / nested cells produce placeholder headers (
col_2, …) and ragged-row flags rather than a silent guess. - Separator ambiguity: a lone
1.234is genuinely ambiguous (1234 vs 1.234); parsed conservatively and flagged rather than guessed.
Two pieces are adapted from my other repositories, which is why they're already
hardened: the OCR fallback pattern from
schematic-extractor and the
offline evaluation harness from
docchat-rag.