"""Classical OCR page reader: a `textsource.PageReader` that transcribes rendered pages on CPU with Tesseract and makes no model call. Shared by the two CPU backends: #84 pairs it with rules-only extraction, #85 with a local LLM extractor, so it is defined once here and reused rather than duplicated. The default engine runs Tesseract through `pytesseract`, which needs the `tesseract` binary installed on the host. The engine is injectable (`ocr_page`), so a test passes a fake and a deployment could swap in another classical engine (PaddleOCR) without touching the reader. """ from __future__ import annotations from pathlib import Path from typing import Callable, Optional # The same paragraph spacing reocr.py joins pages with, so the concatenated text reads as one document # and an evidence quote verifies as a verbatim substring of exactly this stored text (invariant 2). PAGE_SEPARATOR = "\n\n" def tesseract_ocr(path: Path) -> str: """Transcribe one page image with Tesseract. Imports `pytesseract` and Pillow lazily, so importing this module needs neither the packages nor the tesseract binary until a page is actually read.""" import pytesseract from PIL import Image with Image.open(path) as image: return pytesseract.image_to_string(image) class ClassicalPageReader: """A `textsource.PageReader` that OCRs a report's rendered pages on CPU and joins them into one report text, with no model call. The CPU backends read pages through this in place of the vision-model reader. `ocr_page` transcribes one page image and defaults to Tesseract; a test injects a fake.""" def __init__( self, *, ocr_page: Optional[Callable[[Path], str]] = None, page_separator: str = PAGE_SEPARATOR, ) -> None: self._ocr_page = ocr_page or tesseract_ocr self._page_separator = page_separator def read(self, page_images: list[Path]) -> str: parts: list[str] = [] for path in page_images: text = self._ocr_page(Path(path)).strip() if text: parts.append(text) return self._page_separator.join(parts) def tesseract_word_boxes(path: Path) -> tuple[list[dict], int, int]: """The words Tesseract reads on a page, each with its pixel box, plus the image size. Lazy imports, so importing this module needs neither pytesseract nor the tesseract binary until a page is read.""" import pytesseract from PIL import Image from pytesseract import Output with Image.open(path) as image: width, height = image.size data = pytesseract.image_to_data(image, output_type=Output.DICT) words: list[dict] = [] for i, text in enumerate(data["text"]): if text and text.strip(): words.append( { "text": text, "left": data["left"][i], "top": data["top"][i], "width": data["width"][i], "height": data["height"][i], } ) return words, width, height