Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ volume of invoices, saving time and reducing manual-entry errors. It:
1. extracts text from PDF files with a pluggable, cascading backend —
`pdfium` (default, no system deps), `pdftotext`, `text`, `pdfminer`,
`pdfplumber`, or OCR (`tesseract`, `ocrmypdf`, `docTR`, `paddleocr`,
`gvision`).
`gvision`, `pennyocr`).
2. searches for regex in the result using a YAML or JSON-based template system
(with an optional [AI fallback](https://invoice2data.readthedocs.io/en/latest/ai.html)).
3. saves results as CSV, JSON or XML, or renames PDF files to match the content.
Expand Down
2 changes: 2 additions & 0 deletions src/invoice2data/input/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from . import pdfoxide
from . import pdfplumber
from . import pdftotext
from . import pennyocr
from . import tesseract
from . import text

Expand All @@ -35,6 +36,7 @@
"gvision": gvision,
"doctr": doctr,
"paddleocr": paddleocr,
"pennyocr": pennyocr,
"text": text,
"ocrmypdf": ocrmypdf,
}
Expand Down
89 changes: 89 additions & 0 deletions src/invoice2data/input/pennyocr.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
"""PennyOCR input module for invoice2data.

`PennyOCR <https://pennyocr.com>`_ is a hosted, VLM-based OCR API
($0.75 per 1,000 pages, first 100 pages/month free). This backend uploads the
document and returns the extracted plain text — no local OCR engine, GPU or
system binary required. Handles scanned PDFs, photos and images (PDF, PNG,
JPEG, WebP, TIFF).

Set the ``PENNYOCR_API_KEY`` environment variable (keys from
https://pennyocr.com/dashboard/). Uses only the standard library.
"""

import json
import logging
import os
import uuid
from pathlib import Path
from urllib import request as _request


logger = logging.getLogger(__name__)

API_URL = os.environ.get("PENNYOCR_API_URL", "https://api.pennyocr.com/v1/ocr")
TIMEOUT = float(os.environ.get("PENNYOCR_TIMEOUT", "180"))


def have_pennyocr_key() -> bool:
return bool(os.environ.get("PENNYOCR_API_KEY"))


#: Backend availability check (see input.__interface__).
is_available = have_pennyocr_key

#: PennyOCR reads the whole document; it has no area-restricted mode.
SUPPORTS_AREA = False


def to_text(path: str, area_details: dict | None = None, **kwargs) -> str:
"""Send a document to the PennyOCR API and return its plain text.

Args:
path (str): Path of the invoice (PDF, PNG, JPEG, WebP or TIFF).
area_details (dict | None): Ignored — this backend reads whole pages.
**kwargs: Ignored, accepted for interface compatibility.

Returns:
str: Extracted text; multipage documents joined by form feeds,
matching the page separator convention of the pdftotext backend.

Raises:
OSError: If the API cannot be reached or rejects the request
(invalid key, out of credits, unreadable file).
"""
api_key = os.environ.get("PENNYOCR_API_KEY")
if not api_key:
raise OSError("PENNYOCR_API_KEY is not set")
if area_details is not None:
logger.warning("pennyocr does not support area extraction; reading whole pages")

data = Path(path).read_bytes()
boundary = uuid.uuid4().hex
filename = Path(path).name.replace('"', "")
body = (
(
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n'
"Content-Type: application/octet-stream\r\n\r\n"
).encode()
+ data
+ f"\r\n--{boundary}--\r\n".encode()
)
req = _request.Request(
API_URL + "?format=text",
data=body,
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": f"multipart/form-data; boundary={boundary}",
},
)
try:
with _request.urlopen(req, timeout=TIMEOUT) as resp:
payload = json.load(resp)
except Exception as e: # urllib raises subclasses of OSError for HTTP errors
raise OSError(f"PennyOCR request failed: {e}") from e

logger.debug(
"pennyocr extracted %s page(s) from %s", payload.get("pages"), filename
)
return payload.get("text", "")
59 changes: 59 additions & 0 deletions tests/test_pennyocr.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"""Tests for the PennyOCR input backend (network mocked)."""

import io
import json

import pytest

from invoice2data.input import INPUT_MODULES
from invoice2data.input import pennyocr


def test_registered():
assert INPUT_MODULES["pennyocr"] is pennyocr


def test_unavailable_without_key(monkeypatch):
monkeypatch.delenv("PENNYOCR_API_KEY", raising=False)
assert pennyocr.is_available() is False


def test_available_with_key(monkeypatch):
monkeypatch.setenv("PENNYOCR_API_KEY", "pk_live_test")
assert pennyocr.is_available() is True


def test_to_text_posts_multipart_and_returns_text(monkeypatch, tmp_path):
monkeypatch.setenv("PENNYOCR_API_KEY", "pk_live_test")
doc = tmp_path / "invoice.pdf"
doc.write_bytes(b"%PDF-fake")
captured = {}

class FakeResponse(io.BytesIO):
def __enter__(self):
return self

def __exit__(self, *exc):
return False

def fake_urlopen(req, timeout=0):
captured["url"] = req.full_url
captured["auth"] = req.get_header("Authorization")
captured["body"] = req.data
return FakeResponse(json.dumps({"pages": 1, "text": "TOTAL 12.34"}).encode())

monkeypatch.setattr(pennyocr._request, "urlopen", fake_urlopen)
text = pennyocr.to_text(str(doc))
assert text == "TOTAL 12.34"
assert captured["url"].endswith("format=text")
assert captured["auth"] == "Bearer pk_live_test"
assert b"%PDF-fake" in captured["body"]
assert b'filename="invoice.pdf"' in captured["body"]


def test_to_text_without_key_raises(monkeypatch, tmp_path):
monkeypatch.delenv("PENNYOCR_API_KEY", raising=False)
doc = tmp_path / "invoice.pdf"
doc.write_bytes(b"%PDF-fake")
with pytest.raises(OSError):
pennyocr.to_text(str(doc))
Loading