Structured text extraction framework for digital and scanned PDFs with inline formatting preservation
Project description
paradox-pdf · [cpu], [gpu], or [llm]
Structured text extraction for digital and scanned PDFs — usable as a Python library or a CLI.
Three install profiles, one wheel. Pick CPU for portability, GPU for highest local accuracy on photographed documents, or LLM for a no-GPU fallback that escalates to Claude only when the CPU pipeline can't recover a page.
pip install paradox-pdf # CPU profile (default — works everywhere)
pip install 'paradox-pdf[gpu]' # GPU profile (PaddleOCR-VL 0.9B, needs CUDA)
pip install 'paradox-pdf[llm]' # LLM profile (Claude API fallback, no GPU)
Paradox parses any PDF — digital, scanned, or photographed — into a single hierarchical JSON tree of typed elements (titles, paragraphs, tables, lists, headers, signatures, …) with inline marks (bold, italic, underline, strikethrough, …) preserved. It auto-routes each page to the right pipeline (PyMuPDF font analysis for digital, YOLO + OCR + Table Transformer for scanned) and merges the results into a unified document.
Install
Three profiles. Pick one (or combine: paradox-pdf[gpu,llm]):
CPU (default — works on any machine)
pip install paradox-pdf
Vision pipeline: PP-DocLayoutV2 + Table Transformer + RapidOCR (ONNX) + TexTAR. Runs on CPU; uses GPU when PyTorch detects CUDA. Wheel ~60 MB; fully self-contained — bundled TexTAR weights, no extra setup.
GPU (PaddleOCR-VL 0.9B for higher accuracy)
pip install 'paradox-pdf[gpu]'
# paddlepaddle-gpu>=3.0 is not on PyPI (CUDA-version-specific wheels);
# install it from Paddle's index, picking the URL that matches your CUDA:
pip install paddlepaddle-gpu==3.2.1 \
-i https://www.paddlepaddle.org.cn/packages/stable/cu126/
Adds PaddleOCR-VL — a 0.9B VLM that does layout + OCR + table structure in one pass. Higher accuracy on photographed/distorted documents and complex tables; ~12 GB VRAM at inference.
LLM (Claude API fallback — no GPU needed)
pip install 'paradox-pdf[llm]'
export ANTHROPIC_API_KEY=sk-ant-...
Adds a hybrid mode that runs the CPU pipeline first and only calls Claude (Haiku 4.5) when the CPU output is broken — empty page, or a single table with fill_rate < 0.40. Replacement is surgical: only the failing region's bbox crop is sent, not the full page, so cost stays low (~$0.05 per 200 pages in our test corpus vs. ~$1.00 for full-page LLM extraction). Prompt caching is enabled by default.
Activate with backend="llm" or backend="auto" (auto picks llm when ANTHROPIC_API_KEY is set and no CUDA is available).
Which one when
| Scenario | Profile |
|---|---|
| Digital PDFs (text already in PDF) | any; CPU is enough |
| Cleanly-scanned documents | CPU |
| Photographed / off-center / curved pages | GPU |
| Complex multi-level-header tables | GPU |
| No GPU + occasional hard scans | LLM (CPU first, Claude only on failure) |
| Production with no GPU available, no API budget | CPU |
You can also mix: in any install, pass backend="cpu" (or "gpu" / "llm") to force a particular backend for a call.
Python 3.9+. First run downloads a few HuggingFace models (~500 MB CPU profile, ~2 GB GPU profile) into the local cache; subsequent runs use the cache.
60-second quick start
import paradox_pdf as pdx
doc = pdx.extract("contract.pdf")
print(doc["total_pages"], "pages")
print(doc["type_summary"]) # {'TITLE': 1, 'PARAGRAPH': 14, 'TABLE': 3, ...}
for el in doc["elements"]:
print(el["type"], "-", (el.get("text") or "")[:60])
That's it. doc is a plain dict — no custom classes, no streaming generators. JSON-serializable as-is.
Public API
The package exposes 5 functions and 1 dataclass:
| Symbol | Purpose |
|---|---|
extract(pdf, **opts) -> dict |
Run the full pipeline, return JSON in memory. |
extract_to_file(pdf, output, images_dir, **opts) -> dict |
Same as extract but also writes JSON + images to disk. |
extract_pages(pdf, pages, **opts) -> dict |
Subset by page number. |
extract_text(pdf, **opts) -> str |
Plain-text concatenation only. |
extract_tables(pdf, **opts) -> list[dict] |
Flat list of every TABLE element. |
PipelineConfig |
Dataclass to override 30+ thresholds. |
All functions accept the same keyword options:
| Argument | Type | Default | Description |
|---|---|---|---|
pages |
Sequence[int] | None |
None |
1-based page numbers; None = all pages. |
no_images |
bool |
False |
Skip image extraction (faster, no PNGs). |
force_mode |
"heuristic" | "vision" | None |
None |
Force a pipeline; None auto-routes per page. |
backend |
"auto" | "cpu" | "gpu" | "llm" |
"auto" |
Vision backend: cpu = classic, gpu = PaddleOCR-VL ([gpu] extra), llm = CPU + Claude fallback ([llm] extra). auto picks GPU when CUDA is available, else LLM if ANTHROPIC_API_KEY is set, else CPU. |
output |
str | Path | None |
None |
If set, also writes JSON here. |
images_dir |
str | Path | None |
tempdir | Where extracted images go. |
config |
PipelineConfig | None |
None |
Override pipeline thresholds. |
Examples
1. Get the document tree
import paradox_pdf as pdx
doc = pdx.extract("annual_report.pdf")
# Top-level structure
print(doc.keys())
# dict_keys(['source', 'total_pages', 'total_elements', 'total_images',
# 'type_summary', 'elements'])
# Walk the heading tree
def walk(nodes, depth=0):
for n in nodes:
text = (n.get("text") or "").strip()[:80]
print(f"{' '*depth}{n['type']:14s} {text}")
walk(n.get("children", []), depth + 1)
walk(doc["elements"])
2. Process only certain pages
doc = pdx.extract("contract.pdf", pages=[1, 2, 5])
# or
doc = pdx.extract_pages("contract.pdf", pages=range(10, 20))
3. Plain text in one call
text = pdx.extract_text("contract.pdf")
4. Extract every table
tables = pdx.extract_tables("contract.pdf")
for t in tables:
rows, cols = t["shape"]
cells = t["cells"]
print(f"Table {rows}×{cols}, {len(cells)} cells")
for c in cells:
p = c["p"]
if len(p) == 2: # simple cell
r, col = p
print(f" ({r},{col}): {c['t']!r}")
else: # merged cell
r, col, rowspan, colspan = p
print(f" ({r},{col}) span {rowspan}×{colspan}: {c['t']!r}")
Cell schema:
{"p": [row, col], "t": "Some cell text"} # simple
{"p": [row, col, rowspan, colspan], "t": "Header cell"} # merged
5. Persist to disk
doc = pdx.extract_to_file(
"contract.pdf",
output="out/contract.json",
images_dir="out/images/",
)
The function still returns the dict.
6. Convert a folder
from pathlib import Path
import paradox_pdf as pdx
for pdf in Path("inbox/").glob("*.pdf"):
doc = pdx.extract_to_file(pdf, output=f"out/{pdf.stem}.json", no_images=True)
print(f"{pdf.name:40s} {doc['total_pages']}p {doc['total_elements']} elements")
7. Custom configuration
from paradox_pdf import extract, PipelineConfig
cfg = PipelineConfig(
render_dpi=300, # higher DPI for vision pipeline
scan_text_threshold=80, # treat pages with <80 chars as scanned
cv_border_missing_threshold=0.40, # be stricter about declaring borders absent
yolo_confidence=0.30, # stricter YOLO detections
)
doc = extract("noisy_scan.pdf", config=cfg)
Full reference of the 30+ tunables is in docs/configuration.md.
You can also override any parameter with environment variables prefixed PDF_:
PDF_RENDER_DPI=300 PDF_YOLO_CONFIDENCE=0.3 python my_script.py
8. Force a specific pipeline
# Force the digital pipeline even if a page looks scanned (faster, no OCR)
doc = pdx.extract("digital_only.pdf", force_mode="heuristic")
# Force the vision pipeline (OCR every page, even digital ones)
doc = pdx.extract("scanned.pdf", force_mode="vision")
9. Just count things
doc = pdx.extract("contract.pdf", no_images=True)
print(doc["type_summary"])
# {'TITLE': 1, 'H1': 4, 'H2': 11, 'PARAGRAPH': 67, 'TABLE': 3, 'SIGNATURE': 2}
10. Build markdown from the tree
import paradox_pdf as pdx
LEVEL = {"TITLE": 1, "SUBTITLE": 2, "H1": 3, "H2": 4, "H3": 5, "H4": 6}
def to_markdown(nodes, out=None):
out = out if out is not None else []
for n in nodes:
t = n.get("type")
text = (n.get("text") or "").strip()
if t in LEVEL and text:
out.append("#" * LEVEL[t] + " " + text)
elif t == "PARAGRAPH":
out.append(text)
elif t == "TABLE":
out.append(f"_<table {n['shape'][0]}x{n['shape'][1]}>_")
out.append("")
to_markdown(n.get("children", []), out)
return "\n".join(out)
doc = pdx.extract("contract.pdf", no_images=True)
print(to_markdown(doc["elements"]))
Output schema
{
"source": "contract.pdf",
"total_pages": 12,
"total_elements": 145,
"total_images": 4,
"type_summary": {"TITLE": 1, "PARAGRAPH": 67, "TABLE": 3, "...": "..."},
"elements": [
{
"type": "TITLE",
"marks": ["BOLD"],
"text": "**Annual Report — Q4 2025**",
"ref": "(p1,l1):(p12,l8)",
"children": [
{"type": "PARAGRAPH", "text": "...", "ref": "(p1,l2):(p1,l2)"},
{"type": "H1",
"text": "**1. Financial Summary**",
"ref": "(p1,l3):(p2,l4)",
"children": [
{"type": "TABLE",
"shape": [5, 4],
"cells": [
{"p": [0, 0], "t": "Category"},
{"p": [0, 1, 1, 3], "t": "Studio Minimum Rates"}
],
"ref": "(p1,l4):(p1,l4)"}
]}
]
}
]
}
ref field
Every element gets a ref of the form "(pX,lY):(pX,lY)" where:
pX= page number (1-based),lY= element index within that page (1-based).- The first tuple is the start; the second is the end of the element's last descendant.
Element types (excerpt)
TITLE, SUBTITLE, H1–H4, PARAGRAPH, TABLE, LIST (with items[]), TOC (with entries[]), IMAGE, SIGNATURE, AMENDMENT_DEL, EXHIBIT, APPENDIX, FOOTER, HEADER, PAGE_NUMBER, plus 50+ more. Full list: pdf_tagger/catalog.py.
Inline marks
Marks are preserved both in marks: [...] (per-element) and inline in the text:
| Mark | Inline syntax |
|---|---|
| BOLD | **bold text** |
| ITALIC | *italic* |
| UNDERLINE | ++underlined++ |
| STRIKETHROUGH | ~~deleted~~ |
| SUPERSCRIPT | ^superscript^ |
| MONOSPACE | `code` |
CLI
The same package installs a paradox-pdf command:
paradox-pdf contract.pdf # → output/contract.json
paradox-pdf contract.pdf -o result.json
paradox-pdf docs/ -o extracted/ -w 8 # parallel folder
paradox-pdf --pages 1-5 contract.pdf
paradox-pdf --no-images contract.pdf
Run paradox-pdf --help for the full set of flags.
How it works
┌─────────────────┐
PDF ─────────────► scan_detector │ per page (<50 chars → vision)
└────────┬────────┘
┌─────────────┴─────────────┐
▼ ▼
┌──────────────────┐ ┌──────────────────────┐
│ Heuristic │ │ Vision │
│ (PyMuPDF fonts) │ │ YOLO + RapidOCR │
│ │ │ + Table Transformer │
│ │ │ + HDBSCAN borderless │
│ │ │ + TexTAR (marks) │
└────────┬─────────┘ └──────────┬───────────┘
└─────────────┬───────────────┘
▼
┌────────────────────────┐
│ Section tree builder │
│ Post-processing passes │
└───────────┬────────────┘
▼
JSON dict
For tables, three detectors run in parallel — vector lines (PyMuPDF), Table Transformer, OpenCV border morphology — and the highest-quality result wins by IoU 0.5 NMS scored on fill_rate + source_bonus − merge_penalty. Merged cells are detected by missing inner borders (≥35% pixel coverage threshold) for bordered tables, and by cell-width ratio (>1.6× column pitch) for borderless ones.
Reading order (new in 0.5.0)
PP-DocLayoutV2 is a two-stage model: an RT-DETR detector emits boxes + class labels, and a pointer network trained end-to-end re-ranks them into reading order using class-label embeddings + 2D positional encodings + geometric bias. Paradox preserves that prediction by default — earlier releases discarded it by re-sorting on y0, which produced the wrong order on multi-column papers.
Override with the reading_order kwarg:
pdx.extract("paper.pdf") # auto → pointer-net when available
pdx.extract("paper.pdf", reading_order="native") # require pointer-net (errors otherwise)
pdx.extract("paper.pdf", reading_order="geometric") # recursive XY-Cut on bboxes only
pdx.extract("paper.pdf", reading_order="y") # legacy 0.4.x behaviour (snapshot tests)
On the multi-column real-PDF bench (Las Condes journal, Nature Sci Reports, etc.) the pointer-net order matches the human-validated ground truth on 11/20–14/14 pages where naive y sort matched only 4/15–9/14. Pages without the detector (digital text path, YOLOv10 legacy fallback) automatically fall back to geometric XY-Cut.
Digital hierarchy via PP-DocLayoutV2 (new in 0.5.1, improved in 0.5.2)
On the digital (non-scanned) path, paradox normally classifies block types with font_classifier — font flags + size hierarchy + indentation. That works well when titles use a visibly larger font, but misses cases where a title is bold-and-same-size, or where an article subtitle, footnote, or caption is not encoded with a font-rank signal.
Three modes, ordered by trust:
# Default — heuristic only (unchanged from 0.4.x).
pdx.extract("doc.pdf")
# Recommended — tier-aware hybrid. PP for hierarchy signals (TITLE /
# FOOTNOTE / CAPTION / HEADER), heuristic for body text, sanity-checked
# TABLE/FIGURE so PP cannot promote a sectioned CV layout into a fake table.
pdx.extract("doc.pdf", digital_layout_detector="hybrid")
# Trust PP-DocLayoutV2 unconditionally. Good for clean papers; can
# over-classify TABLE on CVs/forms because the visual layout fools it.
pdx.extract("doc.pdf", digital_layout_detector="paddlex")
Validated on three real PDFs — hybrid is strictly better than the other two:
| heuristic | hybrid | paddlex | |
|---|---|---|---|
| Las Condes journal p1 | 8 elems — no TITLE | 12 elems — TITLE:3, FOOTNOTE:1, H1:3, PARAGRAPH:5 ✅ |
10 elems — no H1 |
| Nature Sci Rep p1 | 5 elems — no header/footnote | 5 elems — PAGE_HEADER, TITLE, FOOTNOTE, H1, PARAGRAPH ✅ |
5 elems — no H1 |
| CV (digital, sectioned) | 12 — H1×5, H2, PARAGRAPH | 12 — adds TITLE, otherwise unchanged ✅ | ❌ 8 fake TABLEs |
PyMuPDF still extracts text + marks (digital signals stay perfect). PP-DocLayoutV2's pointer-network rank is preserved through the dispatcher in all three modes, so the reading order of multi-column pages is correct regardless of how types are decided.
Costs ~1 s/page on CPU.
Performance notes
- Digital page: ~0.05 s on CPU.
- Scanned page: ~10 s on CPU, much faster on GPU (PyTorch detects and uses CUDA automatically).
- First run: HuggingFace models are downloaded once (~500 MB total).
If you see multi-minute startup per document with the vision pipeline, set HF_HUB_OFFLINE=1 after the first download — HuggingFace's online metadata revalidation on slow networks is the bottleneck, not the actual inference:
HF_HUB_OFFLINE=1 python my_script.py
Or in code:
import os
os.environ["HF_HUB_OFFLINE"] = "1"
import paradox_pdf as pdx
Repository layout
paradox_pdf/ Public Python API (extract, extract_text, …)
pdf_tagger/ Core extraction (font classifier, vision layout, marks)
pdf_grid/ Vector-line table detection
scripts/ CLI implementation
docs/ Configuration reference, API reference, research notes
examples/ Sample PDFs + expected outputs
_dev/ Test suites, fixtures, benchmarks (not shipped in wheel)
License
Proprietary — © CreAI. Contact feliperodriguez@creai.mx for commercial use.
Links
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file paradox_pdf-0.5.2.tar.gz.
File metadata
- Download URL: paradox_pdf-0.5.2.tar.gz
- Upload date:
- Size: 61.4 MB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.11.15
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
3e63d2b3c3a50ec49b1e8dc11bf3e49cd004128d5a1f513c044baeb2cca336ae
|
|
| MD5 |
506b063dd61d4710b6589098b4ee1e83
|
|
| BLAKE2b-256 |
e1acf5c8ab07e342200b2153b65109666521329d4acc27f9b335205426802e1d
|
File details
Details for the file paradox_pdf-0.5.2-py3-none-any.whl.
File metadata
- Download URL: paradox_pdf-0.5.2-py3-none-any.whl
- Upload date:
- Size: 61.4 MB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.11.15
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
0a963232557c70b1f499d411f23b00ee6416309c528cff385ffd76a967f9dfe1
|
|
| MD5 |
ab0fdc2fbacc06cb7a639351a1de19da
|
|
| BLAKE2b-256 |
b312211d1d0d1c92ce5f650be9a7c8e3a30c34042df55a00c70fd496d63aaa32
|