""" OCR fallback for image-based PDF pages. Uses pytesseract (Tesseract OCR) to extract text from pages that contain no selectable text. easyocr is supported as an optional alternative backend controlled by the OCR_BACKEND environment variable. Detection strategy: a page is considered image-only when PyMuPDF's get_text() returns fewer than MIN_TEXT_CHARS characters after stripping. """ import logging import os from typing import Any, Dict, List import fitz # PyMuPDF logger = logging.getLogger(__name__) # Pages with fewer than this many characters are treated as image-only MIN_TEXT_CHARS = 20 # OCR backend: "tesseract" (default) or "easyocr" OCR_BACKEND = os.getenv("OCR_BACKEND", "tesseract").lower() def _page_is_image_only(page: fitz.Page) -> bool: """Return True when a PDF page has no meaningful selectable text.""" text = page.get_text().strip() return len(text) < MIN_TEXT_CHARS def _render_page_to_image(page: fitz.Page, dpi: int = 200) -> bytes: """Render a fitz page to PNG bytes at the given DPI.""" mat = fitz.Matrix(dpi / 72, dpi / 72) pix = page.get_pixmap(matrix=mat, alpha=False) return pix.tobytes("png") def _ocr_with_tesseract(image_bytes: bytes) -> str: """Run Tesseract OCR on raw PNG bytes and return extracted text.""" try: import io import pytesseract from PIL import Image except ImportError as exc: raise ImportError( "pytesseract and Pillow are required for OCR. " "Install them with: pip install pytesseract Pillow" ) from exc image = Image.open(io.BytesIO(image_bytes)) text = pytesseract.image_to_string(image, lang="eng") return text.strip() def _ocr_with_easyocr(image_bytes: bytes) -> str: """Run EasyOCR on raw PNG bytes and return extracted text.""" try: import easyocr import numpy as np from PIL import Image import io except ImportError as exc: raise ImportError( "easyocr, Pillow, and numpy are required for EasyOCR. " "Install them with: pip install easyocr Pillow numpy" ) from exc # EasyOCR reader is expensive to initialise — cache it at module level global _easyocr_reader # noqa: PLW0603 if "_easyocr_reader" not in globals(): _easyocr_reader = easyocr.Reader(["en"], gpu=False, verbose=False) image = Image.open(io.BytesIO(image_bytes)).convert("RGB") img_array = np.array(image) results = _easyocr_reader.readtext(img_array, detail=0, paragraph=True) return "\n".join(results).strip() def ocr_page(page: fitz.Page, dpi: int = 200) -> str: """ OCR a single fitz page and return the extracted text. Renders the page to a raster image at *dpi* resolution, then runs the configured OCR backend (``OCR_BACKEND`` env var, default ``tesseract``). Args: page: An open fitz.Page object. dpi: Render resolution. Higher values give better accuracy at the cost of memory and speed. Returns: Extracted text string, possibly empty if OCR yields nothing. Raises: ImportError: If the selected backend's dependencies are not installed. """ image_bytes = _render_page_to_image(page, dpi=dpi) if OCR_BACKEND == "easyocr": return _ocr_with_easyocr(image_bytes) return _ocr_with_tesseract(image_bytes) def extract_pdf_with_ocr(filepath: str, dpi: int = 200) -> List[Dict[str, Any]]: """ Extract text from a PDF, using OCR for image-only pages. For each page: - If PyMuPDF can extract at least ``MIN_TEXT_CHARS`` characters, use that text directly (fast path). - Otherwise render the page and run OCR (slow path). Pages that yield no text via either method are skipped. Args: filepath: Absolute path to the PDF file. dpi: Render DPI for OCR pages (default 200). Returns: List of dicts with keys ``text``, ``page``, ``chunk_type``, and ``ocr`` (bool, True when the text came from OCR). """ doc = fitz.open(filepath) pages: List[Dict[str, Any]] = [] try: for page_num, page in enumerate(doc, start=1): native_text = page.get_text().strip() if len(native_text) >= MIN_TEXT_CHARS: pages.append({ "text": native_text, "page": page_num, "chunk_type": "text", "ocr": False, }) continue # Image-only page — fall back to OCR logger.info( "Page %d of '%s' has no selectable text — running OCR (backend=%s)", page_num, filepath, OCR_BACKEND, ) try: ocr_text = ocr_page(page, dpi=dpi) if ocr_text: pages.append({ "text": ocr_text, "page": page_num, "chunk_type": "text", "ocr": True, }) else: logger.warning( "OCR returned no text for page %d of '%s'", page_num, filepath, ) except ImportError: logger.warning( "OCR backend '%s' not installed — skipping page %d of '%s'", OCR_BACKEND, page_num, filepath, ) except Exception as exc: logger.warning( "OCR failed for page %d of '%s': %s", page_num, filepath, exc, ) finally: doc.close() return pages