Structural extraction pipeline for scanned PDFs. Turns regulations, resolutions and legal documents into structured text, navigable Markdown and hierarchical JSON — preserving exact reading order and keeping tables as tables.
Built to process Colombian health-sector regulation, where the source documents are scans of printed pages full of bordered tables, and where a table flattened into loose lines of text is worse than no extraction at all.
Most OCR pipelines hand the model a whole page and try to reconstruct structure from the text it returns. That loses tables — cells come back as a stream of fragments, and any attempt to rebuild rows and columns from coordinates afterwards is guesswork.
This pipeline inverts the order. OpenCV finds the structure before the OCR model sees anything, so the model never looks at a full page — only at regions already known to be paragraphs, or at the exact pixels of a single table cell:
PDF
└─▶ Pages → PNG (pdf2image, 300 DPI)
└─▶ For each page:
├─▶ TableLineDetector (morphological ops: erode/dilate to isolate
│ │ horizontal + vertical rules, intersect to a grid)
│ └─▶ Crop each cell → batch OCR → TableStructure(rows, cols, spans)
└─▶ TextRegionDetector (table areas masked out first)
└─▶ Crop each region → OCR → paragraph
└─▶ Merge into one stream ordered by (page, Y)
└─▶ Markdown + JSON
Two consequences worth noting:
- A cell's text is unambiguous. It came from a crop of that cell, so row/column assignment is geometric fact, not inference. Row and column spans survive.
- Reading order is the page's own. Regions are sorted top-to-bottom by their bounding box, so a Markdown table lands exactly where it sat between two paragraphs.
If a page yields no text regions, the pipeline falls back to full-page OCR with the table areas masked, so nothing is silently dropped.
PDFs that already carry vector text skip all of this. is_vectorial_pdf() samples the first
pages, and if there is a real text layer the pipeline reads it with pdfplumber — no rasterising,
no OCR, no GPU.
- Docker with the NVIDIA Container Toolkit
- An NVIDIA GPU with CUDA 12.3 support, driver ≥ 525
The OCR engine is PP-OCRv5 server (detection + recognition) via PaddleOCR 3.x on
PaddlePaddle GPU 3.1.0. --no-gpu forces CPU, at a considerable cost in speed.
docker compose build # first time only
docker compose run --rm ocr data/document.pdf -o data/output/
docker compose run --rm ocr data/document.pdf --pages 1 2 3 -o data/output/
docker compose run --rm ocr data/document.pdf --mode ocr # force OCR on a vector PDF
docker compose run --rm ocr data/document.pdf --save-images # dump detection overlaysPaddleOCR downloads its weights on first run and persists them in the paddlex-models volume,
so later runs start immediately.
| Argument | Default | Description |
|---|---|---|
pdf |
— | Path to the input PDF |
-o, --output |
output |
Output directory |
--dpi |
300 |
Resolution for PDF → image conversion |
--mode |
auto |
auto · native (pdfplumber) · ocr (force OCR) |
--pages N [N ...] |
all | 1-based page numbers to process |
--min-conf |
0.45 |
Minimum OCR confidence [0.0–1.0] |
--save-images |
off | Save PNGs with detections drawn on them |
--tensorrt |
off | Enable TensorRT (must be installed in the image) |
--no-gpu |
off | Run on CPU |
-v, --verbose |
off | Debug logging |
Three files per document:
| File | Contents |
|---|---|
<name>.md |
Markdown, with GFM tables inline at their original position |
<name>.json |
The full DocumentStructure serialised |
<name>_ocr.txt |
Plain text per page — useful when debugging a bad extraction |
--save-images adds an images/ directory with page_NNN_detection.png (detected table cells)
and page_NNN_text_regions.png (detected text regions). When an extraction goes wrong, these
show whether the failure was in detection or in recognition — which is usually the whole
question.
main.py CLI entry point
pipeline.py ResolutionPipeline — orchestrates the whole run
detector/
table_detector.py TableLineDetector: morphological grid → CellBBox
text_region_detector.py TextRegionDetector: text regions, table areas masked
layout_analyzer.py column detection and reading-order reordering
block_classifier.py block typing
ocr/
engine.py PaddleOCR wrapper (single + batch), confidence filtering
pdf_converter.py PDF → PNG, page by page
preprocessor.py binarisation and deskew
extractors/
native_extractor.py vector-PDF path via pdfplumber
structure_builder.py assembles DocumentStructure, drops text already in tables
table_extractor.py
text_extractor.py
models/schemas.py RawBlock · CellData · TableStructure · Section · DocumentStructure
output/
markdown_renderer.py Markdown / HTML rendering for tables with spans
json_writer.py
- Bordered tables only. The detector works from ruled lines. Tables laid out purely by whitespace are not detected as tables and fall through to the text-region path.
- Tuned on Colombian health-sector regulation: A4 portrait scans, printed text. Handwriting and dense multi-column layouts are outside what it was built for.
- No test suite. The debug overlays are the verification tool in practice.
MIT — see LICENSE.