Skip to content

Repository files navigation

PDF table extraction, with provenance and a human in the loop

This is a small toolset for pulling tables out of scanned or typeset PDFs — game manuals, price lists, spec sheets, anything laid out as columns and rows across many pages — into structured JSONL, with every value traceable back to the exact page and pixel region it came from. A companion GUI lets a human walk the output and settle everything the extractor itself flagged as doubtful, and every correction is recorded durably, never silently overwritten.

It grew out of a project that extracted tens of thousands of table cells from scanned rulebooks for a tabletop game. That project is done; this repo is the domain-free core of it, kept because the method is the actual asset. Stdlib-only Python 3.12, plus the poppler utilities (pdftotext, pdftoppm, pdfinfo) and, optionally, tesseract for scanned pages with no text layer.

The method

Every value carries its own provenance. An extracted record names the source id, the PDF page it came from, and the bounding box on that page — so "where did this number come from" is always answerable by rendering the box, not by trusting the extractor.

Uncertain is a queue, not a failure. Low OCR confidence, a digit that's a classic OCR confusable, a value that trips a declared invariant — none of these block the run. They mark the record "confidence": "uncertain" with a suggested fix and a reason, and move on. The review tool's whole job is walking that queue to zero, not re-running the extractor until it's quiet.

Invariants are declared per run, not inferred. You tell the extractor what should hold — "this column never decreases," "this row's values are non-increasing left to right" — with --monotonic or a named --invariant. A violation is a top-priority review flag. It is never auto-corrected: the tool doesn't get to guess which of two conflicting numbers is right, only to say loudly that they conflict.

Corrections carry evidence, and never destroy the original. When a cell's printed value turns out to be wrong (a books-and-errata scenario, a later-printing fix, whatever your source domain's equivalent is), errata.py records the correction as an erratum: the corrected value lives beside the original printed_value, never in place of it, and applying an erratum is refused outright if the target cell doesn't currently read what the erratum claims was printed. A correction with no evidence that stands on its own isn't a correction.

Decisions are append-only, keyed by content, not by sequence. decisions.jsonl is opened in append mode and never rewritten — a superseded decision is just a later line, and a crash mid-session loses at most the one item on screen. Records are matched to findings by content fingerprint, not by a sequential id, because ids renumber when the extraction re-runs and a fingerprint doesn't.

An unread source looks exactly like agreement. If a tool is meant to consult three documents and only two were actually available, silence about the third reads the same as "checked and it agreed." Every tool that compares sources reports which sources it actually consulted, loudly, not just what it found.

OCR has sharp edges worth knowing before you hit them. A numeric column benefits from a tesseract character whitelist — free-form OCR invents letters into number columns more than you'd expect. pdftotext -layout silently drops words in a way that bbox-based reading catches. Some text layers have invisible "ghost" text that doesn't match what's printed on the page (a raster render — comparing pixels, not the text layer — is the only way to be sure). And for an already-scanned source, the embedded image's DPI is a hard ceiling on how much sharper any amount of processing can make the OCR.

Quickstart

# 1. build a small worked example (no real PDFs needed)
python3 example/make_example_pdf.py
bash example/run_example.sh

