"""Pluggable OCR. The engine is chosen by the OCR_ENGINE env var: tesseract (default) -- pytesseract + Pillow + the system `tesseract` binary none -- OCR disabled; images are marked 'skipped' Swapping in another engine later (e.g. a Claude-vision call) is just another branch in `run_ocr` behind the same signature. `run_ocr` raises OCRUnavailable when the configured engine's dependencies are missing (the worker then marks the row 'skipped' rather than 'failed'), and lets real OCR errors propagate. """ from __future__ import annotations import io import os class OCRUnavailable(RuntimeError): """The configured OCR engine can't run (missing binary / library).""" def engine() -> str: return os.environ.get("OCR_ENGINE", "tesseract").lower() def enabled() -> bool: return engine() != "none" def run_ocr(data: bytes, mime: str) -> str: """Return extracted text for an image. Raises OCRUnavailable if the engine is missing, or any other exception on a genuine OCR failure.""" eng = engine() if eng == "none": raise OCRUnavailable("OCR_ENGINE=none") if eng == "tesseract": return _tesseract(data) raise OCRUnavailable(f"unknown OCR_ENGINE={eng!r}") def _tesseract(data: bytes) -> str: try: import pytesseract from PIL import Image except ImportError as e: raise OCRUnavailable(f"pytesseract/Pillow not installed: {e}") from e try: img = Image.open(io.BytesIO(data)) except Exception as e: # not a decodable image raise OCRUnavailable(f"not a decodable image: {e}") from e try: return pytesseract.image_to_string(img) except pytesseract.TesseractNotFoundError as e: raise OCRUnavailable("tesseract binary not found on PATH") from e