Extract bordered tables from PDFs and export them to Excel (.xlsx).
Project description
ExactPdfGrid
Extract bordered tables from PDFs and export them to Excel (.xlsx).
ExactPdfGrid detects table grid lines with OpenCV, reconstructs the cell
layout (including merged cells), and writes a faithful .xlsx workbook with
one sheet per page. Text is pulled from the PDF's native vector layer by
default; for scanned PDFs you can swap in a RapidOCR backend.
The package ships three usable surfaces for the same pipeline:
- Library — call from Python:
import exactpdfgrid; exactpdfgrid("in.pdf"). - API server — Flask app exposing
POST /convert, started withexactpdfgrid-web. - Web UI — static HTML/JS served by that same server at
GET /, a drag-and-drop front end to the API.
Python ≥ 3.9 · Windows / macOS / Linux · default engine is OCR-free.
Install
pip install exactpdfgrid
Optional extras:
pip install "exactpdfgrid[ocr]" # RapidOCR backend for scanned PDFs
pip install "exactpdfgrid[web]" # Flask web UI / API server
pip install "exactpdfgrid[all]" # everything
1. Library — basic usage
The package is callable. Three positional arguments cover most use cases:
(pdf_path, engine, out_dir). The call returns the pathlib.Path of the
generated workbook (or None if no tables were detected).
import exactpdfgrid
# Most explicit form
xlsx_path = exactpdfgrid("input.pdf", "pymupdf", "output")
print(xlsx_path) # output/input.xlsx
# Use defaults: engine="pymupdf", out_dir="output"
exactpdfgrid("input.pdf")
# Switch to OCR (requires the [ocr] extra)
exactpdfgrid("input.pdf", "rapidocr", "out/")
Equivalent forms — pick whichever reads best:
import exactpdfgrid
from exactpdfgrid import PYMUPDF, RAPIDOCR, run
exactpdfgrid("input.pdf", RAPIDOCR, "out/") # exported string constant
run("input.pdf", RAPIDOCR, "out/") # explicit function (no module-callable magic)
exactpdfgrid(...), exactpdfgrid.run(...), and exactpdfgrid.process_pdf(...)
all share the same underlying pipeline; the first two are shorthands.
2. Library — advanced usage
When you need to retune the pipeline, use process_pdf with three config
dataclasses. Every field has a default that reproduces the out-of-the-box
behavior — set only what you want to change.
from exactpdfgrid import (
process_pdf,
DetectionConfig,
ExtractionConfig,
OutputConfig,
normalize_whitespace,
strip_square_brackets,
split_at_first_paren,
)
det = DetectionConfig(
dpi=300,
min_line_length=10,
ink_threshold=235,
dilate_kernel=(3, 3),
dilate_iterations=1,
cluster_gap=8,
)
ext = ExtractionConfig(
engine="rapidocr",
padding_px=3,
clean_pipeline=[
normalize_whitespace,
strip_square_brackets, # remove "[note]" annotations
split_at_first_paren, # keep only text before the first "("
],
)
out = OutputConfig(apply_borders=True, min_col_width=6.0)
xlsx_path = process_pdf(
"input.pdf",
detection=det,
extraction=ext,
output=out,
out_dir="output",
)
Config field reference
| Config | Fields |
|---|---|
DetectionConfig |
dpi, min_line_length, ink_threshold, max_gap, aspect_ratio, cluster_gap, dilate_kernel, dilate_iterations, morph_open_iterations, border_thickness, border_density |
ExtractionConfig |
engine (str or TextExtractor), padding_px, clean_pipeline (list[Callable[[str], str]]) |
OutputConfig |
apply_borders, apply_size_hints, min_col_width, min_row_height, px_per_char_calibration |
Custom cleaners
A cleaner is any Callable[[str], str]. The pipeline applies them in order to
each cell's raw extracted text:
from exactpdfgrid import ExtractionConfig, normalize_whitespace
def lower_and_strip(s: str) -> str:
return s.strip().lower()
ext = ExtractionConfig(clean_pipeline=[normalize_whitespace, lower_and_strip])
Built-in cleaners (importable from exactpdfgrid):
normalize_whitespace, strip_square_brackets, strip_parentheses,
split_at_first_paren, strip_outer_whitespace.
Custom text extractor
Plug in your own OCR / extraction engine by subclassing TextExtractor:
from exactpdfgrid import TextExtractor, ExtractionConfig, process_pdf
class MyExtractor(TextExtractor):
name = "mine"
def extract(self, *, fitz_page, image, cell, dpi, padding_px) -> str:
# fitz_page: PyMuPDF page · image: BGR numpy array of the rendered page
# cell: CellRegion with pixel coords
return "..."
process_pdf("input.pdf", extraction=ExtractionConfig(engine=MyExtractor()))
3. CLI
The exactpdfgrid console script is installed alongside the library and is a
thin wrapper around process_pdf:
exactpdfgrid input.pdf --out output --dpi 300 --engine rapidocr
| Flag | Default | Notes |
|---|---|---|
--dpi |
200 |
Render resolution. |
--out |
output |
Output directory. |
--engine |
pymupdf |
pymupdf or rapidocr. |
--min-line |
8 |
Minimum line length (px). |
--ink-threshold |
240 |
Brightness ceiling for "ink". |
--cluster-gap |
8 |
Max gap when clustering grid lines (px). |
--aspect-ratio |
40.0 |
Aspect-ratio threshold for line blobs. |
--border-thickness |
6 |
Half-thickness for border probe. |
--border-density |
0.20 |
Ink density threshold. |
Run exactpdfgrid --help for the full list.
4. API server
The [web] extra installs a Flask app that exposes the pipeline as a REST
endpoint.
Install & launch
pip install "exactpdfgrid[web]"
exactpdfgrid-web # binds 0.0.0.0:5000
# or
python -m exactpdfgrid.web.server
Routes
| Method | Path | Purpose |
|---|---|---|
GET |
/ |
Serves the Web UI (index.html). |
POST |
/convert |
Converts a PDF and returns the .xlsx payload. |
POST /convert reference
Request body must be multipart/form-data.
| Field | Type | Default | Notes |
|---|---|---|---|
pdf |
file | — | Required. The PDF to convert. |
dpi |
int | 200 |
Render DPI. |
min_line |
int | 8 |
Minimum line length (px). |
ink_threshold |
int | 240 |
Brightness ceiling for "ink". |
cluster_gap |
int | 8 |
Grid-line cluster gap (px). |
aspect_ratio |
float | 40.0 |
Line blob aspect ratio. |
engine |
str | pymupdf |
pymupdf or rapidocr (requires [ocr] extra on the server). |
Responses:
| Status | Content-Type | Body |
|---|---|---|
200 |
application/vnd.openxmlformats-officedocument.spreadsheetml.sheet |
.xlsx file attachment named <pdf-stem>.xlsx. |
400 |
application/json |
{"error": "No pdf file uploaded"} / {"error": "File must be a PDF"} |
422 |
application/json |
{"error": "No table cells detected in this PDF"} |
500 |
application/json |
{"error": "..."} for any unhandled server error. |
Calling the API
curl:
curl -X POST http://localhost:5000/convert \
-F "pdf=@input.pdf" \
-F "engine=rapidocr" \
-F "dpi=300" \
-o output.xlsx
Python requests:
import requests
with open("input.pdf", "rb") as fh:
response = requests.post(
"http://localhost:5000/convert",
files={"pdf": fh},
data={"engine": "rapidocr", "dpi": 300},
)
response.raise_for_status()
with open("output.xlsx", "wb") as out:
out.write(response.content)
Deployment notes
- The development server binds
0.0.0.0:5000withdebug=False. For production, front it with a real WSGI server (e.g.gunicorn):gunicorn -w 4 -b 0.0.0.0:5000 exactpdfgrid.web.server:app
- Each request renders the full PDF in memory and writes a temp
.xlsx; both are cleaned up before the response returns. Plan worker concurrency and request timeouts accordingly for large PDFs. - The endpoint has no built-in authentication. If exposed beyond localhost, place it behind a reverse proxy that handles auth and TLS.
5. Web UI
When the API server is running, GET / serves a single-page UI from
src/exactpdfgrid/web/static/. It is a thin front end for POST /convert,
not a separate service.
Features:
- PDF drop zone — drag-and-drop or click-to-select a PDF.
- Advanced settings panel (collapsible) — DPI, minimum line length, ink
threshold, aspect ratio, cluster gap. These map 1-to-1 to the
POST /convertform fields. - Convert & download button — posts to
/convertand triggers a browser download of the returned.xlsx. - Inline status area — shows progress and errors returned by the server.
The UI currently exposes the detection-stage knobs; the engine field is not
yet surfaced in the form and defaults to pymupdf. Use the REST API directly
or the library if you need OCR from a non-browser client.
6. How it works
- Render — PyMuPDF rasterises each page at the requested DPI.
- Detect — OpenCV morphology + a connectivity check find the horizontal and vertical line segments that form the table.
- Reconstruct — pure geometry assembles the segments into a logical grid, including merged cells.
- Extract — for each cell, the chosen
TextExtractorreads the text (PyMuPDF clip-extract by default; RapidOCR on the cell crop if selected). - Clean — text passes through your
clean_pipeline. - Write —
openpyxlproduces an.xlsxwith proper merges, borders, and approximate column widths / row heights derived from the pixel grid.
License
MIT.
Project details
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 exactpdfgrid-0.1.1.tar.gz.
File metadata
- Download URL: exactpdfgrid-0.1.1.tar.gz
- Upload date:
- Size: 29.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.13.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
30329060e9eb0e6a7549f3dbe675e66f1384367e07ea62952d463f2ec9d45246
|
|
| MD5 |
d1a20ccd48570060e73ff13d89df4be0
|
|
| BLAKE2b-256 |
b1533780e92567fd0ea29b4c6701e15be5631b0654e85fe353582913932b353f
|
File details
Details for the file exactpdfgrid-0.1.1-py3-none-any.whl.
File metadata
- Download URL: exactpdfgrid-0.1.1-py3-none-any.whl
- Upload date:
- Size: 29.8 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.13.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
32829637c7ccc2f043b963ca8945b7590294367cf8346a17b32642a7bbd97edd
|
|
| MD5 |
5d9c9fa1b43a7146d046cfabe71d4b26
|
|
| BLAKE2b-256 |
2a1a5e7000959e878de6998c74f2a03097c6dea92cc68d6fd2fde7443a85664c
|