# 2. inspect what it produced
cat example/out/extracted/*.jsonl

# 3. open the review GUI on it
python3 review.py --data-dir example/out

example/run_example.sh builds a synthetic multi-page PDF, extracts all three table layouts from it, shows the extractor's invariant check catching a value it planted specifically to be wrong, corrects it with errata.py (keeping both values), and leaves a queue for review.py to open. It's also the project's integration test — see smoke.sh.

Tool reference

extract.py — the extraction engine

extract.py --build-sources --staging-dir sources/
extract.py --doc <source-id> --pages 12-40 --table my-table \
    --header-rows 1 --key-cols 0 [--region x0,y0,x1,y1] \
    [--monotonic col:asc] [--invariant NAME] [--ocr]
extract.py --self-test

Sources are identified by a registry id, not a bare filename. --build-sources scans --staging-dir (default ./sources) and writes sources.json, measuring each PDF's page count and whether it has a native text layer. --doc then resolves an id (unique prefixes accepted) to a file; --pdf bypasses the registry entirely for a one-off path.

Extraction runs page-region by page-region: --pages selects a range, --region optionally restricts to a bounding box, --header-rows and --key-cols describe the table shape, and the extractor auto-detects which of three layouts it's reading — positional (columns defined by gutters), grid (ruled lines), or leaders (dotted/dashed leader lines connecting a label to a value). --value-re and --row-key-re tune what counts as a value versus a stray token or a row boundary; --merge-continuations joins a wrapped row back together. --dump-grid --stdout is the fastest way to see what the extractor recovered before committing to a run.

Every extracted record is {id, table, key, field, value, source: {source, page, bbox, via}, confidence, ...}confidence is "parsed" or "uncertain", and an uncertain record carries a suggested value, an ocr_conf score, and/or a sequence object describing the invariant it tripped.

--check-ocr reports whether the OCR path (tesseract) is usable at all; --no-ocr refuses to touch image-only sources rather than silently skipping them.

render.py — page rendering and crops

render.py sources                       # list resolvable sources
render.py size   --source <id> --page N
render.py page   --source <id> --page N [--out FILE]
render.py crop   --source <id> --page N --bbox x0,y0,x1,y1 [--out FILE]
render.py probe  --source <id> --page N --bbox x0,y0,x1,y1   # arithmetic only
render.py words  --source <id> --page N --bbox x0,y0,x1,y1   # text-layer words in region
render.py dupes                         # source ids sharing one physical file

Handles the PDF-points-to-pixels arithmetic (including media/crop-box offsets), page rotation, and highlighted crop generation used by the review GUI to show a reviewer exactly the region a value came from.

review.py — the review GUI

review.py --data-dir DIR [--rulings FILE ...] [--port 8000] [--no-browser]
review.py --self-test

A local HTTP server with a keyboard-first queue view and a /tables grid view (see tables.py). The queue surfaces every "uncertain" record and every cross-source discrepancy; a decision on either is appended to decisions.jsonl with a content fingerprint, never overwriting a prior line. A discrepancy that gets settled is suppressed from the queue on future runs — it doesn't reappear just because the extraction re-ran.

--rulings is a repeatable flag naming JSONL files of out-of-band human calls (questions answered outside the extracted-value queue itself, e.g. from a review conversation) — pass zero, one, or several; with none, that section of the GUI is simply absent.

tables.py — the table-at-a-glance view

Not run directly; it's what review.py's /tables route serves. Turns extracted/*.jsonl back into the grid it was read out of — rows and columns ordered by their position on the page, not by field name — and renders it beside the page image with every cell's bbox drawn on it, for scanning a whole table for what looks wrong instead of walking it one value at a time.

errata.py — corrections with evidence

errata.py --check [--errata errata.json] [--extracted extracted/]
errata.py --apply [--errata errata.json] [--extracted extracted/]
errata.py --self-test

--errata and --extracted default to errata.json and extracted/ next to errata.py itself; pass them explicitly to point at a different data directory (see example/run_example.sh). An erratum names a target cell, the value it claims was printed, and the corrected value. --apply refuses any erratum whose target doesn't currently read the claimed printed value — so a stale or mistargeted erratum fails loudly instead of corrupting an unrelated cell. Applying twice is idempotent; the printed value is always kept alongside the correction, never discarded.

warm_ocr.py — pre-warm the OCR cache

warm_ocr.py --staging-dir sources/ [--doc ID] [--limit N] [--dpi N]

Renders and OCRs every page of every resolvable source up front, so a later extraction run reads from cache instead of paying OCR cost inline. Resumable — pages already cached are skipped.

Record shapes, in short

  • Extracted record: id, table, key, field, value, source (source id, page, bbox, extraction method), confidence (parsed/uncertain), optionally suggested, ocr_conf, sequence.
  • Decision line (decisions.jsonl, append-only): target, kind, resolution, value, at/ts, tool, plus a fingerprint when the target is a discrepancy — ids renumber across extraction re-runs, fingerprints don't.
  • Erratum: target cell, printed_value (what the source shows), value (the correction), evidence for the correction.

About

Extract tables from scanned or typeset PDFs into JSONL with per-value page+bbox provenance, verify them with a human review GUI, and record every correction durably. Stdlib-only Python + poppler.

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages