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())