ocr update

This commit is contained in:
Pavel committed 2026-08-27 23:46:12 +04:00
1 parent d7a7d4d084
commit d4e92be0ab
27 files changed
+1656 -134

No files matched your search

+10
View File
@@ -66,6 +66,16 @@ 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 \
apt-get update && \
apt-get install -y --no-install-recommends tesseract-ocr tesseract-ocr-eng && \
rm -rf /var/lib/apt/lists/*; \
fi
# Set working directory
WORKDIR /app
+15 -1
View File
@@ -272,7 +272,21 @@ class UploadFile(Resource):
# Office/e-book containers are ZIP-based formats but must
# be parsed as documents, not expanded as user archives.
is_office_format = safe_file.lower().endswith(
(".docx", ".xlsx", ".pptx", ".odt", ".ods", ".odp", ".epub")
(
".docx",
".docm",
".xlsx",
".xlsm",
".xlsb",
".pptx",
".pptm",
".ppsx",
".ppsm",
".odt",
".ods",
".odp",
".epub",
)
)
if zipfile.is_zipfile(temp_file_path) and not is_office_format:
extract_dir = os.path.join(upload_dir, "extracted")
+34
View File
@@ -129,6 +129,40 @@ class Settings(BaseSettings):
# and the upload cap is 100 MB, so the gate is what keeps one upload from
# taking the ingest worker down. 0 disables it.
MARKUP_MAX_BYTES: int = 8_000_000
# Trust-check anydoc's PDF output (application/parser/file/pdf_trust.py):
# flag composite (Type0) fonts without a ToUnicode map, and CJK-declaring
# PDFs whose extracted text has almost no CJK — the two classes where
# anydoc drops text silently. A flagged file re-parses on the docling
# fallback when docling is installed; otherwise the anydoc output is kept
# and the document gets extra_info["parse_warnings"]. ~30 ms per scanned MB.
PDF_TRUST_CHECK: bool = True
# Rewrite dot-leader / whitespace-aligned table runs in anydoc's PDF
# markdown into GFM tables (application/parser/file/tableize.py). Off by
# 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):
# 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).
# 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.
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
# every scanned page after a long scanned PDF, with no error) rather than as content.
+166 -3
View File
@@ -18,6 +18,7 @@ import logging
from pathlib import Path
from typing import Dict, List, Optional, Tuple, Union
from application.core.settings import settings
from application.parser.file.base_parser import (
BaseParser,
DocumentParseError,
@@ -27,6 +28,11 @@ from application.parser.file.base_parser import (
logger = logging.getLogger(__name__)
# A fallback parse (scanned-PDF delegation or trust-check re-parse) yielding
# fewer stripped characters than this is an empty document in the making,
# not content.
_MIN_SCAN_FALLBACK_CHARS = 50
# Suffixes anydoc 0.2.3 converts, verified with ``anydoc.format_from_extension``.
# ``.epub`` is deliberately absent: it stays on ``EpubParser``.
ANYDOC_SUFFIXES: Tuple[str, ...] = (
@@ -56,11 +62,39 @@ ANYDOC_SUFFIXES: Tuple[str, ...] = (
)
# The five formats every engine has its own parser for (docling / legacy);
# under the anydoc engine they get that parser as the fallback. Everything
# else in ``ANYDOC_SUFFIXES`` is anydoc-only ("gained") and is mapped to a
# fallback-less ``AnydocParser`` in every parser map.
_CORE_SUFFIXES = frozenset({".pdf", ".docx", ".pptx", ".xlsx", ".csv"})
ANYDOC_GAINED_SUFFIXES: Tuple[str, ...] = tuple(
suffix for suffix in ANYDOC_SUFFIXES if suffix not in _CORE_SUFFIXES
)
def anydoc_available() -> bool:
"""Whether the ``anydoc`` extension can be imported."""
return module_available("anydoc")
def _result_chars(result: Union[str, List[str]]) -> int:
"""Stripped character count of a parser result (str or list of rows)."""
if isinstance(result, list):
return sum(len(str(part).strip()) for part in result)
return len(str(result).strip())
def _is_docling_backed(parser: Optional[BaseParser]) -> bool:
"""True when ``parser`` is a docling parser (worth a trust-check re-parse)."""
if parser is None:
return False
try:
from application.parser.file.docling_parser import DoclingParser
except ImportError:
return False
return isinstance(parser, DoclingParser)
class AnydocParser(BaseParser):
"""Markdown conversion via ``anydoc.to_markdown`` with a typed fallback.
@@ -82,6 +116,10 @@ class AnydocParser(BaseParser):
super().__init__(parser_config)
self.fallback_parser = fallback_parser
self.last_engine: Optional[str] = None
# (path, problems) from the most recent parse whose output the trust
# 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
def _init_parser(self) -> Dict:
# Import for real rather than trusting ``find_spec``: a wheel whose
@@ -117,6 +155,7 @@ class AnydocParser(BaseParser):
import anydoc
path = Path(file)
self._last_warnings = None
try:
content = anydoc.to_markdown(str(path))
except anydoc.ResourceLimitError as exc:
@@ -132,7 +171,10 @@ class AnydocParser(BaseParser):
# Typed, per-document failures another engine may still handle:
# UnsupportedError (scanned PDF / unknown format), MalformedError,
# EncryptedError, MissingPartError.
return self._delegate(path, errors, f"{type(exc).__name__}: {exc}")
ocr_needed = isinstance(exc, anydoc.UnsupportedError) and "OCR" in str(exc)
return self._delegate(
path, errors, f"{type(exc).__name__}: {exc}", ocr_needed=ocr_needed
)
except OSError as exc:
raise DocumentParseError(
f"Failed to parse {path.name}: the file could not be read."
@@ -147,10 +189,117 @@ class AnydocParser(BaseParser):
return self._delegate(path, errors, "anydoc produced no text")
self.last_engine = "anydoc"
if path.suffix.lower() == ".pdf":
content = self._finish_pdf(path, content, errors)
return content
def _delegate(self, path: Path, errors: str, reason: str) -> Union[str, List[str]]:
"""Hand ``path`` to the fallback parser, or fail loudly without one."""
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:
1. Trust check (``PDF_TRUST_CHECK``): the two classes where anydoc
drops text *silently* — Type0 fonts without ToUnicode, and
CJK-declaring PDFs with CJK-less output. A flagged file re-parses
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 /
whitespace table runs as GFM tables. Never applied to a docling
re-parse — docling emits real tables already.
"""
problems = self._trust_problems(path, content)
if problems:
rerouted = self._reroute_flagged(path, errors, problems)
if rerouted is not None:
return rerouted
self._last_warnings = (path, problems)
logger.warning(
f"anydoc output for {path.name} failed the PDF trust check "
f"({'; '.join(problems)}); keeping it with parse_warnings"
)
if settings.ANYDOC_TABLEIZE:
from application.parser.file.tableize import tableize
content = tableize(content)
return content
def _trust_problems(self, path: Path, content: str) -> List[str]:
"""Trust-check findings for ``content``; [] when disabled, clean, or the check errors."""
if not settings.PDF_TRUST_CHECK:
return []
try:
from application.parser.file.pdf_trust import verify_pdf_file
return verify_pdf_file(path, content)
except Exception:
# The check exists to catch silent loss; it must never turn a
# successful parse into a failure.
logger.warning(
f"PDF trust check errored on {path.name}; trusting the output",
exc_info=True,
)
return []
def _reroute_flagged(
self, path: Path, errors: str, problems: List[str]
) -> Optional[Union[str, List[str]]]:
"""Re-parse a trust-flagged PDF with a docling-backed fallback.
Returns the fallback's output, or None when there is no docling
fallback, it fails, or it comes back near-empty — the caller then
keeps the anydoc output.
Only docling is worth the re-parse: it ships Adobe's predefined
CMaps, which is exactly what the flagged font class needs; the
legacy pypdf parser does not.
"""
fallback = self.fallback_parser
if not _is_docling_backed(fallback):
return None
logger.warning(
f"anydoc output for {path.name} failed the PDF trust check "
f"({'; '.join(problems)}); re-parsing with {type(fallback).__name__}"
)
try:
result = delegate_parse(fallback, path, errors)
except DocumentParseError:
logger.warning(
f"docling re-parse of trust-flagged {path.name} failed; "
"keeping the anydoc output",
exc_info=True,
)
return None
if _result_chars(result) < _MIN_SCAN_FALLBACK_CHARS:
# A docling pipeline dropout ('' / '<!-- image -->') returns
# without raising; adopting it would swap anydoc's real text for
# an empty document.
logger.warning(
f"docling re-parse of trust-flagged {path.name} returned almost "
"no text; keeping the anydoc output"
)
return None
self.last_engine = getattr(fallback, "last_engine", None) or type(fallback).__name__
return result
def get_file_metadata(self, file: Path) -> Dict:
"""Surface trust-check findings for the file just parsed, if any."""
last = self._last_warnings
if last is not None and last[0] == Path(file):
return {"parse_warnings": list(last[1])}
return {}
def _delegate(
self, path: Path, errors: str, reason: str, ocr_needed: bool = False
) -> Union[str, List[str]]:
"""Hand ``path`` to the fallback parser, or fail loudly without one.
``ocr_needed`` marks anydoc's scanned-PDF refusal ("OCR is required").
The fallback still runs first — docling extracts text layers anydoc
refuses (seen on degenerate CJK text layers) even with OCR off — but
when it comes back near-empty the parse fails loudly, telling the
user how to get OCR, instead of silently storing an empty document
for a scan.
"""
fallback = self.fallback_parser
if fallback is None:
self.last_engine = None
@@ -160,5 +309,19 @@ class AnydocParser(BaseParser):
f"falling back to {type(fallback).__name__}"
)
result = delegate_parse(fallback, path, errors)
if ocr_needed and _result_chars(result) < _MIN_SCAN_FALLBACK_CHARS:
self.last_engine = None
if getattr(fallback, "ocr_enabled", False):
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."
)
raise DocumentParseError(
f"{path.name} appears to be a scanned PDF (no text layer), and "
f"{type(fallback).__name__} extracted almost nothing{hint}"
)
self.last_engine = getattr(fallback, "last_engine", None) or type(fallback).__name__
return result
+29 -1
View File
@@ -49,6 +49,25 @@ def _wrap_pdf_fast_path(pdf_parser: BaseParser) -> BaseParser:
)
def _gained_format_entries() -> Dict[str, BaseParser]:
"""Anydoc-only formats (legacy/macro Office, OpenDocument, RTF) for every map.
anydoc is a core dependency, so these suffixes are parseable under both
engines. When anydoc is somehow missing the entries are omitted, and such
files fall to ``SimpleDirectoryReader``'s plain-text read — the
pre-existing behaviour for unmapped suffixes.
"""
from application.parser.file.anydoc_parser import (
ANYDOC_GAINED_SUFFIXES,
AnydocParser,
anydoc_available,
)
if not anydoc_available():
return {}
return {suffix: AnydocParser() for suffix in ANYDOC_GAINED_SUFFIXES}
def _legacy_file_extractor(pdf_text_fast_path: bool = False) -> Dict[str, BaseParser]:
"""Parser map that needs neither docling nor anydoc.
@@ -79,6 +98,7 @@ def _legacy_file_extractor(pdf_text_fast_path: bool = False) -> Dict[str, BasePa
".jpg": ImageParser(),
".jpeg": ImageParser(),
**_build_audio_parser_mapping(),
**_gained_format_entries(),
}
@@ -165,6 +185,8 @@ def _docling_file_extractor(
".xml": DoclingXMLParser(),
# Formats docling doesn't support - use standard parsers
".epub": EpubParser(),
# Formats only anydoc reads (legacy/macro Office, OpenDocument, RTF)
**_gained_format_entries(),
}
@@ -211,7 +233,13 @@ def _anydoc_file_extractor(ocr_enabled: bool, pdf_text_fast_path: bool = False)
)
extractor = dict(base)
for suffix in ANYDOC_SUFFIXES:
extractor[suffix] = AnydocParser(fallback_parser=base.get(suffix))
fallback = base.get(suffix)
if isinstance(fallback, AnydocParser):
# A gained-format entry from the base map is already a
# fallback-less AnydocParser — anydoc delegating to anydoc would
# just repeat the same failure.
continue
extractor[suffix] = AnydocParser(fallback_parser=fallback)
extractor[".html"] = HTMLMarkdownParser()
extractor[".xhtml"] = HTMLMarkdownParser()
return extractor
+7
View File
@@ -1,5 +1,6 @@
"""Shared file-extension constants for parsing and ingestion flows."""
from application.parser.file.anydoc_parser import ANYDOC_GAINED_SUFFIXES
from application.stt.constants import SUPPORTED_AUDIO_EXTENSIONS
@@ -16,6 +17,12 @@ SUPPORTED_SOURCE_DOCUMENT_EXTENSIONS = (
".json",
".xlsx",
".pptx",
# Read by the HTML parsers on every engine.
".xhtml",
# Read by the anydoc engine (legacy/macro Office, OpenDocument, RTF).
# Parseable regardless of DOC_PARSER_ENGINE: anydoc is a core dependency,
# and both parser maps route these suffixes to it.
*ANYDOC_GAINED_SUFFIXES,
)
SUPPORTED_SOURCE_IMAGE_EXTENSIONS = (".png", ".jpg", ".jpeg")
+187 -49
View File
@@ -10,6 +10,8 @@ import importlib.util
import logging
import os
import re
import shutil
import sys
import tempfile
import zipfile
from pathlib import Path
@@ -71,6 +73,112 @@ 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:
"""Resolve the OCR engine to build, degrading to ``auto`` when unavailable.
``auto`` is docling's own selection (ocrmac on macOS, rapidocr on a
typical Linux server). The degradation is deliberate: OCR is switched on
by deployments that expect scans to work, so a missing engine must warn
and OCR with what exists rather than fail every parse.
Args:
requested: Engine name, or None to read ``settings.OCR_ENGINE``.
Returns:
One of ``_VALID_OCR_ENGINES``, guaranteed buildable here.
"""
from application.core.settings import settings
from application.parser.file.base_parser import module_available
engine = str(requested or settings.OCR_ENGINE or "auto").strip().lower()
if engine not in _VALID_OCR_ENGINES:
logger.warning(
f"Unknown OCR_ENGINE {engine!r}; using docling auto-selection"
)
return "auto"
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"
)
return "auto"
if engine == "ocrmac" and (
sys.platform != "darwin" or not module_available("ocrmac")
):
logger.warning(
"OCR_ENGINE=ocrmac needs macOS with the ocrmac package; "
"using docling auto-selection"
)
return "auto"
if engine == "rapidocr" and not module_available("rapidocr"):
logger.warning(
"OCR_ENGINE=rapidocr but rapidocr is not installed; "
"using docling auto-selection"
)
return "auto"
return engine
def _build_ocr_options(
engine: str, languages: Optional[List[str]], force_full_page_ocr: bool
):
"""docling OCR options for a resolved classic engine.
Returns None for ``auto`` (docling's default pipeline options already run
auto-selection) and on any build failure — the parse then proceeds on the
default engine rather than failing; the caller re-applies
``force_full_page_ocr`` onto whatever options end up active.
Args:
engine: A ``_resolve_ocr_engine`` result other than ``deepseek``.
languages: Engine-specific language list; None uses the engine's
default (tesseract reads ``settings.OCR_LANGS``).
force_full_page_ocr: OCR whole pages instead of only bitmap regions.
"""
if engine == "auto":
return None
from application.core.settings import settings
try:
if engine == "tesseract":
from docling.datamodel.pipeline_options import TesseractCliOcrOptions
langs = (
languages
or [lang.strip() for lang in settings.OCR_LANGS.split("+") if lang.strip()]
or ["eng"]
)
return TesseractCliOcrOptions(
lang=langs, force_full_page_ocr=force_full_page_ocr
)
if engine == "rapidocr":
from docling.datamodel.pipeline_options import RapidOcrOptions
return RapidOcrOptions(
lang=languages or ["english"],
force_full_page_ocr=force_full_page_ocr,
)
if engine == "ocrmac":
from docling.datamodel.pipeline_options import OcrMacOptions
if languages:
return OcrMacOptions(
lang=languages, force_full_page_ocr=force_full_page_ocr
)
return OcrMacOptions(force_full_page_ocr=force_full_page_ocr)
except ImportError as e:
logger.warning(f"Failed to build {engine} OCR options: {e}")
return None
except Exception as e:
logger.error(f"Error building {engine} OCR options: {e}")
return None
return None
def _tabular_content_size(file: Path) -> int:
"""Effective content size of a tabular file, in bytes.
@@ -303,7 +411,7 @@ class DoclingParser(BaseParser):
- Advanced PDF layout analysis
- Table structure recognition
- Reading order detection
- OCR for scanned documents (supports RapidOCR)
- OCR for scanned documents (engine chosen by ``OCR_ENGINE``)
- Unified DoclingDocument format
- Export to Markdown
@@ -317,7 +425,7 @@ class DoclingParser(BaseParser):
ocr_enabled: bool = True,
table_structure: bool = True,
export_format: str = "markdown",
use_rapidocr: bool = True,
ocr_engine: Optional[str] = None,
ocr_languages: Optional[List[str]] = None,
force_full_page_ocr: bool = False,
):
@@ -327,29 +435,33 @@ class DoclingParser(BaseParser):
ocr_enabled: Enable OCR for bitmap/image regions in documents
table_structure: Enable table structure recognition
export_format: Output format ('markdown', 'text', 'html')
use_rapidocr: Use RapidOCR engine (default True, works well in Docker)
ocr_languages: List of OCR languages (default: ['english'])
ocr_engine: OCR engine when OCR is enabled — one of
``tesseract | auto | ocrmac | rapidocr | deepseek``. None
reads ``settings.OCR_ENGINE`` at converter build time; an
unavailable engine degrades to docling's auto-selection with
a warning.
ocr_languages: Engine-specific language list; None keeps the
engine's own default (tesseract reads ``settings.OCR_LANGS``).
force_full_page_ocr: Force OCR on entire page (False = smart hybrid OCR)
"""
super().__init__()
self.ocr_enabled = ocr_enabled
self.table_structure = table_structure
self.export_format = export_format
self.use_rapidocr = use_rapidocr
self.ocr_languages = ocr_languages or ["english"]
self.ocr_engine = ocr_engine
self.ocr_languages = ocr_languages
self.force_full_page_ocr = force_full_page_ocr
self._converter = None
def _create_converter(self):
"""Create a docling converter with hybrid OCR configuration.
"""Create a docling converter for the configured OCR engine.
Uses smart OCR approach:
- When ocr_enabled=True and force_full_page_ocr=False (default):
Layout model detects text vs bitmap regions, OCR only runs on bitmaps
- When ocr_enabled=True and force_full_page_ocr=True:
OCR runs on entire page (for scanned documents/images)
- When ocr_enabled=False:
No OCR, only native text extraction
- ``ocr_enabled=False``: no OCR, native text extraction only.
- Classic engines (tesseract / auto / ocrmac / rapidocr): the standard
PDF pipeline (layout + TableFormer) with that engine's OCR options.
``force_full_page_ocr=False`` (default) OCRs only the bitmap regions
the layout model finds; True routes whole pages through OCR.
- ``deepseek``: the VLM pipeline instead (``_create_vlm_converter``).
Returns:
DocumentConverter instance
@@ -364,6 +476,10 @@ class DoclingParser(BaseParser):
_apply_inference_settings()
engine = _resolve_ocr_engine(self.ocr_engine) if self.ocr_enabled else None
if engine == "deepseek":
return self._create_vlm_converter()
pipeline_options = PdfPipelineOptions(
do_ocr=self.ocr_enabled,
do_table_structure=self.table_structure,
@@ -371,13 +487,15 @@ class DoclingParser(BaseParser):
_apply_pipeline_caps(pipeline_options)
if self.ocr_enabled:
ocr_options = self._get_ocr_options()
ocr_options = _build_ocr_options(
engine, self.ocr_languages, self.force_full_page_ocr
)
if ocr_options is not None:
pipeline_options.ocr_options = ocr_options
# Docling's *default* OCR options carry their own flag, so without
# this the setting was silently dropped whenever `_get_ocr_options`
# returned None (use_rapidocr=False) — including the dropout retry,
# whose whole point is forcing full-page OCR.
# this the setting was silently dropped whenever no explicit
# options were built (engine=auto, or a build failure) — including
# the dropout retry, whose whole point is forcing full-page OCR.
active_ocr_options = getattr(pipeline_options, "ocr_options", None)
if hasattr(active_ocr_options, "force_full_page_ocr"):
active_ocr_options.force_full_page_ocr = self.force_full_page_ocr
@@ -393,12 +511,55 @@ class DoclingParser(BaseParser):
}
)
def _create_vlm_converter(self):
"""DeepSeek-OCR converter via docling's VLM pipeline.
Each page goes to an OpenAI-compatible endpoint (Ollama or vLLM;
``OCR_DEEPSEEK_URL`` / ``OCR_DEEPSEEK_MODEL``) and the grounded output
is parsed back into a DoclingDocument. This replaces the *entire*
classic pipeline — no layout/TableFormer/OCR models load in the worker
(~370 MB RSS vs 1.0-1.6 GB measured), the compute lives in the model
server. Bench trade-offs (2026-08): best table/CJK/degraded-scan
quality of every engine tried, ~10-20 s/page on modest hardware, and
occasional silent drops of page-level elements (titles).
"""
from docling.datamodel import vlm_model_specs
from docling.datamodel.pipeline_options import VlmPipelineOptions
from docling.document_converter import (
DocumentConverter,
ImageFormatOption,
InputFormat,
PdfFormatOption,
)
from docling.pipeline.vlm_pipeline import VlmPipeline
from application.core.settings import settings
vlm_options = vlm_model_specs.DEEPSEEKOCR_OLLAMA.model_copy(deep=True)
vlm_options.url = settings.OCR_DEEPSEEK_URL
vlm_options.params["model"] = settings.OCR_DEEPSEEK_MODEL
pipeline_options = VlmPipelineOptions(
vlm_options=vlm_options, enable_remote_services=True
)
return DocumentConverter(
format_options={
InputFormat.PDF: PdfFormatOption(
pipeline_cls=VlmPipeline, pipeline_options=pipeline_options
),
InputFormat.IMAGE: ImageFormatOption(
pipeline_cls=VlmPipeline, pipeline_options=pipeline_options
),
}
)
def _init_parser(self) -> Dict:
"""Initialize the docling converter with hybrid OCR."""
from application.core.settings import settings
logger.info("Initializing DoclingParser...")
logger.info(f" ocr_enabled={self.ocr_enabled}")
logger.info(f" force_full_page_ocr={self.force_full_page_ocr}")
logger.info(f" use_rapidocr={self.use_rapidocr}")
logger.info(f" ocr_engine={self.ocr_engine or settings.OCR_ENGINE}")
if importlib.util.find_spec("docling.document_converter") is None:
raise ImportError(
@@ -414,34 +575,11 @@ class DoclingParser(BaseParser):
"ocr_enabled": self.ocr_enabled,
"table_structure": self.table_structure,
"export_format": self.export_format,
"use_rapidocr": self.use_rapidocr,
"ocr_engine": self.ocr_engine,
"ocr_languages": self.ocr_languages,
"force_full_page_ocr": self.force_full_page_ocr,
}
def _get_ocr_options(self):
"""Get OCR options based on configuration.
Returns RapidOcrOptions if use_rapidocr is True and available,
otherwise returns None to use docling defaults.
"""
if not self.use_rapidocr:
return None
try:
from docling.datamodel.pipeline_options import RapidOcrOptions
return RapidOcrOptions(
lang=self.ocr_languages,
force_full_page_ocr=self.force_full_page_ocr,
)
except ImportError as e:
logger.warning(f"Failed to import RapidOcrOptions: {e}")
return None
except Exception as e:
logger.error(f"Error creating RapidOcrOptions: {e}")
return None
def _export_content(self, document) -> str:
"""Export document content in the configured format.
@@ -651,7 +789,7 @@ class DoclingParser(BaseParser):
class DoclingPDFParser(DoclingParser):
"""Docling-based PDF parser with advanced features and RapidOCR support.
"""Docling-based PDF parser with advanced features and configurable OCR.
Uses hybrid OCR approach by default:
- Text regions: Direct PDF text extraction (fast)
@@ -664,7 +802,7 @@ class DoclingPDFParser(DoclingParser):
self,
ocr_enabled: bool = True,
table_structure: bool = True,
use_rapidocr: bool = True,
ocr_engine: Optional[str] = None,
ocr_languages: Optional[List[str]] = None,
force_full_page_ocr: bool = False,
):
@@ -672,7 +810,7 @@ class DoclingPDFParser(DoclingParser):
ocr_enabled=ocr_enabled,
table_structure=table_structure,
export_format="markdown",
use_rapidocr=use_rapidocr,
ocr_engine=ocr_engine,
ocr_languages=ocr_languages,
force_full_page_ocr=force_full_page_ocr,
)
@@ -723,7 +861,7 @@ class DoclingHTMLParser(DoclingParser):
class DoclingImageParser(DoclingParser):
"""Docling-based image parser with OCR and RapidOCR support.
"""Docling-based image parser with configurable OCR.
For images, force_full_page_ocr=True is used since images are entirely
visual and require full OCR to extract any text.
@@ -732,14 +870,14 @@ class DoclingImageParser(DoclingParser):
def __init__(
self,
ocr_enabled: bool = True,
use_rapidocr: bool = True,
ocr_engine: Optional[str] = None,
ocr_languages: Optional[List[str]] = None,
force_full_page_ocr: bool = True,
):
super().__init__(
ocr_enabled=ocr_enabled,
export_format="markdown",
use_rapidocr=use_rapidocr,
ocr_engine=ocr_engine,
ocr_languages=ocr_languages,
force_full_page_ocr=force_full_page_ocr,
)
+177
View File
@@ -0,0 +1,177 @@
"""Trust checks for PDF text extraction.
anydoc silently drops text from fonts it cannot map to Unicode — seen with
non-embedded composite (Type0/CID) fonts relying on predefined CMaps, where
the EN/ZH NDA corpus file loses its whole Chinese column with no error, and
with Adobe-CNS1 fonts in HK legal PDFs where a valid ToUnicode exists but
extraction still fails. These dependency-free checks scan the PDF's raw
objects (including Flate-compressed object streams) so the pipeline can
route such files to a heavier parser, or at least mark the output as
unverified, instead of trusting silent partial text.
Two stages:
* ``check_pdf_fonts`` — pre-flight on the bytes alone: composite fonts
without an embedded ToUnicode CMap, and whether the PDF declares CJK font
resources at all.
* ``verify_extraction`` — pre-flight plus the cross-check the font scan
cannot do: a PDF that declares CJK fonts whose extracted text contains
almost no CJK characters is not to be trusted.
A flag means "verify or route to a fallback", not "the text is wrong": tools
shipping Adobe's predefined CMaps (docling's parser does) often extract such
files fine. On the benchmark corpus the checks caught both known
silent-drop cases with zero false positives on the other 14 PDFs, and cost
~30 ms per scanned MB (92 ms on a 3 MB, 150-page annual report).
"""
import re
import zlib
from pathlib import Path
from typing import Dict, Iterator, List, Union
_OBJ = re.compile(rb"\d+\s+\d+\s+obj(.*?)endobj", re.DOTALL)
_STREAM = re.compile(rb"stream\r?\n(.*?)\r?\nendstream", re.DOTALL)
_TYPE0 = re.compile(rb"/Subtype\s*/Type0")
# The CJK expectation is keyed on CIDSystemInfo /Ordering ALONE — the
# authoritative "this PDF maps text through a CJK character collection"
# signal, present in every known silent-drop case. Matching CJK font *names*
# (SimSun, MS-Gothic, MSungHK, ...) was tried and rejected: Word-exported
# Latin PDFs routinely embed an MS-Gothic subset for a stray full-width
# character (a 150-page English annual report in the benchmark corpus does),
# and substring matching also catches Latin faces like FranklinGothic —
# either way English-only documents would be rerouted to the heavy engine.
_CJK_ORDERING = re.compile(rb"/Ordering\s*\((GB1|CNS1|Japan1|Japan2|KR|Korea1)\)")
# Bytes kept around a Type0 marker found inside a decompressed object
# stream: object streams hold many dicts with no obj/endobj markers, and a
# font dict's keys sit close to its /Subtype entry.
_WINDOW_BEFORE, _WINDOW_AFTER = 200, 800
# Extracted text with fewer CJK characters than this, from a PDF that
# declares CJK fonts, is treated as a silent drop.
_MIN_CJK_CHARS = 10
_CJK_RANGES = (
("一", "鿿"), # CJK Unified Ideographs
("぀", "ヿ"), # Hiragana + Katakana
("가", "힯"), # Hangul syllables
)
# Per-stream decompression cap. Flate reaches ~1000:1, so uncapped inflation
# of a crafted (or merely image-heavy) PDF inside the upload cap could balloon
# to gigabytes and OOM the ingest worker. Font dicts and object streams — the
# only things the signals live in — are far smaller than this.
_STREAM_INFLATE_CAP = 4_000_000
def _decompressed_streams(raw: bytes) -> Iterator[bytes]:
"""Each Flate stream in ``raw`` that decompresses, one at a time, capped."""
for match in _STREAM.finditer(raw):
try:
inflated = zlib.decompressobj().decompress(
match.group(1), _STREAM_INFLATE_CAP
)
except zlib.error:
continue
yield inflated
def _cjk_chars(text: str, up_to: int) -> int:
"""Count CJK characters in ``text``, stopping once ``up_to`` is reached."""
count = 0
for char in text:
for low, high in _CJK_RANGES:
if low <= char <= high:
count += 1
break
if count >= up_to:
break
return count
def check_pdf_fonts(data: bytes) -> Dict[str, Union[int, bool]]:
"""Pre-flight font scan of a PDF's bytes.
Args:
data: The complete PDF file contents.
Returns:
Dict with ``type0`` (composite fonts seen), ``type0_no_tounicode``
(those without an embedded ToUnicode CMap), ``expects_cjk`` (the PDF
declares CJK font resources), ``has_fonts``, and ``flagged`` — True
when extraction should not be trusted unverified.
"""
type0 = bare = 0
expects_cjk = bool(_CJK_ORDERING.search(data))
has_fonts = b"/Font" in data
def count_font(unit: bytes) -> None:
"""One analysis unit holding a Type0 font dict."""
nonlocal type0, bare
type0 += 1
if b"/ToUnicode" not in unit:
bare += 1
for match in _OBJ.finditer(data):
body = match.group(1)
if _TYPE0.search(body):
count_font(body)
# Each decompressed stream is scanned for every signal in one pass and
# then dropped; nothing inflated is retained past its loop iteration.
for stream in _decompressed_streams(data):
expects_cjk = expects_cjk or bool(_CJK_ORDERING.search(stream))
has_fonts = has_fonts or b"/Font" in stream
for type0_match in _TYPE0.finditer(stream):
start = type0_match.start()
count_font(stream[max(0, start - _WINDOW_BEFORE): start + _WINDOW_AFTER])
return {
"type0": type0,
"type0_no_tounicode": bare,
"expects_cjk": expects_cjk,
"has_fonts": has_fonts,
"flagged": bare > 0 or not has_fonts,
}
def verify_extraction(data: bytes, markdown: str) -> List[str]:
"""Reasons the extracted ``markdown`` of the PDF ``data`` shouldn't be trusted.
Combines the pre-flight font scan with the post-conversion cross-check it
cannot do alone: fonts with a valid ToUnicode that the converter
nevertheless failed to extract still show up as missing CJK output.
Args:
data: The complete PDF file contents.
markdown: The text a converter extracted from it.
Returns:
Human-readable problem strings; empty when the output looks sound.
"""
problems: List[str] = []
result = check_pdf_fonts(data)
if result["type0_no_tounicode"]:
problems.append(
f"{result['type0_no_tounicode']} composite (Type0) font(s) carry no "
"ToUnicode map; extracted text may silently omit glyphs"
)
elif not result["has_fonts"]:
problems.append(
"no font resources detected; text extraction cannot be verified"
)
if result["expects_cjk"]:
cjk = _cjk_chars(markdown, _MIN_CJK_CHARS)
if cjk < _MIN_CJK_CHARS:
problems.append(
f"PDF declares CJK fonts but the extracted text contains only "
f"{cjk} CJK character(s)"
)
return problems
def verify_pdf_file(file: Path, markdown: str) -> List[str]:
"""``verify_extraction`` for a file on disk; unreadable files trust the output."""
try:
data = Path(file).read_bytes()
except OSError:
return []
return verify_extraction(data, markdown)
+94
View File
@@ -0,0 +1,94 @@
"""Reconstruct typographic tables in anydoc's PDF Markdown output.
anydoc converts PDFs straight to Markdown with no document model, so tables
drawn with dot leaders or bare whitespace alignment (financial statements,
tables of contents) come out as flat text lines — values intact, structure
lost. This post-processor detects runs of such lines and rewrites them as
GFM tables: the lightweight alternative to a table-structure model for this
layout family. (The robust fix is upstream in anydoc's Rust PDF code, where
glyph x-positions exist; this is the recoverable-downstream version.)
Deliberately conservative — a run converts only when it has at least
``min_rows`` consecutive lines that each parse as ``label [leaders]
numeric-columns`` with the *same* column count; anything else passes through
untouched. Gated by ``ANYDOC_TABLEIZE`` (off by default) and applied only to
anydoc's own PDF output, never to docling's.
"""
import re
from typing import List, Optional, Tuple
# label ...... 1,234 (56) — dot-leader row
_LEADER = re.compile(r"^(.*?)\s*\.{3,}\s*(.+)$")
# numeric-ish token: $ 1,234 / (30) / 83,431 / 12.5% / —
_NUM_TOKEN = re.compile(r"\$?\(?-?\d[\d,.]*\)?%?|—")
# row without leaders: label text, then a trailing run of numeric tokens
_TRAILING = re.compile(r"^(.*?[^\s\d,.])\s+([\d$(].*)$")
def _merge_currency(tokens: List[str]) -> List[str]:
"""Join a free-standing ``$`` onto the number that follows it."""
out: List[str] = []
for token in tokens:
if out and out[-1] == "$":
out[-1] = "$" + token
else:
out.append(token)
return out
def _parse_row(line: str) -> Optional[Tuple[str, List[str]]]:
"""``(label, values)`` when the line looks like a typographic table row, else None."""
match = _LEADER.match(line) or _TRAILING.match(line)
if not match:
return None
label, rest = match.group(1).strip(" ."), match.group(2)
values = _merge_currency(rest.split())
if not values or not all(_NUM_TOKEN.fullmatch(v.lstrip("$")) for v in values):
return None
if not label or _NUM_TOKEN.fullmatch(label):
return None
return label, values
def tableize(markdown: str, min_rows: int = 3) -> str:
"""Rewrite runs of typographic table rows in ``markdown`` as GFM tables.
Args:
markdown: Converter output to post-process.
min_rows: Minimum consecutive, same-width rows for a run to convert.
Returns:
``markdown`` with qualifying runs rewritten; everything else verbatim.
"""
out: List[str] = []
run: List[Tuple[str, List[str], str]] = [] # (label, values, original line)
def flush() -> None:
nonlocal run
widths = {len(values) for _, values, _ in run}
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)]) + " |")
out.append("|" + " --- |" * (ncols + 1))
for label, values, _ in run:
# Only the label can carry a '|' (values are numeric tokens);
# unescaped it would add a cell and mis-column the row.
cells = [label.replace("|", "\\|")] + values
out.append("| " + " | ".join(cells) + " |")
out.append("")
else:
out.extend(original for _, _, original in run)
run = []
for line in markdown.splitlines():
parsed = _parse_row(line.strip()) if line.strip() else None
if parsed:
run.append((*parsed, line))
else:
if run:
flush()
out.append(line)
if run:
flush()
return "\n".join(out)
+1 -1
View File
@@ -1777,7 +1777,7 @@ def attachment_worker(self, file_info, user):
parser_metadata = {
key: value
for key, value in (attachment_document.extra_info or {}).items()
if key.startswith("transcript_")
if key.startswith("transcript_") or key == "parse_warnings"
}
if parser_metadata:
metadata = {**metadata, **parser_metadata}