diff --git a/README.md b/README.md index 5561214a5..e4ccf3070 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/invoice2data/input/__init__.py b/src/invoice2data/input/__init__.py index 51ad126eb..fca1ac7cb 100644 --- a/src/invoice2data/input/__init__.py +++ b/src/invoice2data/input/__init__.py @@ -19,6 +19,7 @@ from . import pdfoxide from . import pdfplumber from . import pdftotext +from . import pennyocr from . import tesseract from . import text @@ -35,6 +36,7 @@ "gvision": gvision, "doctr": doctr, "paddleocr": paddleocr, + "pennyocr": pennyocr, "text": text, "ocrmypdf": ocrmypdf, } diff --git a/src/invoice2data/input/pennyocr.py b/src/invoice2data/input/pennyocr.py new file mode 100644 index 000000000..e3fed0eaa --- /dev/null +++ b/src/invoice2data/input/pennyocr.py @@ -0,0 +1,89 @@ +"""PennyOCR input module for invoice2data. + +`PennyOCR `_ 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", "") diff --git a/tests/test_pennyocr.py b/tests/test_pennyocr.py new file mode 100644 index 000000000..b9b2fc30f --- /dev/null +++ b/tests/test_pennyocr.py @@ -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))