Metadata-Version: 2.5
Name: maro-processor
Version: 0.2.0
Summary: Extract text and its position from PDF files, digital or scanned, using the text layer or OCR
Project-URL: Homepage, https://marolai.github.io
Project-URL: Repository, https://github.com/Maro-AI-Services/maro-processor
Author-email: "Andriamarolahy R." <marolahyrabe@gmail.com>
License: MIT
License-File: LICENSE
Keywords: bounding-box,document-processing,invoice,layout,ocr,paddleocr,pdf,pdf-extraction,pdfplumber,rapidocr,scanned-pdf,tesseract,text-extraction
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: Information Technology
Classifier: License :: OSI Approved :: MIT License
Classifier: Natural Language :: English
Classifier: Natural Language :: French
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Office/Business
Classifier: Topic :: Scientific/Engineering :: Image Recognition
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Topic :: Text Processing
Classifier: Topic :: Text Processing :: Indexing
Requires-Python: >=3.10
Requires-Dist: numpy>=1.24.0
Requires-Dist: pydantic>=2.0.0
Provides-Extra: all
Requires-Dist: onnxruntime>=1.15.0; extra == 'all'
Requires-Dist: paddleocr>=3.0.0; extra == 'all'
Requires-Dist: paddlepaddle>=3.0.0; extra == 'all'
Requires-Dist: pdfplumber>=0.11.0; extra == 'all'
Requires-Dist: pytesseract>=0.3.10; extra == 'all'
Requires-Dist: rapidocr>=2.0.0; extra == 'all'
Provides-Extra: dev
Requires-Dist: pillow>=10.0.0; extra == 'dev'
Requires-Dist: pytest>=8.0.0; extra == 'dev'
Provides-Extra: pdf-native
Requires-Dist: pdfplumber>=0.11.0; extra == 'pdf-native'
Provides-Extra: pdf-ocr
Requires-Dist: paddleocr>=3.0.0; extra == 'pdf-ocr'
Requires-Dist: paddlepaddle>=3.0.0; extra == 'pdf-ocr'
Requires-Dist: pdfplumber>=0.11.0; extra == 'pdf-ocr'
Provides-Extra: pdf-ocr-light
Requires-Dist: onnxruntime>=1.15.0; extra == 'pdf-ocr-light'
Requires-Dist: pdfplumber>=0.11.0; extra == 'pdf-ocr-light'
Requires-Dist: pytesseract>=0.3.10; extra == 'pdf-ocr-light'
Requires-Dist: rapidocr>=2.0.0; extra == 'pdf-ocr-light'
Description-Content-Type: text/markdown

# maro-processor

