Skip to content
Merged
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
62 changes: 49 additions & 13 deletions autobot-backend/media/document/ocr.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@
# minutes — a caller that wants more must say so explicitly.
DEFAULT_MAX_OCR_PAGES = 50

# Per-page OCR wall-clock ceiling, seconds. Tesseract on a dense A4 page at 300
# DPI runs in single-digit seconds; this leaves headroom while still bounding a
# pathological page. Override with AUTOBOT_DOCUMENT_OCR_PAGE_TIMEOUT.
DEFAULT_OCR_PAGE_TIMEOUT = 60


def ocr_dpi() -> int:
"""Resolve the rasterization DPI from config."""
Expand All @@ -52,6 +57,20 @@ def ocr_dpi() -> int:
)


def ocr_page_timeout() -> int:
"""Resolve the per-page OCR wall-clock ceiling, in seconds.

The page ceiling bounds how many pages are read; this bounds how long any
one of them may take. Both are needed on an ingest path taking arbitrary
uploads — a single page can be arbitrarily expensive regardless of count.
"""
return _positive_int_setting(
blank_to_none(config.misc.document_ocr_page_timeout),
DEFAULT_OCR_PAGE_TIMEOUT,
"AUTOBOT_DOCUMENT_OCR_PAGE_TIMEOUT",
)


