diff --git a/application/Dockerfile b/application/Dockerfile index 33254d79..89bf4825 100644 --- a/application/Dockerfile +++ b/application/Dockerfile @@ -42,10 +42,11 @@ RUN pip install --no-cache-dir --upgrade pip && \ pip install --no-cache-dir tiktoken && \ pip install --no-cache-dir -r requirements.txt -# Optional docling parser engine (DOC_PARSER_ENGINE=docling, OCR for scanned -# PDFs/images, read_document's structured output) — OFF by default: it pulls -# the layout/OCR model stack and adds gigabytes to the image. anydoc (in -# requirements.txt) is the default parser and needs none of it. +# Optional docling parser engine (DOC_PARSER_ENGINE=docling, the docling OCR +# backend, read_document's structured output) — OFF by default: it pulls the +# layout/OCR model stack and adds gigabytes to the image. anydoc (in +# requirements.txt) is the default parser and needs none of it, and OCR runs +# natively on tesseract (below) without it. ARG INSTALL_DOCLING=false RUN if [ "$INSTALL_DOCLING" = "true" ]; then \ pip install --no-cache-dir -r requirements-docling.txt; \ @@ -66,11 +67,13 @@ RUN apt-get update && \ ln -s /usr/bin/python3.12 /usr/bin/python && \ rm -rf /var/lib/apt/lists/* -# The recommended OCR engine (OCR_ENGINE=tesseract) shells out to the system -# tesseract binary, so ship it alongside the optional docling engine. Extra -# language packs are a deployment concern (apt: tesseract-ocr-). -ARG INSTALL_DOCLING=false -RUN if [ "$INSTALL_DOCLING" = "true" ]; then \ +# OCR (OCR_ENABLED=true) runs on the system tesseract binary by default, +# with or without the docling extra, so ship it in every image (~35 MB). +# Opt out with --build-arg INSTALL_TESSERACT=false (e.g. a DeepSeek-OCR-only +# deployment). Extra language packs are a deployment concern +# (apt: tesseract-ocr-, then list them in OCR_LANGS). +ARG INSTALL_TESSERACT=true +RUN if [ "$INSTALL_TESSERACT" = "true" ]; then \ apt-get update && \ apt-get install -y --no-install-recommends tesseract-ocr tesseract-ocr-eng && \ rm -rf /var/lib/apt/lists/*; \ diff --git a/application/core/settings.py b/application/core/settings.py index ca2191b2..9ddc7328 100644 --- a/application/core/settings.py +++ b/application/core/settings.py @@ -2,7 +2,7 @@ import os from pathlib import Path from typing import Optional -from pydantic import Field, field_validator +from pydantic import AliasChoices, Field, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict current_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) @@ -110,13 +110,32 @@ class Settings(BaseSettings): # read_document tool. "anydoc" (default): firecrawl-anydoc, a Rust # converter with no ML models — milliseconds per file, ~100 MB peak RSS. # "docling": the layout/table-model pipeline (optional install; needed - # for OCR and for read_document's structured output). Files anydoc - # cannot convert (scanned PDFs, malformed input) fall back to docling - # when it is installed, otherwise to the legacy parsers. Rollback to the - # previous behaviour is this one variable. + # for read_document's structured output and the docling OCR backend). + # Files anydoc cannot convert (scanned PDFs, malformed input) fall back to + # docling when it is installed, otherwise to the native OCR parsers (OCR + # on) or the legacy parsers. Rollback to the previous behaviour is this + # one variable. DOC_PARSER_ENGINE: str = "anydoc" - DOCLING_OCR_ENABLED: bool = False # Enable OCR for docling parsers (PDF, images) - DOCLING_OCR_ATTACHMENTS_ENABLED: bool = False # Enable OCR for docling when parsing attachments + # OCR for scanned PDFs and images. OCR_ENABLED covers source ingestion, + # OCR_ATTACHMENTS_ENABLED chat attachments. Which stack performs it is + # OCR_BACKEND; which engine, OCR_ENGINE. The DOCLING_OCR_* names are the + # pre-2026-09 spellings and stay accepted as aliases. + OCR_ENABLED: bool = Field( + default=False, validation_alias=AliasChoices("OCR_ENABLED", "DOCLING_OCR_ENABLED") + ) + OCR_ATTACHMENTS_ENABLED: bool = Field( + default=False, + validation_alias=AliasChoices("OCR_ATTACHMENTS_ENABLED", "DOCLING_OCR_ATTACHMENTS_ENABLED"), + ) + # Which stack runs OCR when it is on: + # auto — docling when installed, otherwise native. + # docling — the layout-model pipeline (hybrid region OCR, reading order, + # table structure); needs the optional docling extra. + # native — pypdfium2/Pillow page rendering straight into tesseract or a + # DeepSeek-OCR endpoint (application/parser/file/ocr_parser.py). + # No ML models in the worker; tables come out as text lines + # under tesseract. + OCR_BACKEND: str = "auto" # Pages docling's threaded pipeline buffers in flight; the library # default (100) drives worker RSS to ~3 GB on a mid-size PDF. DOCLING_PIPELINE_QUEUE_MAX_SIZE: int = 2 @@ -141,34 +160,44 @@ class Settings(BaseSettings): # default: it rewrites content on a heuristic (>=3 uniform label+numbers # lines) validated only on a small corpus so far. ANYDOC_TABLEIZE: bool = False - # OCR engine used by the docling parsers when OCR is on - # (DOCLING_OCR_ENABLED / DOCLING_OCR_ATTACHMENTS_ENABLED). Benched 2026-08 - # on EN/ZH/table/degraded scans (docs/Guides/ocr has the menu): + # OCR engine used when OCR is on (OCR_ENABLED / OCR_ATTACHMENTS_ENABLED). + # Benched 2026-08 on EN/ZH/table/degraded scans (docs/Guides/ocr has the + # menu): # tesseract — recommended: best classic-engine accuracy (perfect EN word # recall, 0.000 bilingual CER, 100% table cells), ~35 MB, CPU-only. - # Needs the system binary + language packs (the Docker image installs - # them when built with INSTALL_DOCLING=true). + # Needs the system binary + language packs (the Docker image ships + # them unless built with INSTALL_TESSERACT=false). Both backends. + # deepseek — DeepSeek-OCR against an Ollama/vLLM endpoint + # (OCR_DEEPSEEK_*). Best table/CJK quality; the worker stays light + # (no layout models) but each page costs seconds on the model server. + # Both backends. # auto — docling's pick: ocrmac on macOS (excellent), rapidocr on Linux # (silently shreds some long text lines — avoid as a server default). # ocrmac | rapidocr — force one of those. - # deepseek — DeepSeek-OCR through docling's VLM pipeline against an - # Ollama/vLLM endpoint (OCR_DEEPSEEK_*). Best table/CJK quality; the - # worker stays light (~370 MB, no layout models) but each page costs - # seconds on the model server. - # An engine that is not installed degrades to "auto" with a warning - # instead of failing the parse. + # auto/ocrmac/rapidocr exist only inside docling; the native backend runs + # tesseract for them. An engine that is not installed degrades (docling: + # to "auto") with a warning instead of failing the parse. OCR_ENGINE: str = "tesseract" # Tesseract language packs, "+"-separated (e.g. "eng+chi_sim+deu"). Other # engines keep their own defaults — their language codes differ. OCR_LANGS: str = "eng" OCR_DEEPSEEK_URL: str = "http://localhost:11434/v1/chat/completions" OCR_DEEPSEEK_MODEL: str = "deepseek-ocr:3b" - # Chars-per-page floor below which an OCR'd PDF/image parse is treated as a docling - # pipeline dropout (long-running workers were observed returning zero characters for + # Native backend only: seconds allowed per page request to the DeepSeek + # endpoint (pages are sent one at a time). A 3B model on a laptop needs + # minutes; a vLLM GPU deployment, seconds. + OCR_DEEPSEEK_TIMEOUT: float = 300.0 + # Native backend only: resolution at which pages without a text layer are + # rendered before OCR. 200 suits tesseract; clamped to 72-600. + OCR_RENDER_DPI: int = 200 + # Chars-per-page floor below which an OCR'd PDF/image parse is treated as an OCR + # dropout (long-running docling workers were observed returning zero characters for # every scanned page after a long scanned PDF, with no error) rather than as content. - # Such a parse is retried once on a fresh full-page-OCR converter and then fails + # docling retries once on a fresh full-page-OCR converter; both backends then fail # loudly instead of indexing an empty document. 0 disables the guard. - DOCLING_OCR_MIN_CHARS_PER_PAGE: int = 20 + OCR_MIN_CHARS_PER_PAGE: int = Field( + default=20, validation_alias=AliasChoices("OCR_MIN_CHARS_PER_PAGE", "DOCLING_OCR_MIN_CHARS_PER_PAGE") + ) # Read PDF *attachments* via their embedded text layer (pypdfium2) instead # of docling, falling back to docling when there is no text layer to read. # Attachments go into a prompt, so docling's structural markdown earns far diff --git a/application/parser/document_reader.py b/application/parser/document_reader.py index 05d7e7b0..450a8400 100644 --- a/application/parser/document_reader.py +++ b/application/parser/document_reader.py @@ -169,7 +169,7 @@ def _resolve_ocr_enabled(ocr: str) -> bool: return True if ocr == "off": return False - return bool(getattr(settings, "DOCLING_OCR_ENABLED", False)) + return bool(getattr(settings, "OCR_ENABLED", False)) def _effective_engine(engine: str) -> str: diff --git a/application/parser/file/anydoc_parser.py b/application/parser/file/anydoc_parser.py index 0a3b8388..cebb220c 100644 --- a/application/parser/file/anydoc_parser.py +++ b/application/parser/file/anydoc_parser.py @@ -10,9 +10,10 @@ PDFs. anydoc never OCRs. It *detects* a scanned or image-only PDF and raises ``UnsupportedError`` ("OCR is required"), which is the routing point: a -parser given a ``fallback_parser`` (docling when installed, otherwise the -legacy parser for the suffix) delegates there; without one the file fails -loudly as ``DocumentParseError`` rather than being stored empty. +parser given a ``fallback_parser`` (docling when installed, the native OCR +parser when OCR is on without docling, otherwise the legacy parser for the +suffix) delegates there; without one the file fails loudly as +``DocumentParseError`` rather than being stored empty. """ import logging from pathlib import Path @@ -85,14 +86,21 @@ def _result_chars(result: Union[str, List[str]]) -> int: def _is_docling_backed(parser: Optional[BaseParser]) -> bool: - """True when ``parser`` is a docling parser (worth a trust-check re-parse).""" + """True when ``parser`` is a docling parser (worth a trust-check re-parse). + + Looks through ``NativeOcrPdfParser``, which under ``OCR_BACKEND=native`` + wraps the docling PDF parser as its ``text_parser`` and hands text-layer + PDFs — the only kind the trust check flags — straight to it. + """ if parser is None: return False try: from application.parser.file.docling_parser import DoclingParser except ImportError: return False - return isinstance(parser, DoclingParser) + if isinstance(parser, DoclingParser): + return True + return isinstance(getattr(parser, "text_parser", None), DoclingParser) class AnydocParser(BaseParser): @@ -120,6 +128,8 @@ class AnydocParser(BaseParser): # check flagged but kept — surfaced via ``get_file_metadata`` as # ``parse_warnings`` so the document carries its own caveat. self._last_warnings: Optional[Tuple[Path, List[str]]] = None + # (path, count) of scanned pages OCR'd into the most recent parse. + self._last_ocr_pages: Optional[Tuple[Path, int]] = None def _init_parser(self) -> Dict: # Import for real rather than trusting ``find_spec``: a wheel whose @@ -156,6 +166,7 @@ class AnydocParser(BaseParser): path = Path(file) self._last_warnings = None + self._last_ocr_pages = None try: content = anydoc.to_markdown(str(path)) except anydoc.ResourceLimitError as exc: @@ -196,7 +207,7 @@ class AnydocParser(BaseParser): def _finish_pdf(self, path: Path, content: str, errors: str) -> Union[str, List[str]]: """Post-process a successful anydoc PDF conversion. - Two PDF-only steps: + Three PDF-only steps: 1. Trust check (``PDF_TRUST_CHECK``): the two classes where anydoc drops text *silently* — Type0 fonts without ToUnicode, and @@ -204,7 +215,12 @@ class AnydocParser(BaseParser): on the docling fallback when one is wired; otherwise the anydoc output is kept and the problems ride along as ``parse_warnings`` metadata. - 2. ``ANYDOC_TABLEIZE`` (off by default): rewrite dot-leader / + 2. Mixed documents: anydoc only refuses a PDF when *every* page lacks + text, so a text document with scanned pages in it converts fine + and silently loses those pages. When the fallback parser can OCR + (it is wired with OCR on), the pages with no text layer are OCR'd + through it and appended (``_ocr_scanned_pages``). + 3. ``ANYDOC_TABLEIZE`` (off by default): rewrite dot-leader / whitespace table runs as GFM tables. Never applied to a docling re-parse — docling emits real tables already. """ @@ -218,6 +234,7 @@ class AnydocParser(BaseParser): f"anydoc output for {path.name} failed the PDF trust check " f"({'; '.join(problems)}); keeping it with parse_warnings" ) + content = self._ocr_scanned_pages(path, content) if settings.ANYDOC_TABLEIZE: from application.parser.file.tableize import tableize @@ -281,12 +298,58 @@ class AnydocParser(BaseParser): self.last_engine = getattr(fallback, "last_engine", None) or type(fallback).__name__ return result + def _ocr_scanned_pages(self, path: Path, content: str) -> str: + """Append OCR text for the pages of ``path`` that have no text layer. + + Runs only when the fallback parser both has OCR on and exposes + ``ocr_pages`` (the native OCR PDF parser, or the docling PDF parser). + With OCR off the fallback is the plain pypdf parser and mixed + documents keep today's behaviour: text pages only. An OCR failure is + logged and the anydoc text kept — a partial document beats a + rejected upload, and the loud-failure path stays reserved for fully + scanned files. + """ + fallback = self.fallback_parser + if not (getattr(fallback, "ocr_enabled", False) and hasattr(fallback, "ocr_pages")): + return content + from application.parser.file.ocr_parser import scanned_page_indices + + indices = scanned_page_indices(path) + if not indices: + return content + logger.info( + "%s has %d page(s) without a text layer among its text pages; OCR-ing them with %s", + path.name, + len(indices), + type(fallback).__name__, + ) + try: + if not fallback.parser_config_set: + fallback.init_parser() + texts = fallback.ocr_pages(path, indices) + except Exception: # noqa: BLE001 - keep the text pages rather than fail the file + logger.warning( + "OCR of the scanned pages of %s failed; indexing the text pages only", + path.name, + exc_info=True, + ) + return content + extra = [texts[index] for index in indices if texts.get(index, "").strip()] + if not extra: + return content + self._last_ocr_pages = (path, len(extra)) + return content.rstrip() + "\n\n" + "\n\n".join(extra) + "\n" + def get_file_metadata(self, file: Path) -> Dict: - """Surface trust-check findings for the file just parsed, if any.""" + """Surface trust-check findings and OCR'd page counts for the file just parsed.""" + metadata: Dict = {} last = self._last_warnings if last is not None and last[0] == Path(file): - return {"parse_warnings": list(last[1])} - return {} + metadata["parse_warnings"] = list(last[1]) + ocr = self._last_ocr_pages + if ocr is not None and ocr[0] == Path(file): + metadata["ocr_pages"] = ocr[1] + return metadata def _delegate( self, path: Path, errors: str, reason: str, ocr_needed: bool = False @@ -315,9 +378,10 @@ class AnydocParser(BaseParser): hint = " even with OCR enabled." else: hint = ( - ". Enable OCR to ingest scans: set DOCLING_OCR_ENABLED=true " - "(and DOCLING_OCR_ATTACHMENTS_ENABLED for attachments) with " - "the docling extra installed." + ". Enable OCR to ingest scans: set OCR_ENABLED=true (and " + "OCR_ATTACHMENTS_ENABLED for attachments). OCR runs on the " + "tesseract binary or a DeepSeek-OCR endpoint (OCR_ENGINE), or " + "through the optional docling extra when installed." ) raise DocumentParseError( f"{path.name} appears to be a scanned PDF (no text layer), and " diff --git a/application/parser/file/bulk.py b/application/parser/file/bulk.py index 77b97739..56ddfe4e 100644 --- a/application/parser/file/bulk.py +++ b/application/parser/file/bulk.py @@ -68,21 +68,70 @@ def _gained_format_entries() -> Dict[str, BaseParser]: return {suffix: AnydocParser() for suffix in ANYDOC_GAINED_SUFFIXES} -def _legacy_file_extractor(pdf_text_fast_path: bool = False) -> Dict[str, BaseParser]: +def _native_ocr_parsers( + text_parser: Optional[BaseParser] = None, +) -> Optional[Tuple[BaseParser, Callable[[], BaseParser]]]: + """PDF parser and image-parser factory for the native OCR backend. + + Args: + text_parser: Parser for PDFs whose every page has a text layer (the + docling PDF parser under the docling engine); None reads text + layers with pypdfium2. + + Returns: + ``(pdf_parser, image_parser_factory)``, or None when pypdfium2 or + Pillow is missing — both are core dependencies, so that is a broken + install, logged as such. + """ + from application.parser.file.ocr_parser import ( + NativeOcrImageParser, + NativeOcrPdfParser, + native_ocr_available, + ) + + if not native_ocr_available(): + logging.error( + "OCR is enabled but pypdfium2/Pillow are not importable, so the native " + "OCR backend cannot run; scanned PDFs and images will not be OCR'd" + ) + return None + return NativeOcrPdfParser(text_parser=text_parser), NativeOcrImageParser + + +def _image_entries(factory: Callable[[], BaseParser], suffixes) -> Dict[str, BaseParser]: + """One parser instance per image suffix.""" + return {suffix: factory() for suffix in suffixes} + + +_LEGACY_IMAGE_SUFFIXES = (".png", ".jpg", ".jpeg") + + +def _legacy_file_extractor(pdf_text_fast_path: bool = False, ocr_enabled: bool = False) -> Dict[str, BaseParser]: """Parser map that needs neither docling nor anydoc. Args: pdf_text_fast_path: Put the pypdfium2 text-layer parser in front of - the legacy PDF parser. + the PDF parser. + ocr_enabled: Use the native OCR parsers (tesseract / DeepSeek-OCR via + pypdfium2 + Pillow) for PDFs and images instead of the plain + pypdf / remote-service parsers. Returns: Dict[str, BaseParser]: Parser keyed by lower-case file suffix. """ - legacy_pdf: BaseParser = PDFParser() + from application.parser.file.ocr_parser import IMAGE_SUFFIXES + + pdf_parser: BaseParser = PDFParser() + images: Dict[str, BaseParser] = _image_entries(ImageParser, _LEGACY_IMAGE_SUFFIXES) + if ocr_enabled: + native = _native_ocr_parsers() + if native is not None: + pdf_parser, image_factory = native + images = _image_entries(image_factory, sorted(IMAGE_SUFFIXES)) if pdf_text_fast_path: - legacy_pdf = _wrap_pdf_fast_path(legacy_pdf) + pdf_parser = _wrap_pdf_fast_path(pdf_parser) return { - ".pdf": legacy_pdf, + ".pdf": pdf_parser, ".docx": DocxParser(), ".csv": PandasCSVParser(), ".xlsx": ExcelParser(), @@ -94,9 +143,7 @@ def _legacy_file_extractor(pdf_text_fast_path: bool = False) -> Dict[str, BasePa ".mdx": MarkdownParser(), ".json": JSONParser(), ".pptx": PPTXParser(), - ".png": ImageParser(), - ".jpg": ImageParser(), - ".jpeg": ImageParser(), + **images, **_build_audio_parser_mapping(), **_gained_format_entries(), } @@ -110,6 +157,12 @@ def _docling_file_extractor( ) -> Dict[str, BaseParser]: """docling parser map, or the legacy map when docling is not installed. + With OCR on, ``OCR_BACKEND`` decides who OCRs: docling's own hybrid + pipeline, or the native parsers (``ocr_parser.py``) with the docling PDF + parser kept — OCR off — for PDFs that have a text layer on every page. + Without docling installed the legacy map takes over, itself using the + native OCR parsers when OCR is on. + The availability check has to happen here, not at parse time: ``DoclingParser`` only imports docling inside ``_init_parser``, and the ``ImportError`` that raises is not a ``DocumentParseError``, so a map @@ -144,12 +197,27 @@ def _docling_file_extractor( except ImportError: logging.log( missing_log_level, - "docling is not installed. Using standard parsers. " - "For OCR and layout-model parsing, install with: pip install docling", + "docling is not installed. Using standard parsers%s. For layout-model " + "parsing, install with: pip install -r requirements-docling.txt", + " with native OCR" if ocr_enabled else "", ) - return _legacy_file_extractor(pdf_text_fast_path) + return _legacy_file_extractor(pdf_text_fast_path, ocr_enabled=ocr_enabled) - pdf_parser: BaseParser = DoclingPDFParser(ocr_enabled=ocr_enabled) + from application.parser.file.ocr_parser import IMAGE_SUFFIXES, resolve_ocr_backend + + native = None + if ocr_enabled and resolve_ocr_backend() == "native": + native = _native_ocr_parsers(text_parser=DoclingPDFParser(ocr_enabled=False)) + if native is not None: + pdf_parser, image_factory = native + images = _image_entries(image_factory, sorted(IMAGE_SUFFIXES)) + else: + pdf_parser = DoclingPDFParser(ocr_enabled=ocr_enabled) + # Images (with OCR) - only use Docling when OCR is enabled + image_factory = ( + (lambda: DoclingImageParser(ocr_enabled=True)) if ocr_enabled else ImageParser + ) + images = _image_entries(image_factory, sorted(IMAGE_SUFFIXES)) if pdf_text_fast_path: pdf_parser = _wrap_pdf_fast_path(pdf_parser) return { @@ -170,14 +238,8 @@ def _docling_file_extractor( ".rst": RstParser(), ".adoc": DoclingAsciiDocParser(), ".asciidoc": DoclingAsciiDocParser(), - # Images (with OCR) - only use Docling when OCR is enabled - ".png": DoclingImageParser(ocr_enabled=ocr_enabled) if ocr_enabled else ImageParser(), - ".jpg": DoclingImageParser(ocr_enabled=ocr_enabled) if ocr_enabled else ImageParser(), - ".jpeg": DoclingImageParser(ocr_enabled=ocr_enabled) if ocr_enabled else ImageParser(), - ".tiff": DoclingImageParser(ocr_enabled=ocr_enabled) if ocr_enabled else ImageParser(), - ".tif": DoclingImageParser(ocr_enabled=ocr_enabled) if ocr_enabled else ImageParser(), - ".bmp": DoclingImageParser(ocr_enabled=ocr_enabled) if ocr_enabled else ImageParser(), - ".webp": DoclingImageParser(ocr_enabled=ocr_enabled) if ocr_enabled else ImageParser(), + # Images: OCR'd by the selected backend when OCR is on, else ImageParser + **images, # Media/subtitles ".vtt": DoclingVTTParser(), **_build_audio_parser_mapping(), @@ -195,10 +257,11 @@ def _anydoc_file_extractor(ocr_enabled: bool, pdf_text_fast_path: bool = False) anydoc takes every format it converts; each of those parsers gets the base map's parser for the same suffix as ``fallback_parser``, so a - scanned PDF still reaches docling+OCR when docling is installed and the - legacy parser otherwise. docling, when installed, also keeps serving - what anydoc cannot: image OCR and ``.adoc``/``.vtt``/``.xml``. HTML goes - to the markdownify-based ``HTMLMarkdownParser``. + scanned PDF still reaches OCR — docling's when it is installed and + selected, the native OCR parser otherwise — and the legacy parser when + OCR is off. docling, when installed, also keeps serving what anydoc + cannot: ``.adoc``/``.vtt``/``.xml``. Images go to whichever OCR backend + is active. HTML goes to the markdownify-based ``HTMLMarkdownParser``. ``pdf_text_fast_path`` is deliberately ignored here: anydoc already reads the text layer in milliseconds *and* keeps headings/tables, and does its @@ -255,14 +318,16 @@ def get_default_file_extractor( The engine is ``settings.DOC_PARSER_ENGINE`` unless overridden: * ``anydoc`` (default): firecrawl-anydoc for office/PDF/tabular formats, - markdownify for HTML, docling (if installed) for image OCR and as the - fallback for files anydoc rejects; legacy parsers otherwise. + markdownify for HTML, the active OCR backend for images and as the + fallback for scanned PDFs; docling (if installed) for the rest anydoc + rejects, legacy parsers otherwise. * ``docling``: the docling map, with the legacy parsers when docling is not installed. Args: - ocr_enabled: Enable OCR in the docling parsers. Defaults to - ``settings.DOCLING_OCR_ENABLED``. + ocr_enabled: Enable OCR for scanned PDFs and images, through the + backend ``OCR_BACKEND`` resolves to. Defaults to + ``settings.OCR_ENABLED``. pdf_text_fast_path: Read PDFs via their embedded text layer (pypdfium2) instead of docling, falling back to docling for scans. Off by default: it trades docling's structural markdown for speed, @@ -275,7 +340,7 @@ def get_default_file_extractor( Dict[str, BaseParser]: Parser keyed by lower-case file suffix. """ if ocr_enabled is None: - ocr_enabled = settings.DOCLING_OCR_ENABLED + ocr_enabled = settings.OCR_ENABLED selected = (engine or getattr(settings, "DOC_PARSER_ENGINE", None) or "anydoc") selected = str(selected).strip().lower() if selected == "docling": diff --git a/application/parser/file/docling_parser.py b/application/parser/file/docling_parser.py index a56085ed..47d96836 100644 --- a/application/parser/file/docling_parser.py +++ b/application/parser/file/docling_parser.py @@ -22,6 +22,8 @@ from application.parser.file.base_parser import ( DocumentParseError, delegate_parse as _delegate, ) +from application.parser.file.ocr_parser import VALID_OCR_ENGINES as _VALID_OCR_ENGINES +from application.parser.file.ocr_parser import collapse_cjk_spaces from application.utils import truncate_to_line_boundary logger = logging.getLogger(__name__) @@ -73,7 +75,6 @@ def _apply_inference_settings() -> None: inference.compile_torch_models = settings.DOCLING_COMPILE_TORCH_MODELS -_VALID_OCR_ENGINES = ("tesseract", "auto", "ocrmac", "rapidocr", "deepseek") def _resolve_ocr_engine(requested: Optional[str]) -> str: @@ -102,8 +103,8 @@ def _resolve_ocr_engine(requested: Optional[str]) -> str: if engine == "tesseract" and shutil.which("tesseract") is None: logger.warning( "OCR_ENGINE=tesseract but no tesseract binary is on PATH (install " - "tesseract-ocr plus language packs, or build the Docker image with " - "INSTALL_DOCLING=true); using docling auto-selection" + "tesseract-ocr plus language packs; the Docker image ships them unless " + "built with INSTALL_TESSERACT=false); using docling auto-selection" ) return "auto" if engine == "ocrmac" and ( @@ -314,7 +315,7 @@ def _ocr_min_chars_per_page() -> int: return int( getattr( settings, - "DOCLING_OCR_MIN_CHARS_PER_PAGE", + "OCR_MIN_CHARS_PER_PAGE", _DEFAULT_OCR_MIN_CHARS_PER_PAGE, ) ) @@ -451,6 +452,9 @@ class DoclingParser(BaseParser): self.ocr_engine = ocr_engine self.ocr_languages = ocr_languages self.force_full_page_ocr = force_full_page_ocr + # Engine the current converter was built for (None with OCR off); + # drives tesseract-specific post-processing of the export. + self._active_ocr_engine: Optional[str] = None self._converter = None def _create_converter(self): @@ -477,6 +481,7 @@ class DoclingParser(BaseParser): _apply_inference_settings() engine = _resolve_ocr_engine(self.ocr_engine) if self.ocr_enabled else None + self._active_ocr_engine = engine if engine == "deepseek": return self._create_vlm_converter() @@ -607,8 +612,20 @@ class DoclingParser(BaseParser): f"Standard export minimal ({len(stripped_content)} chars), " f"extracting {len(extracted_texts)} texts directly" ) - return "\n\n".join(extracted_texts) + content = "\n\n".join(extracted_texts) + return self._postprocess_ocr_text(content) + + def _postprocess_ocr_text(self, content: str) -> str: + """Engine-specific cleanup of OCR'd text. + + tesseract's CJK models space every glyph ("互相 保密 协议"); the native + backend collapses that in ``TesseractEngine`` and the docling path + needs the same treatment or Chinese scans index worse here than there. + Other engines (and OCR off) return the export untouched. + """ + if self.ocr_enabled and self._active_ocr_engine == "tesseract": + return collapse_cjk_spaces(content) return content def _ocr_guard_applies(self, file: Path) -> bool: @@ -816,6 +833,35 @@ class DoclingPDFParser(DoclingParser): ) + def ocr_pages(self, file: Path, indices: List[int]) -> Dict[int, str]: + """Convert only the given 0-based pages of ``file`` and return their text. + + The counterpart of ``NativeOcrPdfParser.ocr_pages`` for the docling + backend: ``AnydocParser`` calls it for the scanned pages of a mixed + document. Each page is a separate ``convert`` with ``page_range`` so + the layout model sees one page at a time; a fully scanned page is one + bitmap region, which hybrid OCR reads in full. + + Raises: + DocumentParseError: docling failed on a page. + """ + if self._converter is None: + self._init_parser() + path = Path(file) + texts: Dict[int, str] = {} + for index in indices: + if index < 0: + continue + try: + result = self._converter.convert(str(path), page_range=(index + 1, index + 1)) + texts[index] = self._export_content(result.document).strip() + except Exception as exc: + raise DocumentParseError( + f"Failed to OCR page {index + 1} of {path.name} with docling: {exc}" + ) from exc + return texts + + class DoclingDocxParser(DoclingParser): """Docling-based DOCX parser.""" diff --git a/application/parser/file/ocr_parser.py b/application/parser/file/ocr_parser.py new file mode 100644 index 00000000..2d4938b9 --- /dev/null +++ b/application/parser/file/ocr_parser.py @@ -0,0 +1,693 @@ +"""Native OCR parsers: scanned PDFs and images without docling. + +OCR in DocsGPT has two backends, selected by ``OCR_BACKEND``: + +* ``docling`` — the layout-model pipeline in ``docling_parser.py``. Hybrid + OCR (only the bitmap regions of a page), reading-order and table + structure recovery, and five engines (tesseract / auto / ocrmac / + rapidocr / deepseek). Costs the optional docling install (torch, ONNX + models, gigabytes of image) and seconds to minutes per file. +* ``native`` (this module) — page rendering with pypdfium2 and Pillow, both + core dependencies, feeding one of two engines directly: the system + ``tesseract`` binary, or a DeepSeek-OCR model behind an OpenAI-compatible + endpoint (Ollama / vLLM). No ML models load in the worker. Pages that + carry a text layer are read through pypdfium2 and never OCR'd; pages + without one are rendered and OCR'd. What it lacks against docling is the + layout model: multi-column scans rely on tesseract's own page + segmentation, and tesseract yields tables as plain lines (DeepSeek-OCR + emits Markdown tables itself). + +``auto`` picks docling when it is installed and native otherwise, so a +deployment that never installs the docling extra gets working OCR from the +tesseract binary alone, and one that does install it keeps today's +behaviour unchanged. +""" +import base64 +import io +import logging +import re +import shutil +import subprocess +from pathlib import Path +from typing import Dict, List, Optional, Protocol, Tuple, Union + +from application.parser.file.base_parser import ( + BaseParser, + DocumentParseError, + delegate_parse, + module_available, +) + +logger = logging.getLogger(__name__) + +# Every engine ``OCR_ENGINE`` accepts. ``auto``, ``ocrmac`` and ``rapidocr`` +# exist only inside docling; the native backend maps them to tesseract. +VALID_OCR_ENGINES: Tuple[str, ...] = ("tesseract", "auto", "ocrmac", "rapidocr", "deepseek") +NATIVE_OCR_ENGINES: Tuple[str, ...] = ("tesseract", "deepseek") +VALID_OCR_BACKENDS: Tuple[str, ...] = ("auto", "docling", "native") + +IMAGE_SUFFIXES = frozenset({".png", ".jpg", ".jpeg", ".tiff", ".tif", ".bmp", ".webp"}) + +# Characters in a page's text layer at or above which the page is read from +# that layer instead of being OCR'd. Same separation ``PdfiumTextParser`` +# relies on: scanned pages sit at 0-17 chars, text pages in the hundreds+. +DEFAULT_TEXT_LAYER_MIN_CHARS = 32 +# Chars-per-page floor for the near-empty guard when the setting is unusable. +_DEFAULT_MIN_CHARS_PER_PAGE = 20 +_DEFAULT_RENDER_DPI = 200 +_MIN_RENDER_DPI, _MAX_RENDER_DPI = 72, 600 +_TESSERACT_TIMEOUT_SECONDS = 300 +# docling's DeepSeek-OCR prompt minus its ``<|grounding|>`` prefix: grounding +# makes the model wrap every element in ref/det tags with bounding boxes, +# which docling parses back into a layout tree. Plain Markdown is what the +# ingestion pipeline stores, so ask for that directly. +DEEPSEEK_PROMPT = "Convert the document to markdown." +_DEEPSEEK_MAX_TOKENS = 4096 +# Defensive cleanup should a served model still emit grounding markup. +_DEEPSEEK_DET_BLOCK_RE = re.compile(r"<\|det\|>.*?<\|/det\|>", re.DOTALL) +_DEEPSEEK_TAG_RE = re.compile(r"<\|/?ref\|>|<\|grounding\|>|<\|end▁of▁sentence\|>") +# tesseract's CJK models emit one space between every character ("互相 保密 协议") +# because they segment by glyph. Chinese and Japanese are written without +# word spaces, so collapse whitespace between two CJK characters (or CJK +# punctuation); spaces next to Latin text, digits and line breaks stay. +_CJK_CHAR = r"[\u3000-\u303f\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\uff00-\uffef]" +_CJK_SPACE_RE = re.compile(rf"(?<={_CJK_CHAR})[ \t]+(?={_CJK_CHAR})") + + +class OcrUnavailableError(DocumentParseError): + """The selected OCR engine cannot run on this host (missing binary or endpoint).""" + + +class OcrEngine(Protocol): + """Anything that turns one page image into text.""" + + name: str + + def ocr_image(self, image) -> str: # pragma: no cover - protocol + """Return the text (or Markdown) recognised in ``image`` (a PIL image).""" + ... + + +# --------------------------------------------------------------------------- +# Resolution +# --------------------------------------------------------------------------- + + +def native_ocr_available() -> bool: + """Whether the native backend's rendering stack (pypdfium2 + Pillow) is importable.""" + return module_available("pypdfium2") and module_available("PIL") + + +def resolve_ocr_backend(requested: Optional[str] = None) -> str: + """Resolve ``OCR_BACKEND`` to the backend that will actually run. + + Args: + requested: ``auto`` | ``docling`` | ``native``, or None to read the setting. + + Returns: + ``"docling"`` or ``"native"``. ``auto`` prefers docling when it is + installed; ``docling`` without the install degrades to native with a + warning rather than leaving OCR off. + """ + from application.core.settings import settings + + backend = str(requested or getattr(settings, "OCR_BACKEND", None) or "auto").strip().lower() + if backend not in VALID_OCR_BACKENDS: + logger.warning(f"Unknown OCR_BACKEND {backend!r}; using auto") + backend = "auto" + docling_installed = module_available("docling") + if backend == "docling" and not docling_installed: + logger.warning( + "OCR_BACKEND=docling but docling is not installed (pip install -r " + "requirements-docling.txt); using the native OCR backend" + ) + return "native" + if backend == "auto": + return "docling" if docling_installed else "native" + return backend + + +def resolve_native_ocr_engine(requested: Optional[str] = None) -> str: + """Map ``OCR_ENGINE`` onto an engine the native backend implements. + + Args: + requested: Engine name, or None to read ``settings.OCR_ENGINE``. + + Returns: + ``"tesseract"`` or ``"deepseek"``. docling-only engines (``auto``, + ``ocrmac``, ``rapidocr``) and unknown names become tesseract with a + warning, so switching a deployment off docling never silently + disables OCR. + """ + from application.core.settings import settings + + engine = str(requested or getattr(settings, "OCR_ENGINE", None) or "tesseract").strip().lower() + if engine in NATIVE_OCR_ENGINES: + return engine + if engine in VALID_OCR_ENGINES: + logger.warning( + f"OCR_ENGINE={engine!r} is only available through the docling backend; " + "the native backend is using tesseract" + ) + else: + logger.warning(f"Unknown OCR_ENGINE {engine!r}; the native backend is using tesseract") + return "tesseract" + + +def ocr_min_chars_per_page() -> int: + """Chars-per-page floor for the near-empty OCR guard (``OCR_MIN_CHARS_PER_PAGE``); 0 disables it.""" + from application.core.settings import settings + + try: + return int(getattr(settings, "OCR_MIN_CHARS_PER_PAGE", _DEFAULT_MIN_CHARS_PER_PAGE)) + except (TypeError, ValueError): + return _DEFAULT_MIN_CHARS_PER_PAGE + + +def render_dpi() -> int: + """Rendering resolution for pages that need OCR (``OCR_RENDER_DPI``), clamped to a sane range.""" + from application.core.settings import settings + + try: + dpi = int(getattr(settings, "OCR_RENDER_DPI", _DEFAULT_RENDER_DPI)) + except (TypeError, ValueError): + dpi = _DEFAULT_RENDER_DPI + return max(_MIN_RENDER_DPI, min(_MAX_RENDER_DPI, dpi)) + + +def _png_bytes(image) -> bytes: + """Encode a PIL image as PNG, flattening modes the engines cannot take.""" + if image.mode not in ("RGB", "L"): + image = image.convert("RGB") + buffer = io.BytesIO() + image.save(buffer, format="PNG") + return buffer.getvalue() + + +# --------------------------------------------------------------------------- +# Engines +# --------------------------------------------------------------------------- + + +class TesseractEngine: + """OCR through the system ``tesseract`` binary (stdin -> stdout, no Python wrapper). + + Attributes: + languages: Tesseract language codes; None reads ``OCR_LANGS`` per call. + psm: Page segmentation mode (3 = fully automatic, tesseract's default). + timeout: Seconds allowed per page. + """ + + name = "tesseract" + + def __init__( + self, + languages: Optional[List[str]] = None, + psm: int = 3, + timeout: float = _TESSERACT_TIMEOUT_SECONDS, + ) -> None: + self.languages = languages + self.psm = psm + self.timeout = timeout + + @staticmethod + def available() -> bool: + """Whether a ``tesseract`` binary is on PATH.""" + return shutil.which("tesseract") is not None + + def _language_arg(self) -> str: + if self.languages: + langs = [lang.strip() for lang in self.languages if lang and lang.strip()] + else: + from application.core.settings import settings + + configured = str(getattr(settings, "OCR_LANGS", "") or "eng") + langs = [lang.strip() for lang in configured.split("+") if lang.strip()] + return "+".join(langs) or "eng" + + def command(self) -> List[str]: + """The tesseract command line, for logging and tests.""" + return ["tesseract", "stdin", "stdout", "-l", self._language_arg(), "--psm", str(self.psm)] + + def ocr_image(self, image) -> str: + """OCR one PIL image. + + Raises: + OcrUnavailableError: No tesseract binary on PATH. + DocumentParseError: tesseract failed or timed out. + """ + if not self.available(): + raise OcrUnavailableError( + "OCR_ENGINE=tesseract but no tesseract binary is on PATH. Install " + "tesseract-ocr plus language packs (the Docker image ships them " + "unless built with INSTALL_TESSERACT=false), or set " + "OCR_ENGINE=deepseek / install the docling extra." + ) + try: + completed = subprocess.run( + self.command(), + input=_png_bytes(image), + capture_output=True, + timeout=self.timeout, + check=False, + ) + except subprocess.TimeoutExpired as exc: + raise DocumentParseError(f"tesseract timed out after {self.timeout:.0f}s on a page") from exc + except OSError as exc: + raise DocumentParseError(f"tesseract could not be started: {exc}") from exc + if completed.returncode != 0: + detail = completed.stderr.decode("utf-8", "replace").strip()[:300] + raise DocumentParseError(f"tesseract failed (exit {completed.returncode}): {detail}") + return collapse_cjk_spaces(completed.stdout.decode("utf-8", "replace")).strip() + + +def collapse_cjk_spaces(text: str) -> str: + """Remove the per-glyph spaces tesseract inserts inside CJK runs.""" + return _CJK_SPACE_RE.sub("", text) + + +def clean_deepseek_output(text: str) -> str: + """Strip DeepSeek-OCR grounding markup, keeping the referenced text.""" + text = _DEEPSEEK_DET_BLOCK_RE.sub("", text) + return _DEEPSEEK_TAG_RE.sub("", text).strip() + + +class DeepseekOcrEngine: + """DeepSeek-OCR over an OpenAI-compatible chat-completions endpoint. + + One request per page, sequentially. The image travels as a base64 data + URL, which both Ollama and vLLM accept. Pages are sent one at a time + because the model server, not the worker, is the bottleneck; docling's + default of four concurrent requests and a 90 s timeout is what broke on + a laptop-hosted Ollama in testing. + + Attributes: + url: Chat-completions URL (``OCR_DEEPSEEK_URL``). + model: Model name at that endpoint (``OCR_DEEPSEEK_MODEL``). + timeout: Seconds per page (``OCR_DEEPSEEK_TIMEOUT``). + prompt: Instruction sent with every page image. + """ + + name = "deepseek" + + def __init__( + self, + url: Optional[str] = None, + model: Optional[str] = None, + timeout: Optional[float] = None, + prompt: str = DEEPSEEK_PROMPT, + max_tokens: int = _DEEPSEEK_MAX_TOKENS, + ) -> None: + from application.core.settings import settings + + self.url = url or settings.OCR_DEEPSEEK_URL + self.model = model or settings.OCR_DEEPSEEK_MODEL + self.timeout = float(timeout if timeout is not None else getattr(settings, "OCR_DEEPSEEK_TIMEOUT", 300)) + self.prompt = prompt + self.max_tokens = max_tokens + + def payload(self, image) -> Dict: + """The chat-completions request body for one page image.""" + data_url = "data:image/png;base64," + base64.b64encode(_png_bytes(image)).decode("ascii") + return { + "model": self.model, + "messages": [ + { + "role": "user", + "content": [ + {"type": "image_url", "image_url": {"url": data_url}}, + {"type": "text", "text": self.prompt}, + ], + } + ], + "max_tokens": self.max_tokens, + "temperature": 0, + } + + def ocr_image(self, image) -> str: + """OCR one PIL image through the endpoint. + + Raises: + DocumentParseError: The request failed, timed out, or the + response was not a chat completion. + """ + import requests + + try: + response = requests.post(self.url, json=self.payload(image), timeout=self.timeout) + response.raise_for_status() + body = response.json() + except requests.RequestException as exc: + raise DocumentParseError( + f"DeepSeek-OCR request to {self.url} failed: {exc}. Check OCR_DEEPSEEK_URL " + f"and that model {self.model!r} is served (e.g. `ollama pull {self.model}`)." + ) from exc + except ValueError as exc: + raise DocumentParseError(f"DeepSeek-OCR endpoint {self.url} returned a non-JSON body") from exc + try: + content = body["choices"][0]["message"]["content"] + except (KeyError, IndexError, TypeError) as exc: + raise DocumentParseError( + f"DeepSeek-OCR endpoint {self.url} returned no chat completion: {str(body)[:200]}" + ) from exc + if isinstance(content, list): # some servers return content parts + content = "".join(part.get("text", "") for part in content if isinstance(part, dict)) + return clean_deepseek_output(str(content or "")) + + +def build_native_ocr_engine(engine: Optional[str] = None, languages: Optional[List[str]] = None) -> OcrEngine: + """Instantiate the engine ``OCR_ENGINE`` (or ``engine``) resolves to for the native backend.""" + resolved = resolve_native_ocr_engine(engine) + if resolved == "deepseek": + return DeepseekOcrEngine() + return TesseractEngine(languages=languages) + + +# --------------------------------------------------------------------------- +# Parsers +# --------------------------------------------------------------------------- + + +def _render_page(page, dpi: int): + """Render a pypdfium2 page to a PIL image at ``dpi``.""" + return page.render(scale=dpi / 72.0).to_pil() + + +def text_layer_counts(path: Path) -> List[int]: + """Characters in each page's text layer (pypdfium2), in page order. + + Raises: + Exception: Propagated from pypdfium2 when the file cannot be opened. + """ + import pypdfium2 as pdfium + + pdf = pdfium.PdfDocument(str(path)) + counts: List[int] = [] + try: + for index in range(len(pdf)): + page = pdf[index] + try: + textpage = page.get_textpage() + try: + counts.append(max(0, textpage.count_chars())) + finally: + textpage.close() + finally: + page.close() + finally: + pdf.close() + return counts + + +def scanned_page_indices(path: Path, min_chars: int = DEFAULT_TEXT_LAYER_MIN_CHARS) -> List[int]: + """0-based indices of the pages whose text layer is too thin to be real. + + The probe behind mixed-document handling: a text-layer converter (anydoc) + reads the text pages and silently skips these, so whoever owns OCR has to + fill them in. Returns [] when the file cannot be probed — the caller then + keeps what it has rather than failing a successful parse. + """ + try: + counts = text_layer_counts(Path(path)) + except Exception: # noqa: BLE001 - a probe must never fail the parse + logger.warning("Could not probe %s for scanned pages", Path(path).name, exc_info=True) + return [] + return [index for index, count in enumerate(counts) if count < max(1, int(min_chars))] + + +def _check_near_empty(name: str, engine: str, content: str, pages: int, ocr_pages: int) -> None: + """Loud failure for OCR that produced nothing, a warning for text-sparse documents. + + Mirrors the docling parser's dropout guard: failing only on positive + evidence — several pages that OCR'd to literally nothing — because a + photo, logo or chart is genuinely text-poor, and ``DocumentParseError`` + rejects the upload permanently. + """ + if ocr_pages == 0: + return + floor = ocr_min_chars_per_page() + chars = len(content.strip()) + if floor <= 0 or chars >= floor * pages: + return + if chars == 0 and pages > 1: + raise DocumentParseError( + f"OCR ({engine}) produced no text over {pages} pages of {name}; " + "document not indexed. Check the engine's languages (OCR_LANGS) and " + "that the scan is legible." + ) + logger.warning( + "%s OCR'd (%s) to only %d chars over %d page(s); indexing it as text-sparse", + name, + engine, + chars, + pages, + ) + + +class NativeOcrPdfParser(BaseParser): + """PDF parser that OCRs pages without a text layer and reads the rest directly. + + Per page: a text layer of at least ``min_text_chars`` characters is read + with pypdfium2; anything thinner is rendered and sent to the engine. So + a scan, a born-digital PDF and a mix of both all come out right without + any prior classification. + + Attributes: + engine: The ``OcrEngine``; built from ``OCR_ENGINE`` on first use when None. + text_parser: Optional parser for documents whose *every* page has a + text layer — used under ``DOC_PARSER_ENGINE=docling`` to keep + docling's structured Markdown for text PDFs while scans take the + native OCR path. None reads text layers with pypdfium2. + min_text_chars: Text-layer characters per page below which the page is OCR'd. + ocr_enabled: Always True; lets callers that inspect their fallback + parser (``AnydocParser``) phrase their errors correctly. + last_engine: Engine name behind the most recent parse. + """ + + ocr_enabled = True + + def __init__( + self, + engine: Optional[OcrEngine] = None, + text_parser: Optional[BaseParser] = None, + min_text_chars: int = DEFAULT_TEXT_LAYER_MIN_CHARS, + parser_config: Optional[Dict] = None, + ) -> None: + super().__init__(parser_config) + self.engine = engine + self.text_parser = text_parser + self.min_text_chars = max(1, int(min_text_chars)) + self.last_engine: Optional[str] = None + self._last_metadata: Dict = {} + + def _init_parser(self) -> Dict: + try: + import pypdfium2 # noqa: F401 + from PIL import Image # noqa: F401 + except ImportError as exc: + raise ImportError("pypdfium2 and Pillow are required for NativeOcrPdfParser") from exc + if self.engine is None: + self.engine = build_native_ocr_engine() + return { + "engine": self.engine.name, + "text_parser": type(self.text_parser).__name__ if self.text_parser else None, + "min_text_chars": self.min_text_chars, + } + + def _ensure_engine(self) -> OcrEngine: + if self.engine is None: + self.engine = build_native_ocr_engine() + return self.engine + + def _text_layer_counts(self, pdf) -> List[int]: + counts: List[int] = [] + for index in range(len(pdf)): + page = pdf[index] + try: + textpage = page.get_textpage() + try: + counts.append(max(0, textpage.count_chars())) + finally: + textpage.close() + finally: + page.close() + return counts + + def ocr_pages(self, file: Path, indices: List[int]) -> Dict[int, str]: + """OCR only the given 0-based pages of ``file``, ignoring their text layers. + + Used by ``AnydocParser`` for mixed documents: anydoc has already read + the text pages, so only the scanned ones come here. + + Returns: + Page index -> recognised text, in the order requested. + + Raises: + DocumentParseError: The file cannot be opened or the engine failed. + """ + import pypdfium2 as pdfium + + path = Path(file) + engine = self._ensure_engine() + try: + pdf = pdfium.PdfDocument(str(path)) + except Exception as exc: + raise DocumentParseError(f"Failed to open {path.name} with pypdfium2: {exc}") from exc + texts: Dict[int, str] = {} + try: + dpi = render_dpi() + for index in indices: + if index < 0 or index >= len(pdf): + continue + page = pdf[index] + try: + texts[index] = (engine.ocr_image(_render_page(page, dpi)) or "").strip() + finally: + page.close() + except DocumentParseError: + raise + except Exception as exc: + raise DocumentParseError(f"Failed to OCR pages of {path.name} ({engine.name}): {exc}") from exc + finally: + pdf.close() + self.last_engine = engine.name + return texts + + def _delegate_text(self, path: Path, errors: str) -> Union[str, List[str]]: + text_parser = self.text_parser + assert text_parser is not None + logger.info( + "%s has a text layer on every page; parsing with %s instead of OCR", + path.name, + type(text_parser).__name__, + ) + result = delegate_parse(text_parser, path, errors) + self.last_engine = getattr(text_parser, "last_engine", None) or type(text_parser).__name__ + self._last_metadata = {**text_parser.get_file_metadata(path), "parse_engine": self.last_engine} + return result + + def parse_file(self, file: Path, errors: str = "ignore") -> Union[str, List[str]]: + """Parse a PDF, OCR-ing only the pages that need it. + + Raises: + DocumentParseError: The file cannot be opened, the engine failed, + or several pages OCR'd to nothing. + """ + import pypdfium2 as pdfium + + path = Path(file) + engine = self._ensure_engine() + try: + pdf = pdfium.PdfDocument(str(path)) + except Exception as exc: + raise DocumentParseError(f"Failed to open {path.name} with pypdfium2: {exc}") from exc + + pages_text: List[str] = [] + ocr_pages = 0 + try: + page_count = len(pdf) + if page_count == 0: + raise DocumentParseError(f"{path.name} has no pages") + counts = self._text_layer_counts(pdf) + if self.text_parser is not None and all(count >= self.min_text_chars for count in counts): + pdf.close() + pdf = None + return self._delegate_text(path, errors) + dpi = render_dpi() + for index in range(page_count): + page = pdf[index] + try: + if counts[index] >= self.min_text_chars: + textpage = page.get_textpage() + try: + text = textpage.get_text_range() + finally: + textpage.close() + else: + text = engine.ocr_image(_render_page(page, dpi)) + ocr_pages += 1 + finally: + page.close() + pages_text.append((text or "").strip()) + except DocumentParseError: + raise + except Exception as exc: + raise DocumentParseError(f"Failed to parse {path.name} with native OCR ({engine.name}): {exc}") from exc + finally: + if pdf is not None: + pdf.close() + + content = "\n\n".join(pages_text) + _check_near_empty(path.name, engine.name, content, page_count, ocr_pages) + self.last_engine = engine.name + self._last_metadata = {"parse_engine": engine.name, "pdf_pages": page_count, "ocr_pages": ocr_pages} + logger.info( + "Parsed %s with native OCR (%s): %d/%d page(s) OCR'd, %d chars", + path.name, + engine.name, + ocr_pages, + page_count, + len(content), + ) + return content + + def get_file_metadata(self, file: Path) -> Dict: + """Engine and page counts for the most recently parsed file.""" + _ = file + return dict(self._last_metadata) + + +class NativeOcrImageParser(BaseParser): + """Image parser that OCRs every frame (multi-page TIFFs included) with the native engine.""" + + ocr_enabled = True + + def __init__(self, engine: Optional[OcrEngine] = None, parser_config: Optional[Dict] = None) -> None: + super().__init__(parser_config) + self.engine = engine + self.last_engine: Optional[str] = None + self._last_metadata: Dict = {} + + def _init_parser(self) -> Dict: + try: + from PIL import Image # noqa: F401 + except ImportError as exc: + raise ImportError("Pillow is required for NativeOcrImageParser") from exc + if self.engine is None: + self.engine = build_native_ocr_engine() + return {"engine": self.engine.name} + + def parse_file(self, file: Path, errors: str = "ignore") -> Union[str, List[str]]: + """OCR an image file. + + Raises: + DocumentParseError: The image cannot be decoded, the engine failed, + or a multi-frame image OCR'd to nothing. + """ + from PIL import Image, ImageSequence + + _ = errors + path = Path(file) + if self.engine is None: + self.engine = build_native_ocr_engine() + engine = self.engine + texts: List[str] = [] + try: + with Image.open(path) as image: + for frame in ImageSequence.Iterator(image): + texts.append((engine.ocr_image(frame.convert("RGB")) or "").strip()) + except DocumentParseError: + raise + except Exception as exc: + raise DocumentParseError(f"Failed to OCR {path.name} ({engine.name}): {exc}") from exc + + content = "\n\n".join(texts) + _check_near_empty(path.name, engine.name, content, max(1, len(texts)), len(texts)) + self.last_engine = engine.name + self._last_metadata = {"parse_engine": engine.name, "ocr_pages": len(texts)} + return content + + def get_file_metadata(self, file: Path) -> Dict: + """Engine and frame count for the most recently parsed file.""" + _ = file + return dict(self._last_metadata) diff --git a/application/parser/file/tableize.py b/application/parser/file/tableize.py index 1ea60173..9fd9d675 100644 --- a/application/parser/file/tableize.py +++ b/application/parser/file/tableize.py @@ -69,7 +69,9 @@ def tableize(markdown: str, min_rows: int = 3) -> str: if len(run) >= min_rows and len(widths) == 1: ncols = widths.pop() out.append("") - out.append("| " + " | ".join([" "] + [f"col{i + 1}" for i in range(ncols)]) + " |") + # GFM needs a header row; an empty one keeps invented labels out + # of the indexed text (they would otherwise be embedded as content). + out.append("|" + " |" * (ncols + 1)) out.append("|" + " --- |" * (ncols + 1)) for label, values, _ in run: # Only the label can carry a '|' (values are numeric tokens); diff --git a/application/requirements-docling.txt b/application/requirements-docling.txt index 07dd1074..1241187b 100644 --- a/application/requirements-docling.txt +++ b/application/requirements-docling.txt @@ -1,9 +1,11 @@ # Optional docling parser engine. # -# Needed only for: DOC_PARSER_ENGINE=docling, OCR of scanned PDFs and images -# (DOCLING_OCR_ENABLED / DOCLING_OCR_ATTACHMENTS_ENABLED), .adoc/.vtt/.xml -# source parsing, and the read_document tool's `structured` output. The -# default anydoc engine needs none of this. +# Needed only for: DOC_PARSER_ENGINE=docling, the docling OCR backend +# (OCR_BACKEND=docling: layout-model hybrid OCR, ocrmac/rapidocr engines), +# .adoc/.vtt/.xml source parsing, and the read_document tool's `structured` +# output. The default anydoc engine needs none of this, and OCR itself does +# not either: OCR_ENABLED=true with the tesseract binary (or a DeepSeek-OCR +# endpoint) runs through application/parser/file/ocr_parser.py. # # Install ON TOP of requirements.txt (torch/transformers stay pinned there, # shared with sentence-transformers): diff --git a/application/worker.py b/application/worker.py index a8fd9144..de038a76 100755 --- a/application/worker.py +++ b/application/worker.py @@ -1745,7 +1745,7 @@ def attachment_worker(self, file_info, user): # calls SimpleDirectoryReader without a ``file_extractor`` and so keeps # the ``DOC_PARSER_ENGINE`` default map, which retrieval quality depends on. file_extractor = get_default_file_extractor( - ocr_enabled=settings.DOCLING_OCR_ATTACHMENTS_ENABLED, + ocr_enabled=settings.OCR_ATTACHMENTS_ENABLED, pdf_text_fast_path=settings.ATTACHMENT_PDF_TEXT_FAST_PATH, ) _parser = file_extractor.get(os.path.splitext(filename)[1].lower()) diff --git a/deployment/docker-compose.yaml b/deployment/docker-compose.yaml index d1dfc7c4..4a974fb3 100644 --- a/deployment/docker-compose.yaml +++ b/deployment/docker-compose.yaml @@ -18,9 +18,11 @@ services: build: context: ../application args: - # Bake the optional docling engine (OCR, structured output) into the - # image: set INSTALL_DOCLING=true in ../.env or the shell. + # Bake the optional docling engine (layout-model OCR backend, structured + # output) into the image: set INSTALL_DOCLING=true in ../.env or the shell. INSTALL_DOCLING: ${INSTALL_DOCLING:-false} + # The tesseract binary behind OCR_ENABLED=true; on by default (~35 MB). + INSTALL_TESSERACT: ${INSTALL_TESSERACT:-true} env_file: - ../.env environment: @@ -47,6 +49,7 @@ services: context: ../application args: INSTALL_DOCLING: ${INSTALL_DOCLING:-false} + INSTALL_TESSERACT: ${INSTALL_TESSERACT:-true} # Consumes the default queue AND the dedicated `parsing` queue (read_document / # parse_document). Without `parsing` here the read_document await never resolves. # For heavy/OCR parsing run a separate worker with `-Q parsing`. diff --git a/deployment/sandbox/README.md b/deployment/sandbox/README.md index 8de18717..b4dd0765 100644 --- a/deployment/sandbox/README.md +++ b/deployment/sandbox/README.md @@ -200,7 +200,7 @@ Run a dedicated parsing worker that consumes the `parsing` queue: celery -A application.app.celery worker -Q parsing -l INFO ``` -It can be GPU-enabled with its own env (`DOCLING_OCR_ENABLED=true` plus GPU +It can be GPU-enabled with its own env (`OCR_ENABLED=true` plus GPU libraries) so OCR-heavy parsing runs on a separate, optionally larger pool. **Dev / single-worker setups:** without a dedicated parsing worker the default diff --git a/docs/content/Deploying/DocsGPT-Settings.mdx b/docs/content/Deploying/DocsGPT-Settings.mdx index dee844db..35298ba7 100644 --- a/docs/content/Deploying/DocsGPT-Settings.mdx +++ b/docs/content/Deploying/DocsGPT-Settings.mdx @@ -206,19 +206,23 @@ for the engines and flows. | Setting | Default | Description | | --- | --- | --- | -| `DOC_PARSER_ENGINE` | `anydoc` | Parser engine: `anydoc` (fast Rust converter, no ML models) or `docling` (layout/table models, OCR, structured output). Files anydoc cannot read fall back to Docling when installed. Docling is an optional extra: `pip install -r application/requirements-docling.txt`, or Docker builds with `--build-arg INSTALL_DOCLING=true`. | -| `DOCLING_OCR_ENABLED` | `false` | OCR for source ingestion (Docling only). | -| `DOCLING_OCR_ATTACHMENTS_ENABLED` | `false` | OCR for chat attachments (Docling only). | +| `DOC_PARSER_ENGINE` | `anydoc` | Parser engine: `anydoc` (fast Rust converter, no ML models) or `docling` (layout/table models, structured output). Files anydoc cannot read fall back to Docling when installed. Docling is an optional extra: `pip install -r application/requirements-docling.txt`, or Docker builds with `--build-arg INSTALL_DOCLING=true`. | +| `OCR_ENABLED` | `false` | OCR for source ingestion. Alias: `DOCLING_OCR_ENABLED`. | +| `OCR_ATTACHMENTS_ENABLED` | `false` | OCR for chat attachments. Alias: `DOCLING_OCR_ATTACHMENTS_ENABLED`. | +| `OCR_BACKEND` | `auto` | Who performs OCR: `auto` (Docling when installed, else native), `native` (pypdfium2 + Pillow rendering into tesseract or DeepSeek-OCR; no docling needed), or `docling` (layout-model hybrid OCR). See the [OCR guide](/Guides/ocr#ocr-backends). | | `ATTACHMENT_PDF_TEXT_FAST_PATH` | `true` | Under the `docling` engine, read PDF attachments through their text layer (pypdfium2) and only hand scans to Docling. Not needed under `anydoc`. | | `DOCLING_TABULAR_MAX_BYTES` | `2000000` | Docling engine: CSV/XLSX larger than this (by content) use the lightweight tabular parsers. | | `DOCLING_MARKUP_MAX_BYTES` | `8000000` | Docling engine: HTML/VTT larger than this are head-truncated before parsing. | | `MARKUP_MAX_BYTES` | `8000000` | anydoc engine: HTML/XHTML larger than this are head-truncated before the `markdownify` parser runs (its tree costs ~50x the input). `0` disables the gate. | | `PDF_TRUST_CHECK` | `true` | Trust-check anydoc's PDF output for the two silent-loss classes (Type0 fonts without ToUnicode, CJK-declaring PDFs with CJK-less text). Flagged files re-parse on Docling when installed, else carry `parse_warnings` metadata. | | `ANYDOC_TABLEIZE` | `false` | Rewrite dot-leader / whitespace-aligned table runs in anydoc's PDF markdown into GFM tables. | -| `OCR_ENGINE` | `tesseract` | OCR engine when OCR is on: `tesseract` (recommended), `auto`, `ocrmac`, `rapidocr`, or `deepseek` (VLM endpoint). Unavailable engines degrade to `auto` with a warning. See the [OCR guide](/Guides/ocr). | +| `OCR_ENGINE` | `tesseract` | OCR engine when OCR is on: `tesseract` (recommended, both backends), `deepseek` (VLM endpoint, both backends), or the Docling-only `auto`, `ocrmac`, `rapidocr`. Unavailable engines degrade with a warning. See the [OCR guide](/Guides/ocr). | | `OCR_LANGS` | `eng` | Tesseract language packs, `+`-separated (e.g. `eng+chi_sim+deu`). | | `OCR_DEEPSEEK_URL` | `http://localhost:11434/v1/chat/completions` | OpenAI-compatible endpoint for `OCR_ENGINE=deepseek` (Ollama or vLLM). | | `OCR_DEEPSEEK_MODEL` | `deepseek-ocr:3b` | Model name at that endpoint. | +| `OCR_DEEPSEEK_TIMEOUT` | `300` | Native backend: seconds allowed per page request to the DeepSeek endpoint (pages are sent one at a time). | +| `OCR_RENDER_DPI` | `200` | Native backend: resolution at which pages without a text layer are rendered before OCR (clamped to 72-600). | +| `OCR_MIN_CHARS_PER_PAGE` | `20` | Chars-per-page floor below which an OCR'd parse is treated as an OCR dropout and fails loudly instead of indexing an empty document; `0` disables. Alias: `DOCLING_OCR_MIN_CHARS_PER_PAGE`. | ## Speech-to-Text Settings diff --git a/docs/content/Guides/ocr.mdx b/docs/content/Guides/ocr.mdx index bfa90ec5..decc29f8 100644 --- a/docs/content/Guides/ocr.mdx +++ b/docs/content/Guides/ocr.mdx @@ -26,9 +26,10 @@ DOC_PARSER_ENGINE=anydoc anydoc's other formats (DOC, PPT/PPS/POT, XLS, ODT/ODS/ODP, RTF and the macro-enabled Office variants). It never performs OCR: a scanned or image-only PDF is *detected* and handed to the fallback - parser — Docling when it is installed, the legacy text parsers otherwise — - and if nothing can read the file the upload fails with a clear error - instead of storing an empty document. + parser — the active OCR backend when OCR is on (see below), Docling when + it is installed, the legacy text parsers otherwise — and if nothing can + read the file the upload fails with a clear error instead of storing an + empty document. - `docling`: the previous default. Docling's layout and table models produce structured Markdown, support OCR, and back the `read_document` tool's `structured` output, at the cost of a large dependency tree (torch, @@ -36,14 +37,16 @@ DOC_PARSER_ENGINE=anydoc this one variable; nothing else changes. With `DOC_PARSER_ENGINE=anydoc`, Docling still handles what anydoc cannot when -it is installed: image OCR (`DOCLING_OCR_ENABLED`), `.adoc`, `.vtt` and `.xml` -sources, and the fallback for files anydoc rejects. Without Docling those -formats use the standard parsers, and images are only read when +it is installed: `.adoc`, `.vtt` and `.xml` sources, the fallback for files +anydoc rejects, and — when it is the OCR backend — scanned PDFs and images. +Without Docling those formats use the standard parsers; OCR still works +through the native backend, and with OCR off images are only read when `PARSE_IMAGE_REMOTE=true`. ## Installing the docling engine -docling is not part of the base install. Add it when you need OCR, +docling is not part of the base install, and OCR does not need it (see +[OCR backends](#ocr-backends)). Add it when you want its layout-model OCR, `.adoc`/`.vtt`/`.xml` source parsing, or `read_document`'s `structured` output: @@ -59,44 +62,79 @@ docker build --build-arg INSTALL_DOCLING=true ./application `deployment/docker-compose.yaml` forwards the same switch, so setting `INSTALL_DOCLING=true` in `.env` (or the shell) bakes docling into locally -built backend and worker images. `setup.sh` writes it automatically when you -answer yes to the OCR question. Either way no code changes are needed — -docling is picked up as the fallback engine as soon as it is importable, and -`DOC_PARSER_ENGINE=docling` makes it the primary one. +built backend and worker images; `setup.sh` offers it as a follow-up to the +OCR question. Either way no code changes are needed — docling is picked up as +the fallback engine (and, under `OCR_BACKEND=auto`, as the OCR backend) as +soon as it is importable, and `DOC_PARSER_ENGINE=docling` makes it the +primary parser. -## Docling OCR +## OCR -OCR is optional, Docling-only, and controlled by two on/off settings plus an +OCR is optional and controlled by two on/off settings, a backend and an engine choice: ```env -DOCLING_OCR_ENABLED=false -DOCLING_OCR_ATTACHMENTS_ENABLED=false +OCR_ENABLED=false +OCR_ATTACHMENTS_ENABLED=false +OCR_BACKEND=auto OCR_ENGINE=tesseract ``` -- `DOCLING_OCR_ENABLED`: OCR behavior for Source Docs ingestion. -- `DOCLING_OCR_ATTACHMENTS_ENABLED`: OCR behavior for chat attachments uploaded from the message box. -- `OCR_ENGINE`: which engine performs the OCR when it is on (next section). +- `OCR_ENABLED`: OCR behavior for Source Docs ingestion. +- `OCR_ATTACHMENTS_ENABLED`: OCR behavior for chat attachments uploaded from the message box. +- `OCR_BACKEND`: which stack performs the OCR (next section). +- `OCR_ENGINE`: which recognition engine it uses ([Choosing the OCR engine](#choosing-the-ocr-engine)). + +The older names `DOCLING_OCR_ENABLED` and `DOCLING_OCR_ATTACHMENTS_ENABLED` +are still accepted as aliases. Under the default anydoc engine, a scanned PDF reaches OCR through anydoc's -own detection: anydoc refuses it ("OCR is required") and the docling fallback -takes over. If that fallback also extracts almost nothing — OCR off, or no -docling installed — the upload now fails with a clear message instead of -silently indexing an empty document. +own detection: anydoc refuses it ("OCR is required") and the OCR backend +takes over as the fallback parser. If that fallback also extracts almost +nothing — OCR off, or no engine available — the upload fails with a clear +message instead of silently indexing an empty document. + +Mixed documents — text pages with scanned pages among them — convert +through anydoc, which reads the text pages and skips the scanned ones. With +OCR on, DocsGPT probes every page's text layer, OCRs the pages that have +none through the active backend, and appends their text; the document's +metadata records the count as `ocr_pages`. With OCR off, only the text pages +are indexed. + +## OCR backends + +Docling is not the only way to OCR. The `native` backend renders the pages +that need it with pypdfium2 and Pillow — both already core dependencies — +and feeds them straight into tesseract or a DeepSeek-OCR endpoint. No ML +models load in the worker, and nothing beyond the ~35 MB tesseract binary +(shipped in the Docker image) is installed. + +| `OCR_BACKEND` | What runs | When to pick it | +|---|---|---| +| `auto` (default) | Docling when the docling extra is installed, `native` otherwise. | Leave it: a plain install gets working OCR from tesseract alone, and installing docling upgrades OCR without touching config. | +| `native` | pypdfium2 + Pillow page rendering into `tesseract` or `deepseek`. Pages that carry a text layer are read directly and never OCR'd; pages without one are rendered at `OCR_RENDER_DPI` (200) and OCR'd. Multi-frame TIFFs are read frame by frame. | You do not want docling's dependency tree or memory footprint, or you have docling installed for `structured` output but want lightweight OCR. | +| `docling` | Docling's layout-model pipeline: hybrid OCR (only the bitmap regions of a page), reading-order recovery, TableFormer table structure, and the `auto` / `ocrmac` / `rapidocr` engines. | Multi-column scans, scanned tables you need as Markdown tables under tesseract, or macOS `ocrmac`. Needs the docling extra. | + +The trade-off is the layout model. Under `native`, multi-column scans rely +on tesseract's own page segmentation and tesseract yields tables as plain +lines; DeepSeek-OCR emits Markdown tables itself, so it remains the quality +path on either backend. Under `DOC_PARSER_ENGINE=docling` with the `native` +backend, PDFs whose every page has a text layer still go through Docling +(OCR off) for its structured Markdown; only documents with scanned pages +take the native path. ## Choosing the OCR engine Benchmarked 2026-08 on English, bilingual EN/ZH, table-heavy and degraded scans (all engines driven through docling so layout handling is identical): -| `OCR_ENGINE` | Role | Notes | -|---|---|---| -| `tesseract` | **recommended default** | Best classic-engine accuracy in the bench: perfect EN word recall on all docs, 0.000 CER on the bilingual page, 100% table cells, robust to mild degradation. ~35 MB of *system* packages, CPU-only. Needs the `tesseract` binary + language packs in the image — installed automatically when the Docker image is built with `INSTALL_DOCLING=true`; set languages via `OCR_LANGS` (e.g. `eng+chi_sim`). | -| `auto` | convenience | docling picks: `ocrmac` on macOS (excellent, ~1 s/page), `rapidocr` on Linux — see below before relying on it server-side. Also the automatic fallback whenever the selected engine is not installed. | -| `ocrmac` | macOS only | Best raw accuracy and fastest of all classic engines; irrelevant for Linux deploys. | -| `rapidocr` | pip-only fallback | No system packages needed, perfect on tables/CJK — but it silently shreds some long text lines into garbage at every setting tried, which is content loss for RAG ingestion. Avoid as a server default until fixed upstream. | -| `deepseek` | best quality, heavy on the *server* | DeepSeek-OCR through docling's VLM pipeline. Only engine that reconstructs totals rows as table rows; near-perfect CJK; barely affected by degradation. The ingestion worker gets *lighter* (~370 MB RSS — no layout models); the model runs in Ollama or vLLM. Costs: a GPU/Apple-Silicon endpoint, ~seconds per page, and occasional silent drops of page-level elements (titles). | +| `OCR_ENGINE` | Backends | Role | Notes | +|---|---|---|---| +| `tesseract` | native, docling | **recommended default** | Best classic-engine accuracy in the bench: perfect EN word recall on all docs, 0.000 CER on the bilingual page, 100% table cells, robust to mild degradation. ~35 MB of *system* packages, CPU-only. Needs the `tesseract` binary + language packs — shipped in the Docker image (opt out with `--build-arg INSTALL_TESSERACT=false`); set languages via `OCR_LANGS` (e.g. `eng+chi_sim`). | +| `deepseek` | native, docling | best quality, heavy on the *server* | DeepSeek-OCR against an OpenAI-compatible endpoint. Only engine that reconstructs totals rows as table rows; near-perfect CJK; barely affected by degradation. The ingestion worker stays light (no layout models); the model runs in Ollama or vLLM. Costs: a GPU/Apple-Silicon endpoint, ~seconds per page, and occasional silent drops of page-level elements (titles). | +| `auto` | docling | convenience | docling picks: `ocrmac` on macOS (excellent, ~1 s/page), `rapidocr` on Linux — see below before relying on it server-side. Also docling's automatic fallback whenever the selected engine is not installed. The native backend runs tesseract for it. | +| `ocrmac` | docling | macOS only | Best raw accuracy and fastest of all classic engines; irrelevant for Linux deploys. | +| `rapidocr` | docling | pip-only fallback | No system packages needed, perfect on tables/CJK — but it silently shreds some long text lines into garbage at every setting tried, which is content loss for RAG ingestion. Avoid as a server default until fixed upstream. | For `deepseek`, point the worker at an OpenAI-compatible endpoint: @@ -104,14 +142,61 @@ For `deepseek`, point the worker at an OpenAI-compatible endpoint: OCR_ENGINE=deepseek OCR_DEEPSEEK_URL=http://localhost:11434/v1/chat/completions # Ollama default OCR_DEEPSEEK_MODEL=deepseek-ocr:3b +OCR_DEEPSEEK_TIMEOUT=300 # native backend: seconds per page ``` Ollama works out of the box (`ollama pull deepseek-ocr:3b`); for real throughput serve `deepseek-ai/DeepSeek-OCR` with vLLM on a GPU and set the -URL accordingly. +URL accordingly. The native backend sends pages one at a time, so a slow +laptop-hosted model only needs a generous `OCR_DEEPSEEK_TIMEOUT`; docling's +VLM pipeline uses its own 90 s timeout and four concurrent requests. -A selected engine that is not available (no tesseract binary, no macOS for -ocrmac) degrades to `auto` with a warning rather than failing parses. +A selected engine that is not available degrades rather than failing parses: +under docling, a missing tesseract binary or non-macOS `ocrmac` falls back to +`auto` with a warning; under `native`, a docling-only engine becomes +tesseract, and a missing tesseract binary fails the scanned file with an +install hint (text-layer documents are unaffected). + + +A language listed in `OCR_LANGS` whose tesseract pack is not installed fails +every scanned page loudly (tesseract exits with "Error opening data file +... chi_sim.traineddata"), on both backends. Install the pack before +listing it: `apt-get install tesseract-ocr-chi-sim` in the image, or on +macOS download `chi_sim.traineddata` into `$(brew --prefix)/share/tessdata`. + + +### DeepSeek through docling versus native + +`OCR_ENGINE=deepseek` behaves differently on the two backends, and the +difference matters more than for tesseract: + +- **native** sends one request per page that lacks a text layer and reads + every other page directly. Pages go one at a time with + `OCR_DEEPSEEK_TIMEOUT` per request, so a slow model server just takes + longer. +- **docling** uses its VLM pipeline, which *replaces* the whole converter: + every page of every PDF that reaches docling goes to the model, text layer + or not (a 39-page text PDF measured 721 s against 20 s on the other paths), + with four concurrent requests and a fixed 90 s timeout. In testing it also + dropped page-level elements — a title, an intro paragraph — that the same + model kept through the native path, and it logs a burst of pydantic + serialization warnings per conversion. Its one advantage was table + structure: it was the only configuration that returned an invoice's + subtotal/VAT/total rows as table rows. + +Measured on the same scans (2026-09, MacBook Air, Ollama `deepseek-ocr:3b`): + +| Configuration | Scanned page | Image | Accuracy | Tables | Chinese | +|---|---|---|---|---|---| +| tesseract, native | 1-2 s | 1-2 s | exact | flat lines | clean | +| deepseek, native | 15-30 s | 15-25 s | exact | Markdown table, totals as bold lines | clean | +| tesseract, docling | 3-4 s | ~8 s | exact | Markdown body, totals shredded | clean | +| deepseek, docling | ~20 s | ~20 s | drops titles/paragraphs | exact | clean | + +Recommendation: `native` with `tesseract` for throughput, `native` with +`deepseek` for quality, `docling` with `tesseract` when you want its layout +model, and `docling` with `deepseek` only for table-heavy scans where the +costs above are acceptable. ## Processing Flow @@ -120,7 +205,7 @@ ocrmac) degrades to `auto` with a warning rather than failing parses. 1. Files are uploaded through `/api/upload`. 2. Ingestion runs asynchronously in Celery (`ingest_worker`). 3. `SimpleDirectoryReader` parses files with `get_default_file_extractor`. -4. Documents are parsed by the `DOC_PARSER_ENGINE` engine; images (and, under `anydoc`, scanned PDFs) reach Docling when it is installed. OCR in this path is controlled by `DOCLING_OCR_ENABLED`. +4. Documents are parsed by the `DOC_PARSER_ENGINE` engine; images (and, under `anydoc`, scanned PDFs) reach the OCR backend. OCR in this path is controlled by `OCR_ENABLED`. 5. Parsed text is chunked, embedded, and stored in the vector store. 6. Retrieval during chat uses this indexed text and returns source citations. @@ -128,13 +213,13 @@ ocrmac) degrades to `auto` with a warning rather than failing parses. 1. Files are uploaded through `/api/store_attachment`. 2. Celery task `attachment_worker` parses and stores the attachment in Postgres (`attachments` table). -3. OCR in this path is controlled by `DOCLING_OCR_ATTACHMENTS_ENABLED`. +3. OCR in this path is controlled by `OCR_ATTACHMENTS_ENABLED`. 4. Attachments are not vectorized and are not added to the source index. 5. During answer generation, selected attachment IDs are loaded and passed directly to the LLM pipeline. ## How Docling OCR Works -Docling OCR behavior is different for PDFs vs images: +With `OCR_BACKEND=docling`, OCR behavior is different for PDFs vs images: - PDF parser defaults to hybrid OCR: - text regions: extracted directly @@ -182,18 +267,19 @@ This means OCR quality is especially important for text fallback paths and for m ## Recommended Configuration -For most OCR-enabled use cases, enable both flags: +For most OCR-enabled use cases, enable both flags and leave the backend on +`auto`: ```env -DOCLING_OCR_ENABLED=true -DOCLING_OCR_ATTACHMENTS_ENABLED=true +OCR_ENABLED=true +OCR_ATTACHMENTS_ENABLED=true ``` After changing these settings, restart the API and Celery worker. ## Legacy Fallback Notes -- If Docling is unavailable, DocsGPT falls back to legacy parsers for the formats anydoc does not cover, and anydoc's own refusals become upload errors. +- If Docling is unavailable, DocsGPT falls back to the native OCR parsers (OCR on) or the legacy parsers (OCR off) for the formats anydoc does not cover, and anydoc's refusals of scanned PDFs become upload errors only when no OCR is available. - With OCR disabled, text-based PDFs can still parse, but scanned/image-heavy content may produce little text. -- For image parsing without Docling OCR, the legacy image parser only extracts text when `PARSE_IMAGE_REMOTE=true`. +- For image parsing without OCR, the legacy image parser only extracts text when `PARSE_IMAGE_REMOTE=true`. diff --git a/frontend/src/locale/de.json b/frontend/src/locale/de.json index 3ce9e279..b1ee147f 100644 --- a/frontend/src/locale/de.json +++ b/frontend/src/locale/de.json @@ -765,7 +765,7 @@ "start": "Chat starten", "name": "Name", "choose": "Dateien auswählen", - "info": "Bitte lade .pdf, .txt, .rst, .csv, .xlsx, .xls, .ods, .docx, .doc, .odt, .rtf, .md, .html, .epub, .json, .pptx, .ppt, .odp, .zip hoch (max. 25 MB)", + "info": "Bitte lade .pdf, .txt, .rst, .csv, .xlsx, .xls, .ods, .docx, .doc, .odt, .rtf, .md, .html, .xhtml, .png, .jpg, .jpeg, .epub, .json, .pptx, .ppt, .odp, .zip hoch (max. 25 MB)", "uploadedFiles": "Hochgeladene Dateien", "cancel": "Abbrechen", "train": "Trainieren", diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index 8a290a1c..e9c7badc 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -770,7 +770,7 @@ "start": "Start Chatting", "name": "Name", "choose": "Choose Files", - "info": "Please upload .pdf, .txt, .rst, .csv, .xlsx, .xls, .ods, .docx, .doc, .odt, .rtf, .md, .html, .epub, .json, .pptx, .ppt, .odp, .zip limited to 25mb", + "info": "Please upload .pdf, .txt, .rst, .csv, .xlsx, .xls, .ods, .docx, .doc, .odt, .rtf, .md, .html, .xhtml, .png, .jpg, .jpeg, .epub, .json, .pptx, .ppt, .odp, .zip limited to 25mb", "uploadedFiles": "Uploaded Files", "cancel": "Cancel", "train": "Train", diff --git a/frontend/src/locale/es.json b/frontend/src/locale/es.json index 99b89529..88bd2a18 100644 --- a/frontend/src/locale/es.json +++ b/frontend/src/locale/es.json @@ -765,7 +765,7 @@ "start": "Comenzar a chatear", "name": "Nombre", "choose": "Seleccionar Archivos", - "info": "Por favor, sube archivos .pdf, .txt, .rst, .csv, .xlsx, .xls, .ods, .docx, .doc, .odt, .rtf, .md, .html, .epub, .json, .pptx, .ppt, .odp, .zip limitados a 25MB", + "info": "Por favor, sube archivos .pdf, .txt, .rst, .csv, .xlsx, .xls, .ods, .docx, .doc, .odt, .rtf, .md, .html, .xhtml, .png, .jpg, .jpeg, .epub, .json, .pptx, .ppt, .odp, .zip limitados a 25MB", "uploadedFiles": "Archivos Subidos", "cancel": "Cancelar", "train": "Entrenar", diff --git a/frontend/src/locale/jp.json b/frontend/src/locale/jp.json index 5d978d05..3bbda7ee 100644 --- a/frontend/src/locale/jp.json +++ b/frontend/src/locale/jp.json @@ -765,7 +765,7 @@ "start": "チャットを開始する", "name": "名前", "choose": "ファイルを選択", - "info": "25MBまでの.pdf、.txt、.rst、.csv、.xlsx、.xls、.ods、.docx、.doc、.odt、.rtf、.md、.html、.epub、.json、.pptx、.ppt、.odp、.zipファイルをアップロードしてください", + "info": "25MBまでの.pdf、.txt、.rst、.csv、.xlsx、.xls、.ods、.docx、.doc、.odt、.rtf、.md、.html、.xhtml、.png、.jpg、.jpeg、.epub、.json、.pptx、.ppt、.odp、.zipファイルをアップロードしてください", "uploadedFiles": "アップロードされたファイル", "cancel": "キャンセル", "train": "トレーニング", diff --git a/frontend/src/locale/ru.json b/frontend/src/locale/ru.json index c9a2e725..39203650 100644 --- a/frontend/src/locale/ru.json +++ b/frontend/src/locale/ru.json @@ -765,7 +765,7 @@ "start": "Начать чат", "name": "Имя", "choose": "Выбрать файлы", - "info": "Пожалуйста, загрузите файлы .pdf, .txt, .rst, .csv, .xlsx, .xls, .ods, .docx, .doc, .odt, .rtf, .md, .html, .epub, .json, .pptx, .ppt, .odp, .zip размером до 25 МБ", + "info": "Пожалуйста, загрузите файлы .pdf, .txt, .rst, .csv, .xlsx, .xls, .ods, .docx, .doc, .odt, .rtf, .md, .html, .xhtml, .png, .jpg, .jpeg, .epub, .json, .pptx, .ppt, .odp, .zip размером до 25 МБ", "uploadedFiles": "Загруженные файлы", "cancel": "Отмена", "train": "Тренировка", diff --git a/frontend/src/locale/zh-TW.json b/frontend/src/locale/zh-TW.json index 8a3aab5b..e4bf57e3 100644 --- a/frontend/src/locale/zh-TW.json +++ b/frontend/src/locale/zh-TW.json @@ -765,7 +765,7 @@ "start": "開始對話", "name": "名稱", "choose": "選擇檔案", - "info": "請上傳限制為25MB的.pdf、.txt、.rst、.csv、.xlsx、.xls、.ods、.docx、.doc、.odt、.rtf、.md、.html、.epub、.json、.pptx、.ppt、.odp、.zip檔案", + "info": "請上傳限制為25MB的.pdf、.txt、.rst、.csv、.xlsx、.xls、.ods、.docx、.doc、.odt、.rtf、.md、.html、.xhtml、.png、.jpg、.jpeg、.epub、.json、.pptx、.ppt、.odp、.zip檔案", "uploadedFiles": "已上傳檔案", "cancel": "取消", "train": "訓練", diff --git a/frontend/src/locale/zh.json b/frontend/src/locale/zh.json index c4e2c829..e51cdd3b 100644 --- a/frontend/src/locale/zh.json +++ b/frontend/src/locale/zh.json @@ -765,7 +765,7 @@ "start": "开始聊天", "name": "名称", "choose": "选择文件", - "info": "请上传限制为25MB的.pdf、.txt、.rst、.csv、.xlsx、.xls、.ods、.docx、.doc、.odt、.rtf、.md、.html、.epub、.json、.pptx、.ppt、.odp、.zip文件", + "info": "请上传限制为25MB的.pdf、.txt、.rst、.csv、.xlsx、.xls、.ods、.docx、.doc、.odt、.rtf、.md、.html、.xhtml、.png、.jpg、.jpeg、.epub、.json、.pptx、.ppt、.odp、.zip文件", "uploadedFiles": "已上传文件", "cancel": "取消", "train": "训练", diff --git a/setup.sh b/setup.sh index 310267ef..d3111c87 100755 --- a/setup.sh +++ b/setup.sh @@ -362,13 +362,17 @@ configure_doc_processing() { echo -e "${GREEN}PDF-as-image parsing enabled.${NC}" fi - read -p "$(echo -e "${DEFAULT_FG}Enable OCR for document processing (Docling)? (y/N): ${NC}")" ocr_enabled + read -p "$(echo -e "${DEFAULT_FG}Enable OCR for scanned PDFs and images? (y/N): ${NC}")" ocr_enabled if [[ "$ocr_enabled" =~ ^[yY]$ ]]; then - echo "DOCLING_OCR_ENABLED=true" >> "$ENV_FILE" - # OCR needs the optional docling engine; locally built images include - # it via this build arg (hub images must be published with it baked in). - echo "INSTALL_DOCLING=true" >> "$ENV_FILE" - echo -e "${GREEN}Docling OCR enabled (locally built images will include the docling engine).${NC}" + echo "OCR_ENABLED=true" >> "$ENV_FILE" + echo -e "${GREEN}OCR enabled (tesseract, shipped in the Docker image; set OCR_ENGINE=deepseek for a DeepSeek-OCR endpoint).${NC}" + read -p "$(echo -e "${DEFAULT_FG}Also install the Docling layout engine for OCR (better tables/reading order, several GB heavier)? (y/N): ${NC}")" docling_ocr + if [[ "$docling_ocr" =~ ^[yY]$ ]]; then + # Locally built images include docling via this build arg; it becomes + # the OCR backend automatically (OCR_BACKEND=auto). + echo "INSTALL_DOCLING=true" >> "$ENV_FILE" + echo -e "${GREEN}Docling will be built into locally built images.${NC}" + fi fi } diff --git a/tests/parser/file/test_anydoc_parser.py b/tests/parser/file/test_anydoc_parser.py index 433dc4b8..fb6292a8 100644 --- a/tests/parser/file/test_anydoc_parser.py +++ b/tests/parser/file/test_anydoc_parser.py @@ -493,7 +493,7 @@ def test_scanned_pdf_with_near_empty_fallback_fails_loudly_when_ocr_off(tmp_path fallback.ocr_enabled = False parser = AnydocParser(fallback_parser=fallback) - with pytest.raises(DocumentParseError, match="DOCLING_OCR_ENABLED"): + with pytest.raises(DocumentParseError, match="OCR_ENABLED"): parser.parse_file(path) assert parser.last_engine is None diff --git a/tests/parser/file/test_bulk.py b/tests/parser/file/test_bulk.py index ea0d2b72..7e68fa93 100644 --- a/tests/parser/file/test_bulk.py +++ b/tests/parser/file/test_bulk.py @@ -525,6 +525,15 @@ class TestParserEngineSwitch: return settings + @pytest.fixture(autouse=True) + def _default_ocr_settings(self, monkeypatch): + """Pin the OCR knobs to their defaults so a developer's ``.env`` (OCR on, + native backend) cannot change which parsers the maps hand out here.""" + from application.core.settings import settings + + monkeypatch.setattr(settings, "OCR_ENABLED", False) + monkeypatch.setattr(settings, "OCR_BACKEND", "auto") + def test_default_engine_is_anydoc(self, settings, monkeypatch): pytest.importorskip("anydoc") from application.parser.file.anydoc_parser import ANYDOC_SUFFIXES, AnydocParser diff --git a/tests/parser/file/test_docling_parser.py b/tests/parser/file/test_docling_parser.py index 6868c112..0d47471f 100644 --- a/tests/parser/file/test_docling_parser.py +++ b/tests/parser/file/test_docling_parser.py @@ -99,9 +99,15 @@ class TestOcrEngineSelection: return settings - def test_default_setting_is_tesseract(self, settings): - assert settings.OCR_ENGINE == "tesseract" - assert settings.OCR_LANGS == "eng" + def test_default_setting_is_tesseract(self): + # Field defaults, not the live settings: a developer's ``.env`` may + # legitimately set other engines/languages. + from application.core.settings import Settings + + defaults = Settings.model_construct() + assert defaults.OCR_ENGINE == "tesseract" + assert defaults.OCR_LANGS == "eng" + assert defaults.OCR_BACKEND == "auto" def test_none_reads_setting(self, settings, monkeypatch): from application.parser.file.docling_parser import _resolve_ocr_engine @@ -1147,7 +1153,7 @@ def _set_threshold(monkeypatch, value: int) -> None: return getattr(real_settings, name) stub = _Stub() - stub.DOCLING_OCR_MIN_CHARS_PER_PAGE = value + stub.OCR_MIN_CHARS_PER_PAGE = value monkeypatch.setattr("application.core.settings.settings", stub) @@ -1559,3 +1565,67 @@ class TestForceFullPageOCRWiring: force_full_page_ocr=False, ) assert options.ocr_options.force_full_page_ocr is False + + +@pytest.mark.unit +class TestDoclingTesseractPostprocessing: + """docling's tesseract path gets the same CJK glyph-space cleanup as the native engine.""" + + def _parser_with_export(self, engine, ocr_enabled=True): + from application.parser.file.docling_parser import DoclingPDFParser + + parser = DoclingPDFParser(ocr_enabled=ocr_enabled) + parser._active_ocr_engine = engine + document = MagicMock() + document.export_to_markdown.return_value = "## 互相 保密 协议\n\nhello world 你 好" + return parser, document + + def test_tesseract_output_collapses_cjk_spaces(self): + parser, document = self._parser_with_export("tesseract") + assert parser._export_content(document) == "## 互相保密协议\n\nhello world 你好" + + def test_other_engines_untouched(self): + parser, document = self._parser_with_export("deepseek") + assert parser._export_content(document) == "## 互相 保密 协议\n\nhello world 你 好" + + def test_ocr_off_untouched(self): + parser, document = self._parser_with_export("tesseract", ocr_enabled=False) + assert parser._export_content(document) == "## 互相 保密 协议\n\nhello world 你 好" + + +@pytest.mark.unit +class TestDoclingOcrPages: + def test_converts_each_requested_page_with_page_range(self, tmp_path): + from application.parser.file.docling_parser import DoclingPDFParser + + parser = DoclingPDFParser(ocr_enabled=True) + converter = MagicMock() + + def convert(source, page_range=None): + result = MagicMock() + result.document.export_to_markdown.return_value = f"page {page_range[0]} text " * 5 + return result + + converter.convert.side_effect = convert + parser._converter = converter + pdf = tmp_path / "doc.pdf" + pdf.write_bytes(b"%PDF-1.4") + + texts = parser.ocr_pages(pdf, [2, 0]) + + assert set(texts) == {2, 0} + assert texts[2].startswith("page 3 text") + assert texts[0].startswith("page 1 text") + assert [c.kwargs["page_range"] for c in converter.convert.call_args_list] == [(3, 3), (1, 1)] + + def test_failure_is_a_parse_error(self, tmp_path): + from application.parser.file.base_parser import DocumentParseError + from application.parser.file.docling_parser import DoclingPDFParser + + parser = DoclingPDFParser(ocr_enabled=True) + parser._converter = MagicMock() + parser._converter.convert.side_effect = RuntimeError("boom") + pdf = tmp_path / "doc.pdf" + pdf.write_bytes(b"%PDF-1.4") + with pytest.raises(DocumentParseError, match="page 1 of doc.pdf"): + parser.ocr_pages(pdf, [0]) diff --git a/tests/parser/file/test_ocr_parser.py b/tests/parser/file/test_ocr_parser.py new file mode 100644 index 00000000..d8b11be9 --- /dev/null +++ b/tests/parser/file/test_ocr_parser.py @@ -0,0 +1,763 @@ +"""Native OCR backend: engines, backend/engine resolution, and the PDF/image parsers. + +Everything here runs without docling and without a tesseract binary: engines +are faked or their process/HTTP layer is patched. The one test that drives +the real ``tesseract`` binary skips when it is not on PATH. +""" +import shutil +import subprocess +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +pytest.importorskip("pypdfium2") +PIL = pytest.importorskip("PIL") +from PIL import Image, ImageDraw # noqa: E402 + +from application.parser.file.base_parser import DocumentParseError # noqa: E402 +from application.parser.file import ocr_parser as op # noqa: E402 + + +# --------------------------------------------------------------------------- +# Fixtures / helpers +# --------------------------------------------------------------------------- + + +class FakeEngine: + """Records the images it is handed and returns canned text per call.""" + + name = "fake" + + def __init__(self, texts=None): + self.texts = list(texts) if texts is not None else [] + self.calls = [] + + def ocr_image(self, image): + self.calls.append(image.size) + if self.texts: + return self.texts.pop(0) + return "OCR TEXT" + + +def _text_pdf( + path: Path, pages: int = 1, text: str = "Hello text layer, plenty of characters here.", lines: int = 1 +) -> Path: + """A born-digital PDF; ``lines`` paragraphs per page make anydoc classify it as text-based.""" + reportlab = pytest.importorskip("reportlab") # noqa: F841 + from reportlab.lib.pagesizes import letter + from reportlab.pdfgen import canvas + + c = canvas.Canvas(str(path), pagesize=letter) + for index in range(pages): + for line in range(lines): + c.drawString(72, 700 - 18 * line, f"{text} page {index + 1} line {line + 1}") + c.showPage() + c.save() + return path + + +def _image_pdf(path: Path, pages: int = 1) -> Path: + """A PDF whose pages are pure raster images (no text layer): what a scanner produces.""" + frames = [] + for _ in range(pages): + img = Image.new("RGB", (300, 400), "white") + ImageDraw.Draw(img).rectangle((50, 50, 250, 350), outline="black", width=3) + frames.append(img) + frames[0].save(str(path), "PDF", save_all=True, append_images=frames[1:]) + return path + + +def _pdf_page_count(path: Path) -> int: + import pypdfium2 as pdfium + + pdf = pdfium.PdfDocument(str(path)) + try: + return len(pdf) + finally: + pdf.close() + + +@pytest.fixture +def settings(): + from application.core.settings import settings + + return settings + + +# --------------------------------------------------------------------------- +# Settings aliases +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestSettingsAliases: + def test_legacy_docling_names_still_configure_ocr(self, monkeypatch): + from application.core.settings import Settings + + monkeypatch.setenv("DOCLING_OCR_ENABLED", "true") + monkeypatch.setenv("DOCLING_OCR_ATTACHMENTS_ENABLED", "true") + monkeypatch.setenv("DOCLING_OCR_MIN_CHARS_PER_PAGE", "7") + loaded = Settings() + assert loaded.OCR_ENABLED is True + assert loaded.OCR_ATTACHMENTS_ENABLED is True + assert loaded.OCR_MIN_CHARS_PER_PAGE == 7 + + def test_new_names_and_defaults(self, monkeypatch): + from application.core.settings import Settings + + for name in ( + "OCR_ENABLED", + "DOCLING_OCR_ENABLED", + "OCR_ATTACHMENTS_ENABLED", + "DOCLING_OCR_ATTACHMENTS_ENABLED", + ): + monkeypatch.delenv(name, raising=False) + monkeypatch.setenv("OCR_ENABLED", "true") + loaded = Settings() + assert loaded.OCR_ENABLED is True + assert loaded.OCR_ATTACHMENTS_ENABLED is False + assert loaded.OCR_BACKEND == "auto" + assert loaded.OCR_ENGINE == "tesseract" + assert loaded.OCR_DEEPSEEK_TIMEOUT == 300.0 + assert loaded.OCR_RENDER_DPI == 200 + + +# --------------------------------------------------------------------------- +# Resolution +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestResolution: + def test_auto_prefers_docling_when_installed(self, monkeypatch, settings): + monkeypatch.setattr(settings, "OCR_BACKEND", "auto") + monkeypatch.setitem(sys.modules, "docling", MagicMock()) + assert op.resolve_ocr_backend() == "docling" + + def test_auto_is_native_without_docling(self, monkeypatch, settings): + monkeypatch.setattr(settings, "OCR_BACKEND", "auto") + monkeypatch.setitem(sys.modules, "docling", None) + assert op.resolve_ocr_backend() == "native" + + def test_native_wins_over_installed_docling(self, monkeypatch, settings): + monkeypatch.setattr(settings, "OCR_BACKEND", "native") + monkeypatch.setitem(sys.modules, "docling", MagicMock()) + assert op.resolve_ocr_backend() == "native" + + def test_docling_backend_degrades_to_native_when_missing(self, monkeypatch, settings, caplog): + monkeypatch.setattr(settings, "OCR_BACKEND", "docling") + monkeypatch.setitem(sys.modules, "docling", None) + with caplog.at_level("WARNING"): + assert op.resolve_ocr_backend() == "native" + assert "OCR_BACKEND=docling" in caplog.text + + def test_unknown_backend_is_auto(self, monkeypatch, settings): + monkeypatch.setattr(settings, "OCR_BACKEND", "paddle") + monkeypatch.setitem(sys.modules, "docling", None) + assert op.resolve_ocr_backend() == "native" + + @pytest.mark.parametrize("engine", ["tesseract", "deepseek"]) + def test_native_engines_pass_through(self, engine): + assert op.resolve_native_ocr_engine(engine) == engine + + @pytest.mark.parametrize("engine", ["auto", "ocrmac", "rapidocr", "easyocr", ""]) + def test_docling_only_or_unknown_engines_become_tesseract(self, engine, monkeypatch, settings, caplog): + monkeypatch.setattr(settings, "OCR_ENGINE", engine) + with caplog.at_level("WARNING"): + assert op.resolve_native_ocr_engine(None) == "tesseract" + if engine: + assert "tesseract" in caplog.text + + def test_build_engine_from_setting(self, monkeypatch, settings): + monkeypatch.setattr(settings, "OCR_ENGINE", "deepseek") + assert isinstance(op.build_native_ocr_engine(), op.DeepseekOcrEngine) + monkeypatch.setattr(settings, "OCR_ENGINE", "tesseract") + assert isinstance(op.build_native_ocr_engine(), op.TesseractEngine) + + def test_render_dpi_is_clamped(self, monkeypatch, settings): + monkeypatch.setattr(settings, "OCR_RENDER_DPI", 10) + assert op.render_dpi() == 72 + monkeypatch.setattr(settings, "OCR_RENDER_DPI", 5000) + assert op.render_dpi() == 600 + monkeypatch.setattr(settings, "OCR_RENDER_DPI", "nope") + assert op.render_dpi() == 200 + + +# --------------------------------------------------------------------------- +# Tesseract engine +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestTesseractEngine: + def test_command_uses_ocr_langs_setting(self, monkeypatch, settings): + monkeypatch.setattr(settings, "OCR_LANGS", "eng+chi_sim") + assert op.TesseractEngine().command() == [ + "tesseract", "stdin", "stdout", "-l", "eng+chi_sim", "--psm", "3", + ] + + def test_explicit_languages_override_setting(self, monkeypatch, settings): + monkeypatch.setattr(settings, "OCR_LANGS", "eng") + assert op.TesseractEngine(languages=["deu", " fra "]).command()[4] == "deu+fra" + + def test_missing_binary_is_a_typed_error(self, monkeypatch): + monkeypatch.setattr(shutil, "which", lambda name: None) + with pytest.raises(op.OcrUnavailableError, match="INSTALL_TESSERACT"): + op.TesseractEngine().ocr_image(Image.new("RGB", (10, 10))) + + def test_stdin_stdout_roundtrip_is_mocked(self, monkeypatch): + monkeypatch.setattr(shutil, "which", lambda name: "/usr/bin/tesseract") + seen = {} + + def fake_run(cmd, input=None, capture_output=None, timeout=None, check=None): + seen["cmd"], seen["input"] = cmd, input + return subprocess.CompletedProcess(cmd, 0, stdout=b" recognised words \n", stderr=b"") + + monkeypatch.setattr(subprocess, "run", fake_run) + text = op.TesseractEngine().ocr_image(Image.new("RGBA", (10, 10))) + assert text == "recognised words" + assert seen["cmd"][:3] == ["tesseract", "stdin", "stdout"] + assert seen["input"].startswith(b"\x89PNG") # RGBA flattened and PNG-encoded + + def test_cjk_glyph_spaces_are_collapsed(self, monkeypatch): + monkeypatch.setattr(shutil, "which", lambda name: "/usr/bin/tesseract") + raw = "互相 保密 协议\n本 协议 由 Meridian Components Ltd. 与 深圳 华 芯 于 2026 年 9 月 1 日 签 订 。 各 方\n" + monkeypatch.setattr( + subprocess, "run", + lambda *a, **k: subprocess.CompletedProcess(a[0], 0, stdout=raw.encode("utf-8"), stderr=b""), + ) + text = op.TesseractEngine().ocr_image(Image.new("RGB", (10, 10))) + assert text == "互相保密协议\n本协议由 Meridian Components Ltd. 与深圳华芯于 2026 年 9 月 1 日签订。各方" + + def test_collapse_cjk_spaces_leaves_latin_alone(self): + assert op.collapse_cjk_spaces("hello world 你 好 ok 再 见") == "hello world 你好 ok 再见" + + def test_nonzero_exit_and_timeout_become_parse_errors(self, monkeypatch): + monkeypatch.setattr(shutil, "which", lambda name: "/usr/bin/tesseract") + monkeypatch.setattr( + subprocess, "run", + lambda *a, **k: subprocess.CompletedProcess(a[0], 1, stdout=b"", stderr=b"Error opening data file"), + ) + with pytest.raises(DocumentParseError, match="Error opening data file"): + op.TesseractEngine().ocr_image(Image.new("RGB", (10, 10))) + + def timeout(*a, **k): + raise subprocess.TimeoutExpired(a[0], 5) + + monkeypatch.setattr(subprocess, "run", timeout) + with pytest.raises(DocumentParseError, match="timed out"): + op.TesseractEngine(timeout=5).ocr_image(Image.new("RGB", (10, 10))) + + @pytest.mark.skipif(shutil.which("tesseract") is None, reason="tesseract binary not installed") + def test_real_binary_reads_rendered_text(self): + img = Image.new("RGB", (700, 140), "white") + draw = ImageDraw.Draw(img) + try: + from PIL import ImageFont + + font = ImageFont.truetype("/System/Library/Fonts/Supplemental/Arial.ttf", 48) + except (OSError, ImportError): + font = None + draw.text((20, 40), "NATIVE OCR 2026", fill="black", font=font) + text = op.TesseractEngine(languages=["eng"]).ocr_image(img) + assert "OCR" in text.upper() + + +# --------------------------------------------------------------------------- +# DeepSeek engine +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestDeepseekEngine: + def test_defaults_come_from_settings(self, monkeypatch, settings): + monkeypatch.setattr(settings, "OCR_DEEPSEEK_URL", "http://vllm:8000/v1/chat/completions") + monkeypatch.setattr(settings, "OCR_DEEPSEEK_MODEL", "deepseek-ai/DeepSeek-OCR") + monkeypatch.setattr(settings, "OCR_DEEPSEEK_TIMEOUT", 42) + engine = op.DeepseekOcrEngine() + assert engine.url.startswith("http://vllm") + assert engine.model == "deepseek-ai/DeepSeek-OCR" + assert engine.timeout == 42.0 + + def test_payload_is_openai_compatible_with_data_url(self): + engine = op.DeepseekOcrEngine(url="http://x", model="m", timeout=1) + payload = engine.payload(Image.new("RGB", (8, 8))) + assert payload["model"] == "m" + assert payload["temperature"] == 0 + parts = payload["messages"][0]["content"] + assert parts[0]["image_url"]["url"].startswith("data:image/png;base64,") + assert parts[1]["text"] == op.DEEPSEEK_PROMPT + assert "<|grounding|>" not in parts[1]["text"] + + def test_success_strips_grounding_markup(self): + engine = op.DeepseekOcrEngine(url="http://x", model="m", timeout=1) + response = MagicMock() + response.json.return_value = { + "choices": [{"message": {"content": "<|ref|>Title<|/ref|><|det|>[[1, 2, 3, 4]]<|/det|>\n\n| a | b |"}}] + } + with patch("requests.post", return_value=response) as post: + text = engine.ocr_image(Image.new("RGB", (8, 8))) + assert text == "Title\n\n| a | b |" + assert post.call_args.kwargs["timeout"] == 1.0 + assert post.call_args.args[0] == "http://x" + + def test_content_parts_list_is_joined(self): + engine = op.DeepseekOcrEngine(url="http://x", model="m", timeout=1) + response = MagicMock() + response.json.return_value = {"choices": [{"message": {"content": [{"type": "text", "text": "ab"}]}}]} + with patch("requests.post", return_value=response): + assert engine.ocr_image(Image.new("RGB", (8, 8))) == "ab" + + def test_network_failure_is_a_parse_error_with_hint(self): + import requests + + engine = op.DeepseekOcrEngine(url="http://x", model="deepseek-ocr:3b", timeout=1) + with ( + patch("requests.post", side_effect=requests.ConnectionError("refused")), + pytest.raises(DocumentParseError, match="OCR_DEEPSEEK_URL"), + ): + engine.ocr_image(Image.new("RGB", (8, 8))) + + def test_malformed_body_is_a_parse_error(self): + engine = op.DeepseekOcrEngine(url="http://x", model="m", timeout=1) + response = MagicMock() + response.json.return_value = {"error": "model not found"} + with ( + patch("requests.post", return_value=response), + pytest.raises(DocumentParseError, match="no chat completion"), + ): + engine.ocr_image(Image.new("RGB", (8, 8))) + + def test_clean_output_helper(self): + raw = "<|grounding|><|ref|>x<|/ref|><|det|>[[0,0,1,1]]<|/det|> y<|end▁of▁sentence|>" + assert op.clean_deepseek_output(raw) == "x y" + + +# --------------------------------------------------------------------------- +# PDF parser +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestNativeOcrPdfParser: + def test_text_layer_pages_are_read_without_ocr(self, tmp_path): + pdf = _text_pdf(tmp_path / "text.pdf", pages=2) + engine = FakeEngine() + parser = op.NativeOcrPdfParser(engine=engine) + parser.init_parser() + + content = parser.parse_file(pdf) + + assert engine.calls == [] + assert "page 1" in content and "page 2" in content + assert parser.last_engine == "fake" + assert parser.get_file_metadata(pdf) == {"parse_engine": "fake", "pdf_pages": 2, "ocr_pages": 0} + + def test_scanned_pages_are_rendered_and_ocrd(self, tmp_path, monkeypatch, settings): + pdf = _image_pdf(tmp_path / "scan.pdf", pages=2) + assert _pdf_page_count(pdf) == 2 + monkeypatch.setattr(settings, "OCR_RENDER_DPI", 72) + engine = FakeEngine(["first page words here", "second page words here"]) + parser = op.NativeOcrPdfParser(engine=engine) + + content = parser.parse_file(pdf) + + assert len(engine.calls) == 2 + assert all(width > 0 and height > 0 for width, height in engine.calls) + assert content == "first page words here\n\nsecond page words here" + assert parser.get_file_metadata(pdf)["ocr_pages"] == 2 + + def test_render_dpi_scales_the_page_image(self, tmp_path, monkeypatch, settings): + pdf = _image_pdf(tmp_path / "scan.pdf") + sizes = {} + for dpi in (72, 144): + monkeypatch.setattr(settings, "OCR_RENDER_DPI", dpi) + engine = FakeEngine(["some words on this page, enough of them"]) + op.NativeOcrPdfParser(engine=engine).parse_file(pdf) + sizes[dpi] = engine.calls[0] + assert sizes[144][0] == pytest.approx(sizes[72][0] * 2, abs=2) + + def test_mixed_document_ocrs_only_the_scanned_pages(self, tmp_path, monkeypatch, settings): + """A text page followed by an image page: the text layer is used where it exists.""" + pytest.importorskip("pypdf") + from pypdf import PdfWriter + + text_pdf = _text_pdf(tmp_path / "t.pdf") + image_pdf = _image_pdf(tmp_path / "i.pdf") + writer = PdfWriter() + for source in (text_pdf, image_pdf): + writer.append(str(source)) + mixed = tmp_path / "mixed.pdf" + with open(mixed, "wb") as handle: + writer.write(handle) + monkeypatch.setattr(settings, "OCR_RENDER_DPI", 72) + + engine = FakeEngine(["scanned page words"]) + parser = op.NativeOcrPdfParser(engine=engine) + content = parser.parse_file(mixed) + + assert len(engine.calls) == 1 + assert "page 1" in content and "scanned page words" in content + assert parser.get_file_metadata(mixed) == {"parse_engine": "fake", "pdf_pages": 2, "ocr_pages": 1} + + def test_text_parser_takes_fully_text_documents(self, tmp_path): + pdf = _text_pdf(tmp_path / "text.pdf", pages=2) + text_parser = MagicMock() + text_parser.parser_config_set = True + text_parser.parse_file.return_value = "# structured markdown" + text_parser.get_file_metadata.return_value = {"x": 1} + text_parser.last_engine = None + engine = FakeEngine() + + parser = op.NativeOcrPdfParser(engine=engine, text_parser=text_parser) + content = parser.parse_file(pdf) + + assert content == "# structured markdown" + assert engine.calls == [] + text_parser.parse_file.assert_called_once() + assert parser.get_file_metadata(pdf)["x"] == 1 + assert parser.last_engine == "MagicMock" + + def test_text_parser_is_bypassed_when_any_page_is_scanned(self, tmp_path, monkeypatch, settings): + pdf = _image_pdf(tmp_path / "scan.pdf") + monkeypatch.setattr(settings, "OCR_RENDER_DPI", 72) + text_parser = MagicMock() + engine = FakeEngine(["enough recognised words to pass the floor"]) + + op.NativeOcrPdfParser(engine=engine, text_parser=text_parser).parse_file(pdf) + + text_parser.parse_file.assert_not_called() + assert len(engine.calls) == 1 + + def test_multi_page_scan_that_ocrs_to_nothing_fails_loudly(self, tmp_path, monkeypatch, settings): + pdf = _image_pdf(tmp_path / "blank.pdf", pages=3) + monkeypatch.setattr(settings, "OCR_RENDER_DPI", 72) + monkeypatch.setattr(settings, "OCR_MIN_CHARS_PER_PAGE", 20) + parser = op.NativeOcrPdfParser(engine=FakeEngine(["", "", ""])) + with pytest.raises(DocumentParseError, match="produced no text over 3 pages"): + parser.parse_file(pdf) + + def test_sparse_single_page_is_kept_with_a_warning(self, tmp_path, monkeypatch, settings, caplog): + pdf = _image_pdf(tmp_path / "logo.pdf") + monkeypatch.setattr(settings, "OCR_RENDER_DPI", 72) + monkeypatch.setattr(settings, "OCR_MIN_CHARS_PER_PAGE", 20) + with caplog.at_level("WARNING"): + content = op.NativeOcrPdfParser(engine=FakeEngine(["ACME"])).parse_file(pdf) + assert content == "ACME" + assert "text-sparse" in caplog.text + + def test_guard_disabled_with_zero_floor(self, tmp_path, monkeypatch, settings): + pdf = _image_pdf(tmp_path / "blank.pdf", pages=2) + monkeypatch.setattr(settings, "OCR_RENDER_DPI", 72) + monkeypatch.setattr(settings, "OCR_MIN_CHARS_PER_PAGE", 0) + assert op.NativeOcrPdfParser(engine=FakeEngine(["", ""])).parse_file(pdf) == "\n\n" + + def test_engine_failure_propagates_as_parse_error(self, tmp_path, monkeypatch, settings): + pdf = _image_pdf(tmp_path / "scan.pdf") + monkeypatch.setattr(settings, "OCR_RENDER_DPI", 72) + + class Broken: + name = "broken" + + def ocr_image(self, image): + raise DocumentParseError("engine down") + + with pytest.raises(DocumentParseError, match="engine down"): + op.NativeOcrPdfParser(engine=Broken()).parse_file(pdf) + + def test_unreadable_file_is_a_parse_error(self, tmp_path): + bad = tmp_path / "bad.pdf" + bad.write_bytes(b"not a pdf") + with pytest.raises(DocumentParseError, match="Failed to open"): + op.NativeOcrPdfParser(engine=FakeEngine()).parse_file(bad) + + def test_engine_is_built_lazily_from_settings(self, monkeypatch, settings): + monkeypatch.setattr(settings, "OCR_ENGINE", "deepseek") + parser = op.NativeOcrPdfParser() + parser.init_parser() + assert isinstance(parser.engine, op.DeepseekOcrEngine) + assert parser.parser_config["engine"] == "deepseek" + + def test_reports_ocr_enabled_for_anydoc_hint(self): + assert op.NativeOcrPdfParser(engine=FakeEngine()).ocr_enabled is True + + +# --------------------------------------------------------------------------- +# Image parser +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestNativeOcrImageParser: + def test_single_image(self, tmp_path): + png = tmp_path / "scan.png" + Image.new("RGB", (40, 30), "white").save(png) + engine = FakeEngine(["words from the picture"]) + parser = op.NativeOcrImageParser(engine=engine) + + assert parser.parse_file(png) == "words from the picture" + assert engine.calls == [(40, 30)] + assert parser.get_file_metadata(png) == {"parse_engine": "fake", "ocr_pages": 1} + + def test_multi_frame_tiff_ocrs_every_frame(self, tmp_path): + tiff = tmp_path / "fax.tiff" + frames = [Image.new("L", (20, 20), 255) for _ in range(3)] + frames[0].save(tiff, save_all=True, append_images=frames[1:]) + engine = FakeEngine(["one page", "two pages", "three pages"]) + + content = op.NativeOcrImageParser(engine=engine).parse_file(tiff) + + assert len(engine.calls) == 3 + assert content == "one page\n\ntwo pages\n\nthree pages" + + def test_multi_frame_that_ocrs_to_nothing_fails(self, tmp_path, monkeypatch, settings): + tiff = tmp_path / "fax.tiff" + frames = [Image.new("L", (20, 20), 255) for _ in range(2)] + frames[0].save(tiff, save_all=True, append_images=frames[1:]) + monkeypatch.setattr(settings, "OCR_MIN_CHARS_PER_PAGE", 20) + with pytest.raises(DocumentParseError, match="produced no text"): + op.NativeOcrImageParser(engine=FakeEngine(["", ""])).parse_file(tiff) + + def test_undecodable_image_is_a_parse_error(self, tmp_path): + bad = tmp_path / "bad.png" + bad.write_bytes(b"nope") + with pytest.raises(DocumentParseError, match="Failed to OCR"): + op.NativeOcrImageParser(engine=FakeEngine()).parse_file(bad) + + +# --------------------------------------------------------------------------- +# Extractor-map wiring (bulk.py) +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestExtractorWiring: + def test_legacy_map_without_ocr_is_unchanged(self): + from application.parser.file.bulk import _legacy_file_extractor + + extractor = _legacy_file_extractor() + assert type(extractor[".pdf"]).__name__ == "PDFParser" + assert type(extractor[".png"]).__name__ == "ImageParser" + assert ".tiff" not in extractor + + def test_legacy_map_with_ocr_uses_native_parsers(self): + from application.parser.file.bulk import _legacy_file_extractor + + extractor = _legacy_file_extractor(ocr_enabled=True) + assert isinstance(extractor[".pdf"], op.NativeOcrPdfParser) + assert extractor[".pdf"].text_parser is None + for suffix in op.IMAGE_SUFFIXES: + assert isinstance(extractor[suffix], op.NativeOcrImageParser) + + def test_ocr_without_docling_reaches_native_under_anydoc(self, monkeypatch): + pytest.importorskip("anydoc") + from application.parser.file.anydoc_parser import AnydocParser + from application.parser.file.bulk import get_default_file_extractor + + monkeypatch.setitem(sys.modules, "docling", None) + extractor = get_default_file_extractor(engine="anydoc", ocr_enabled=True) + + assert isinstance(extractor[".pdf"], AnydocParser) + assert isinstance(extractor[".pdf"].fallback_parser, op.NativeOcrPdfParser) + assert isinstance(extractor[".png"], op.NativeOcrImageParser) + + def test_ocr_without_docling_reaches_native_under_docling_engine(self, monkeypatch): + from application.parser.file.bulk import get_default_file_extractor + + monkeypatch.setitem(sys.modules, "docling", None) + extractor = get_default_file_extractor(engine="docling", ocr_enabled=True) + assert isinstance(extractor[".pdf"], op.NativeOcrPdfParser) + assert isinstance(extractor[".jpg"], op.NativeOcrImageParser) + + def test_ocr_off_without_docling_keeps_legacy(self, monkeypatch): + from application.parser.file.bulk import get_default_file_extractor + + monkeypatch.setitem(sys.modules, "docling", None) + extractor = get_default_file_extractor(engine="docling", ocr_enabled=False) + assert type(extractor[".pdf"]).__name__ == "PDFParser" + assert type(extractor[".png"]).__name__ == "ImageParser" + + def test_native_backend_with_docling_installed_wraps_docling_for_text_pdfs(self, monkeypatch, settings): + pytest.importorskip("docling") + from application.parser.file.bulk import get_default_file_extractor + from application.parser.file.docling_parser import DoclingPDFParser + + monkeypatch.setattr(settings, "OCR_BACKEND", "native") + extractor = get_default_file_extractor(engine="docling", ocr_enabled=True) + + pdf_parser = extractor[".pdf"] + assert isinstance(pdf_parser, op.NativeOcrPdfParser) + assert isinstance(pdf_parser.text_parser, DoclingPDFParser) + assert pdf_parser.text_parser.ocr_enabled is False + assert isinstance(extractor[".png"], op.NativeOcrImageParser) + # docling still owns what anydoc/native cannot read. + assert type(extractor[".vtt"]).__name__ == "DoclingVTTParser" + + def test_native_backend_under_anydoc_keeps_docling_reroute_for_trust_check(self, monkeypatch, settings): + pytest.importorskip("anydoc") + pytest.importorskip("docling") + from application.parser.file.anydoc_parser import _is_docling_backed + from application.parser.file.bulk import get_default_file_extractor + + monkeypatch.setattr(settings, "OCR_BACKEND", "native") + extractor = get_default_file_extractor(engine="anydoc", ocr_enabled=True) + + fallback = extractor[".pdf"].fallback_parser + assert isinstance(fallback, op.NativeOcrPdfParser) + assert _is_docling_backed(fallback) is True + + def test_auto_backend_with_docling_installed_is_unchanged(self, monkeypatch, settings): + pytest.importorskip("docling") + from application.parser.file.bulk import get_default_file_extractor + + monkeypatch.setattr(settings, "OCR_BACKEND", "auto") + extractor = get_default_file_extractor(engine="docling", ocr_enabled=True) + assert type(extractor[".pdf"]).__name__ == "DoclingPDFParser" + assert extractor[".pdf"].ocr_enabled is True + assert type(extractor[".png"]).__name__ == "DoclingImageParser" + + def test_anydoc_scan_hint_names_new_setting(self, tmp_path): + pytest.importorskip("anydoc") + from application.parser.file.anydoc_parser import AnydocParser + + fallback = MagicMock() + fallback.parser_config_set = True + fallback.parse_file.return_value = "" + fallback.ocr_enabled = False + parser = AnydocParser(fallback_parser=fallback) + pdf = _image_pdf(tmp_path / "scan.pdf") + with pytest.raises(DocumentParseError, match="OCR_ENABLED=true"): + parser.parse_file(pdf) + + +# --------------------------------------------------------------------------- +# Mixed documents: page probe + page-subset OCR +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestScannedPageProbe: + def test_indices_of_pages_without_text_layer(self, tmp_path): + pytest.importorskip("pypdf") + from pypdf import PdfWriter + + text_pdf = _text_pdf(tmp_path / "t.pdf", pages=2) + image_pdf = _image_pdf(tmp_path / "i.pdf") + writer = PdfWriter() + writer.append(str(text_pdf)) + writer.append(str(image_pdf)) + writer.append(str(text_pdf)) + mixed = tmp_path / "mixed.pdf" + with open(mixed, "wb") as handle: + writer.write(handle) + + assert op.scanned_page_indices(mixed) == [2] + assert op.text_layer_counts(mixed)[2] == 0 + + def test_all_text_gives_empty_list(self, tmp_path): + assert op.scanned_page_indices(_text_pdf(tmp_path / "t.pdf", pages=3)) == [] + + def test_unreadable_file_gives_empty_list(self, tmp_path): + bad = tmp_path / "bad.pdf" + bad.write_bytes(b"nope") + assert op.scanned_page_indices(bad) == [] + + def test_ocr_pages_renders_only_requested_pages(self, tmp_path, monkeypatch, settings): + pdf = _image_pdf(tmp_path / "scan.pdf", pages=3) + monkeypatch.setattr(settings, "OCR_RENDER_DPI", 72) + engine = FakeEngine(["page three", "page one"]) + parser = op.NativeOcrPdfParser(engine=engine) + + texts = parser.ocr_pages(pdf, [2, 0, 99]) + + assert texts == {2: "page three", 0: "page one"} + assert len(engine.calls) == 2 + assert parser.last_engine == "fake" + + +@pytest.mark.unit +class TestAnydocMixedDocuments: + """anydoc converts a text PDF with scanned pages and silently skips them; OCR fills them in.""" + + @pytest.fixture + def mixed_pdf(self, tmp_path): + pytest.importorskip("pypdf") + from pypdf import PdfWriter + + text_pdf = _text_pdf(tmp_path / "t.pdf", pages=2, text="Digital text page with enough characters", lines=25) + image_pdf = _image_pdf(tmp_path / "i.pdf") + writer = PdfWriter() + writer.append(str(text_pdf)) + writer.append(str(image_pdf)) + mixed = tmp_path / "mixed.pdf" + with open(mixed, "wb") as handle: + writer.write(handle) + return mixed + + def test_scanned_page_is_ocrd_and_appended(self, mixed_pdf, monkeypatch, settings): + pytest.importorskip("anydoc") + from application.parser.file.anydoc_parser import AnydocParser + + monkeypatch.setattr(settings, "OCR_RENDER_DPI", 72) + engine = FakeEngine(["SCANNED PAGE WORDS"]) + fallback = op.NativeOcrPdfParser(engine=engine) + parser = AnydocParser(fallback_parser=fallback) + + content = parser.parse_file(mixed_pdf) + + assert "Digital text page" in content + assert content.rstrip().endswith("SCANNED PAGE WORDS") + assert len(engine.calls) == 1 # only the scanned page was rendered + assert parser.last_engine == "anydoc" + assert parser.get_file_metadata(mixed_pdf) == {"ocr_pages": 1} + + def test_no_ocr_fallback_keeps_text_pages_only(self, mixed_pdf): + pytest.importorskip("anydoc") + from application.parser.file.anydoc_parser import AnydocParser + from application.parser.file.docs_parser import PDFParser + + parser = AnydocParser(fallback_parser=PDFParser()) + content = parser.parse_file(mixed_pdf) + + assert "Digital text page" in content + assert parser.get_file_metadata(mixed_pdf) == {} + + def test_fallback_with_ocr_off_is_not_used(self, mixed_pdf): + pytest.importorskip("anydoc") + from application.parser.file.anydoc_parser import AnydocParser + + fallback = MagicMock() + fallback.ocr_enabled = False + parser = AnydocParser(fallback_parser=fallback) + parser.parse_file(mixed_pdf) + fallback.ocr_pages.assert_not_called() + + def test_ocr_failure_keeps_text_pages(self, mixed_pdf, caplog): + pytest.importorskip("anydoc") + from application.parser.file.anydoc_parser import AnydocParser + + fallback = MagicMock() + fallback.ocr_enabled = True + fallback.parser_config_set = True + fallback.ocr_pages.side_effect = DocumentParseError("engine down") + parser = AnydocParser(fallback_parser=fallback) + + with caplog.at_level("WARNING"): + content = parser.parse_file(mixed_pdf) + + assert "Digital text page" in content + assert "text pages only" in caplog.text + assert parser.get_file_metadata(mixed_pdf) == {} + + def test_all_text_document_never_probes_ocr(self, tmp_path): + pytest.importorskip("anydoc") + from application.parser.file.anydoc_parser import AnydocParser + + pdf = _text_pdf(tmp_path / "t.pdf", pages=2, text="Digital text page with enough characters", lines=25) + fallback = MagicMock() + fallback.ocr_enabled = True + parser = AnydocParser(fallback_parser=fallback) + parser.parse_file(pdf) + fallback.ocr_pages.assert_not_called() diff --git a/tests/parser/test_document_reader.py b/tests/parser/test_document_reader.py index db6648af..ab6bd550 100644 --- a/tests/parser/test_document_reader.py +++ b/tests/parser/test_document_reader.py @@ -471,11 +471,11 @@ def test_temp_file_cleaned_up_on_parser_error(monkeypatch): # --------------------------------------------------------------------------- @pytest.mark.unit def test_ocr_resolution(monkeypatch): - monkeypatch.setattr(dr.settings, "DOCLING_OCR_ENABLED", True, raising=False) + monkeypatch.setattr(dr.settings, "OCR_ENABLED", True, raising=False) assert dr._resolve_ocr_enabled("off") is False assert dr._resolve_ocr_enabled("on") is True assert dr._resolve_ocr_enabled("auto") is True - monkeypatch.setattr(dr.settings, "DOCLING_OCR_ENABLED", False, raising=False) + monkeypatch.setattr(dr.settings, "OCR_ENABLED", False, raising=False) assert dr._resolve_ocr_enabled("auto") is False