![PyPI Version](https://img.shields.io/pypi/v/maro-processor)
![Python](https://img.shields.io/pypi/pyversions/maro-processor)
![License](https://img.shields.io/pypi/l/maro-processor)

**Get text with its position from any PDF, digital or scanned, on your own machine.**

`maro-processor` reads the text layer of a PDF when there is one. When there is none, it uses OCR
instead. It checks this for each page, not once for the whole file. You always get the same
`PageExtractionResult` object.

Every word comes back with its bounding box. So you can rebuild columns, find a value by its
position, or send text to an LLM without losing the layout.

This is a small adapter, not a document AI model. No large model files, no cloud API, no cost
per page.

---

## Why

Most PDF projects write the same three things again. This package gives you those three things.

1. **Scanned or digital?** You try the text layer. If it is empty, you render the page and run OCR.
   It is easy to get wrong, and everybody writes it again.
2. **Four engines, four output formats.** pdfplumber returns dicts with `x0/top`. PaddleOCR returns
   arrays of polygons. Tesseract returns a dict of lists with `left/width`. RapidOCR returns an
   object. If you write code for one, you are stuck with it. Here they all become
   `SpatialTextElement`.
3. **Reading order breaks tables.** Invoices, bank statements and forms lose their columns when you
   read them as one string. With coordinates you can put the columns back.

---

## Installation

Install only the engine you need:

```bash
# Digital PDFs only
pip install maro-processor[pdf-native]

# + light OCR (Tesseract and RapidOCR, no PaddlePaddle)
pip install maro-processor[pdf-ocr-light]

# + PaddleOCR (best accuracy for many languages, but much bigger)
pip install maro-processor[pdf-ocr]

# Everything
pip install maro-processor[all]
```

| Extra | Installed size | Engines |
|---|---:|---|
| `pdf-native` | 93 MB | `PdfPlumberNativeEngine` |
| `pdf-ocr-light` | 246 MB | `+ TesseractOCREngine`, `RapidOCREngine` |
| `pdf-ocr` | ~1 GB | `+ PaddleOCREngine` |

<sub>Sizes are `site-packages`, measured on CPython 3.11 / Windows. The `pdf-ocr` size is
approximate.</sub>

> **Note:** `TesseractOCREngine` also needs the `tesseract-ocr` program on your system
> (`sudo apt-get install tesseract-ocr tesseract-ocr-fra`). `RapidOCREngine` runs on ONNX Runtime
> and needs nothing else.

If you use an engine and its extra is not installed, you get an `ImportError` that tells you which
extra to install. Not a confusing `ModuleNotFoundError`.

---

## Quick start

```python
from maro_processor.pdf import SmartPDFExtractorRouter, PdfPlumberNativeEngine, RapidOCREngine

router = SmartPDFExtractorRouter(
    pdf_path="invoice.pdf",
    native_engine=PdfPlumberNativeEngine(),
    ocr_engine=RapidOCREngine(),      # used only for the pages that need it
)

print(router.detect_scanned_pages())   # for example [False, True, False]

for page in router.process_document():
    print(f"--- Page {page.page_number} ({'OCR' if page.is_ocr else 'native'}) ---")
    print(page.to_layout_text(char_width=6.0, line_height=13.0))
```

Each element keeps its own position:

```python
for el in page.elements:
    el.text, el.x0, el.y0, el.x1, el.y1, el.confidence
```

---

## Keep the layout in the text

`to_layout_text()` puts the bounding boxes on a character grid. An LLM can read the result. It
cannot read a page where all the columns are gone.

<table>
<tr>
<th width="33.3%">1 — The PDF</th>
<th width="33.3%">2 — <code>extract_text()</code></th>
<th width="33.3%">3 — <code>to_layout_text()</code></th>
</tr>
<tr>
<td valign="top">
<img alt="The original invoice page" src="https://raw.githubusercontent.com/Maro-AI-Services/maro-processor/main/docs/assets/layout-1-original.png">
</td>
<td valign="top">
<img alt="pdfplumber reading-order text, with all columns collapsed onto single lines" src="https://raw.githubusercontent.com/Maro-AI-Services/maro-processor/main/docs/assets/layout-2-reading-order.png">
</td>
<td valign="top">
<img alt="Animated: layout text with a red bounding box drawn around every element, revealed line by line" src="https://raw.githubusercontent.com/Maro-AI-Services/maro-processor/main/docs/assets/layout-3-layout-text.gif">
</td>
</tr>
<tr>
<td valign="top"><sub>Three columns of line items, and a totals block on the right.</sub></td>
<td valign="top"><sub><b>Layout lost.</b> In <code>Total HT 868,20</code>, nothing tells you which column the value comes from, or that it belongs to the totals and not to the line items.</sub></td>
<td valign="top"><sub><b>Layout kept.</b> Every element keeps its bounding box (in red) and goes to the character cell that its coordinates give.</sub></td>
</tr>
</table>

Choose `char_width` and `line_height` for your document. OCR gives pixels at the render DPI, and
the native engine gives PDF points.

<details>
<summary>The same comparison as plain text</summary>

`pdfplumber.extract_text()` — the columns are gone, and the values are far from their labels:

```text
Désignation Quantité PU HT Total HT
Cartons 60x40x40 120 2,45 294,00
Palettes Europe 18 14,90 268,20
Total HT 868,20
TVA 20% 173,64
```

`page.to_layout_text()` — the columns are still there:

```text
Désignation                             Quantité       PU HT          Total HT
Cartons 60x40x40                        120            2,45           294,00
Palettes Europe                         18             14,90          268,20
Film étirable 17u                       45             6,80           306,00
                                                       Total HT       868,20
                                                       TVA 20%        173,64
                                                       Total TTC      1041,84
```

</details>

Do you need the grid positions themselves, to draw an overlay or to find a value by its line and
column? `layout_cells()` gives you each position with its element:

```python
for cell in page.layout_cells(char_width=6.0, line_height=13.0):
    cell.line, cell.column, cell.text, cell.element.x0
```

---

## One choice per page

The check runs on **every page**, not only on the first one. If a digital contract has one scanned
page at the end, only that page goes to OCR:

```python
router = SmartPDFExtractorRouter(pdf_path="contract.pdf", ocr_engine=RapidOCREngine())
router.detect_scanned_pages()   # [False, True, False]  -> only page 2 goes to OCR
router.detect_is_scanned()      # True: at least one page needs OCR
```

This is also about speed. The native engine is **50 to 300 times faster** than OCR, depending on the
OCR engine. If page 1 is a scan and you send the whole file to OCR, the result is wrong and slow.

When a page needs OCR and you did not give an OCR engine, `process_document()` raises a
`ValueError` with the page numbers. It does not return an empty page in silence.

---

## Engines

| Engine | Extra | System programs | Notes |
|---|---|---|---|
| `PdfPlumberNativeEngine` | `pdf-native` | none | Text layer only. Fast and exact, no model. |
| `RapidOCREngine` | `pdf-ocr-light` | none | ONNX Runtime. Good default for light OCR. |
| `TesseractOCREngine` | `pdf-ocr-light` | `tesseract-ocr` | Fast, but weaker on bad scans. |
| `PaddleOCREngine` | `pdf-ocr` | none | Best for many languages and CJK, but about 1 GB and very slow on a CPU. See the benchmark. |

### RapidOCR

Nothing else to install:

```bash
pip install maro-processor[pdf-ocr-light]
```

```python
from maro_processor.pdf import RapidOCREngine

engine = RapidOCREngine()
page = engine.extract_page("invoice.pdf", page_idx=0)
```

The first call downloads the ONNX models, about 15 MB, and keeps them inside the `rapidocr`
package folder. So the first run needs the internet, and it is slower. The next runs are offline.

This is the engine to try first. It works the same on Windows, Linux and macOS.

### Tesseract

Tesseract has two parts: the Python package, and the program itself. `pip` installs only the first
one, so you must install the program too.

**Windows**

```powershell
winget install UB-Mannheim.TesseractOCR
```

Then open a **new** terminal, so that Windows sees the new `PATH`. If the command still does not
work, give the path yourself:

```python
from maro_processor.pdf import TesseractOCREngine

engine = TesseractOCREngine(
    lang="fra+eng",
    tesseract_cmd=r"C:\Program Files\Tesseract-OCR\tesseract.exe",
)
```

The engine also looks in `C:\Program Files\Tesseract-OCR\` by itself, so most of the time it works
without `tesseract_cmd`.

**Linux**

```bash
sudo apt-get install tesseract-ocr tesseract-ocr-fra
```

**macOS**

```bash
brew install tesseract tesseract-lang
```

The `lang` value uses the Tesseract codes, and needs the language files to be installed. `fra+eng`
means French first, then English. To see the languages you have:

```python
import pytesseract
from maro_processor.pdf import TesseractOCREngine

TesseractOCREngine(lang="eng")   # this finds tesseract and sets its path
print(pytesseract.get_languages(config=""))
```

Build the engine first. It is the engine that finds the program, so `pytesseract` alone can fail
here even when everything is installed.

On Windows, the `winget` installer asks you which languages to add. If `fra` is missing, run the
installer again and select French.

### PaddleOCR

```bash
pip install maro-processor[pdf-ocr]
```

Nothing else to install, but this one is big, about 1 GB, and the first call downloads its models.

```python
from maro_processor.pdf import PaddleOCREngine

engine = PaddleOCREngine(lang="fr")
```

By default it takes the newest PaddleOCR models. You can ask for another set with
`PaddleOCREngine(lang="fr", ocr_version="PP-OCRv5")`, but do not ask for `PP-OCRv3`: that old model
drops the spaces and the accents, so `N° FA-2026-0001` comes back as `NFA-2026-0001`.

Take PaddleOCR when you have a GPU, or for a language that Tesseract and RapidOCR read badly. On a
CPU it is very slow: see the benchmark.

### Write your own

All of them use `BaseExtractorEngine`, so you can write your own:

```python
from maro_processor.base import BaseExtractorEngine

class MyEngine(BaseExtractorEngine):
    def extract_page(self, file_source, page_idx, **kwargs) -> PageExtractionResult:
        ...
```

---

## Benchmark

One A4 page. The OCR engines render it at 150 DPI. Best of 3 runs, model loading not counted:

| Engine | ms/page | Elements found |
|---|---:|---:|
| Native (pdfplumber) | 7 | 55 |
| Tesseract (`fra+eng`) | 310 | 55 |
| RapidOCR | 2,265 | 30 |
| PaddleOCR (`fr`) | 78,000 | 31 |

<sub>Measured on an Intel Core i5-13420H, CPU only, CPython 3.11, on `tests/fixtures/`. The times
change from one run to another. You can run the test again with
`python scripts/extract_pdf.py bench`.</sub>

Three things to remember here.

The native engine is much faster than any OCR. So send a page to OCR only when it really needs it.

This test file is a clean page made by a computer, which is the easiest case for Tesseract. On a
real scan, a photocopy or a photo from a phone, Tesseract loses much more quality than RapidOCR. Do
not choose an engine from this table alone. Try them on your own documents.

And PaddleOCR is very slow here, 78 seconds for one page. Two reasons. It takes its big "server"
models, and it runs without oneDNN, because oneDNN makes PaddlePaddle 3.3 crash on this machine.
With the small models it goes down to about 8 seconds, but it then loses spaces, like RapidOCR
does. On a CPU, RapidOCR gives you the same quality much faster. Take PaddleOCR when you have a
GPU, or for a language the other two read badly.

---

## Project structure

```text
src/maro_processor/
├── __init__.py         # Package version and metadata
├── base.py             # BaseExtractorEngine interface
├── schemas.py          # SpatialTextElement / PageExtractionResult (Pydantic)
│
└── pdf/
    ├── __init__.py     # Public exports + clear errors for missing extras
    ├── router.py       # Per-page scanned/digital check and routing
    ├── native.py       # pdfplumber text-layer engine
    ├── ocr.py          # PaddleOCR engine (set up for low memory)
    ├── tesseract.py    # Tesseract engine
    ├── rapidocr.py     # RapidOCR / ONNX engine
    └── utils.py        # Page rendering + OCR output normalization

scripts/
└── extract_pdf.py      # Demos and the benchmark

docs/assets/            # README images
tests/fixtures/         # Invented invoices for the tests (no private data)
```

---

## What it does not do

Some things are outside the scope, on purpose:

- No layout model. No reading order, no title detection, no figure detection.
- No table structure. The elements have coordinates, but you build the cells yourself for now.
- No page rotation or skew correction.
- PDF only, no other file formats.

If you want a full document pipeline with Markdown output, look at
[Docling](https://github.com/docling-project/docling) or
[unstructured](https://github.com/Unstructured-IO/unstructured). Use this package when you want
coordinates, a small install, and no model download.

---

## Development

```bash
uv venv && uv pip install -e ".[pdf-native]" pytest
pytest
```

The tests use PDF files that are in the repo, so they really test something after a fresh clone.
The OCR engine tests are skipped when the engine is not installed. See
[tests/fixtures/README.md](tests/fixtures/README.md) for what each file contains.

To run the demos and the speed test:

```bash
python scripts/extract_pdf.py         # examples + speed test
python scripts/extract_pdf.py bench   # only the speed test, as a table
```

If you change the test files, run the speed test again and update the numbers in this README.

---

## Changelog

See [CHANGELOG.md](CHANGELOG.md). In `0.2.0`, `detect_is_scanned()` changed its meaning, and the
per-page routing was fixed.

---

## Common problems

**`pdfplumber` or `PyPDF2` gives me an empty text for my PDF.**
The page has no text layer. It is an image, so there is nothing to read. You need OCR. This is what
`SmartPDFExtractorRouter` does for you: it sees the empty page and sends it to the OCR engine.

**How do I know if a PDF is scanned or not?**
`router.detect_scanned_pages()` gives you one `True` or `False` for each page. `True` means the page
has no text layer. Some files have both kinds of pages, so check page by page.

**My table becomes one long line and I lose the columns.**
Reading a PDF in reading order removes the positions. Use `page.to_layout_text()`. It puts every
word back at its place on a character grid, so the columns stay. See
[the images above](#keep-the-layout-in-the-text).

**Some pages give me nothing, but the file is not scanned.**
Look at `detect_scanned_pages()`. If only some pages are `True`, that file mixes digital pages and
scanned pages. Give an `ocr_engine` and the router will handle them.

**Which OCR engine should I take?**
Start with `RapidOCREngine`. Nothing else to install, it works on every system, and it is a good
default. Take Tesseract if you want more speed on clean scans. Take PaddleOCR for a language the
others read badly, but read the [benchmark](#benchmark) first: it is very slow on a CPU.

**Windows says tesseract is not installed, but I installed it.**
The installer often does not add it to your `PATH`. Open a new terminal, or give the path with
`TesseractOCREngine(tesseract_cmd=r"C:\Program Files\Tesseract-OCR\tesseract.exe")`. See
[Tesseract](#tesseract).

**Does it work without internet? Do my documents leave my machine?**
Your documents never leave your machine. Nothing is sent anywhere, and there is no API key. The OCR
engines download their models the first time you use them, so that first run needs internet. After
that everything works offline.

---

## Contributing

Issues and pull requests are welcome, in English or in French. If a PDF gives you a bad result, open
an issue with the page that fails, or with a small file that shows the same problem. Please do not
attach a real invoice: build a small example instead, like the ones in
[tests/fixtures/](tests/fixtures/).

---

## License

MIT, see [LICENSE](LICENSE). The default engines (pdfplumber, Tesseract, RapidOCR) also have
permissive licenses, so you can use this in a commercial product.