def max_ocr_pages() -> int:
"""Resolve the per-document page ceiling for OCR."""
return _positive_int_setting(
Expand Down Expand Up @@ -128,9 +147,9 @@ def ocr_availability() -> Tuple[bool, str]:
return False, "pytesseract is not installed"

try:
import fitz # noqa: F401 (PyMuPDF; import name differs from the distribution)
import pypdfium2 # noqa: F401
except ImportError:
return False, "PyMuPDF is not installed, so PDF pages cannot be rasterized"
return False, "pypdfium2 is not installed, so PDF pages cannot be rasterized"

try:
pytesseract.get_tesseract_version()
Expand Down Expand Up @@ -181,33 +200,50 @@ def ocr_pdf_pages(raw: bytes, page_numbers: Sequence[int]) -> OcrResult:

def _run_ocr(raw: bytes, wanted: List[int], skipped: Tuple[int, ...]) -> OcrResult:
"""Render and read the pages, translating any failure into a reported reason."""
import fitz
import pypdfium2
import pytesseract
from PIL import Image

dpi = ocr_dpi()
recovered: Dict[int, str] = {}

try:
with fitz.open(stream=raw, filetype="pdf") as document:
document = pypdfium2.PdfDocument(raw)
try:
page_count = len(document)
for number in wanted:
if not 1 <= number <= document.page_count:
logger.warning("Requested OCR for page %d outside a %d-page document", number, document.page_count)
if not 1 <= number <= page_count:
logger.warning("Requested OCR for page %d outside a %d-page document", number, page_count)
continue
recovered[number] = _ocr_one_page(document, number, dpi, pytesseract, Image)
recovered[number] = _ocr_one_page(document, number, dpi, pytesseract)
finally:
document.close()
except Exception as exc:
logger.warning("OCR failed while rasterizing: %s", exc)
return OcrResult(attempted=False, reason=f"rasterization failed: {exc}", skipped_pages=tuple(wanted) + skipped)

return OcrResult(attempted=True, pages=recovered, skipped_pages=skipped)


def _ocr_one_page(document, number: int, dpi: int, pytesseract, image_module) -> str:
"""Read one page, degrading to empty text rather than failing the document."""
def _ocr_one_page(document, number: int, dpi: int, pytesseract) -> str:
"""Read one page, degrading to empty text rather than failing the document.

`render(scale=...)` takes a multiple of 72 DPI, pdfium's unit, and returns a
PIL image directly — so the explicit `frombytes` round-trip the PyMuPDF
version needed is gone rather than reimplemented.

The `timeout` is not optional decoration. This runs on an ingest path that
accepts arbitrary uploads, and `image_to_string` is otherwise unbounded: a
page whose MediaBox is pathological still renders a very large bitmap
however few pages were requested, so the page ceiling caps count and this
caps cost.
"""
try:
pixmap = document.load_page(number - 1).get_pixmap(dpi=dpi)
image = image_module.frombytes("RGB", (pixmap.width, pixmap.height), pixmap.samples)
return pytesseract.image_to_string(image) or ""
page = document[number - 1]
image = page.render(scale=dpi / 72).to_pil()
try:
return pytesseract.image_to_string(image, timeout=ocr_page_timeout()) or ""
finally:
image.close()
except Exception as exc:
logger.warning("OCR failed on page %d: %s", number, exc)
return ""
Expand Down
13 changes: 9 additions & 4 deletions autobot-backend/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -118,10 +118,15 @@ Pillow>=12.3.0 # Image processing - SECURITY UPDATE (OOB write fix)
# guard-import it and degrade to "pytesseract not installed" with no alert.
pytesseract>=0.3.13
# PDF rasterizer for the OCR fallback (#13896). pypdf cannot render pages, so
# without this a scanned PDF can be *detected* (#13884) but never *read*. Import
# name is `fitz`; media/document/ocr.py guard-imports it and degrades to
# "OCR unavailable" rather than failing extraction.
PyMuPDF>=1.26.0
# without this a scanned PDF can be *detected* (#13884) but never *read*.
# media/document/ocr.py guard-imports it and degrades to "OCR unavailable"
# rather than failing extraction.
#
# Apache-2.0/BSD, and it bundles pdfium, so no system package is required.
# Chosen over PyMuPDF, which is AGPL-3.0-or-commercial: AutoBot ships
# Apache-2.0, AGPL's copyleft reaches network use, and this repo already
# removed a weaker GPL-3.0 dependency for the same reason (0d67a956e).
pypdfium2>=4.30.0
jsonschema>=4.26.0 # Issue #7405: JSON Schema validation for /knowledge/extract
defusedxml>=0.7.1 # MVA-2039: XML parsing for WebDAV (Nextcloud connector)
# Optional (install separately for full media support):
Expand Down
2 changes: 1 addition & 1 deletion autobot-backend/tests/test_ocr_fallback_13896.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
the toolchain is absent — degrade with a reason, never raise, never claim an
attempt that did not happen. This is the half that protects hosts like the ones
#13885 found, where the binary shipped and the binding did not.
- End-to-end tests, which need ``pytesseract``, ``PyMuPDF`` and a working
- End-to-end tests, which need ``pytesseract``, ``pypdfium2`` and a working
tesseract binary. They ``skip`` where those are missing rather than passing
vacuously, so a green run in a stripped environment never reads as proof that
OCR works.
Expand Down
4 changes: 4 additions & 0 deletions autobot_shared/ssot_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -1587,6 +1587,10 @@ class MiscConfig(RedactedSettings):
# costs CPU-seconds, so an unbounded document can occupy a worker for
# minutes.
document_max_ocr_pages: str = Field(default="", alias="AUTOBOT_DOCUMENT_MAX_OCR_PAGES")
# Per-page OCR wall-clock ceiling (#13896 review). The page ceiling bounds
# count, this bounds cost: one page with a pathological MediaBox renders a
# very large bitmap however few pages were asked for.
document_ocr_page_timeout: str = Field(default="", alias="AUTOBOT_DOCUMENT_OCR_PAGE_TIMEOUT")
# #13896: master switch for the OCR fallback. Default on where the toolchain
# is present, since it only runs on pages that produced no text at all — a
# born-digital document never rasterizes. Set to "false" to trade scanned
Expand Down
5 changes: 5 additions & 0 deletions repo_tests/requirements_ci_drift_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,12 @@ def test_pytesseract_is_mirrored_now_that_this_pr_landed_it():
was steering toward — and this one has an end worth pinning: declared in
CI, and NOT carrying an exemption that would let it drift back out.
"""
ci = checker.ci_requirement_names()
allowlist = checker.load_allowlist()
assert "pytesseract" in ci, (
"pytesseract must be declared where CI installs from — an absent exemption "
"proves nothing on its own if the mirror it was waiting for went away."
)
assert "pytesseract" not in allowlist, (
"pytesseract is declared in requirements-ci now, so an allowlist entry "
"is a stale exemption — the drift audit fails on exactly this."
Expand Down
2 changes: 1 addition & 1 deletion requirements-ci/document.txt
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ markdownify==1.2.3
# pytesseract is the binding; the tesseract binary is installed by the
# "Install OCR system packages" step in .github/actions/setup-python-suite.
pytesseract>=0.3.13
PyMuPDF>=1.26.0 # import name `fitz`; renders PDF pages for OCR
pypdfium2>=4.30.0 # renders PDF pages for OCR; Apache-2.0/BSD, bundles pdfium
# #14551: production dep (autobot-backend/requirements.txt, Live Canvas
# HTML->PDF export). transcriber/tests/test_export_api.py gates on
# pytest.importorskip("weasyprint") — silently skipping without this.
Expand Down
Loading