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.
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.
# 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/outexample/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.
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 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 --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.
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 --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 --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.
- Extracted record:
id,table,key,field,value,source(source id, page, bbox, extraction method),confidence(parsed/uncertain), optionallysuggested,ocr_conf,sequence. - Decision line (
decisions.jsonl, append-only):target,kind,resolution,value,at/ts,tool, plus afingerprintwhen 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.