standalone OCR

This commit is contained in:
Pavel committed 2026-09-02 23:12:36 +04:00
1 parent d4e92be0ab
commit 96b878217d
27 files changed
+1995 -152

No files matched your search

+12 -9
View File
@@ -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-<lang>).
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-<lang>, 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/*; \
+51 -22
View File
@@ -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
+1 -1
View File
@@ -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:
+77 -13
View File
@@ -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 "
+94 -29
View File
@@ -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":
+51 -5
View File
@@ -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."""
+693
View File
@@ -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)
+3 -1
View File
@@ -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);
+6 -4
View File
@@ -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):
+1 -1
View File
@@ -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())
+5 -2
View File
@@ -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`.
+1 -1
View File
@@ -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
+8 -4
View File
@@ -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
+126 -40
View File
@@ -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).
<Callout type="warning" emoji="⚠️">
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`.
</Callout>
### 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`.
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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": "トレーニング",
+1 -1
View File
@@ -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": "Тренировка",
+1 -1
View File
@@ -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": "訓練",
+1 -1
View File
@@ -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": "训练",
+10 -6
View File
@@ -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
}
+1 -1
View File
@@ -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
+9
View File
@@ -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
+74 -4
View File
@@ -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])
+763
View File
@@ -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()
+2 -2
View File
@@ -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