mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-12 18:15:33 +00:00
Merge pull request #2701 from arc53/anydoc-support
This commit is contained in:
50 files changed
+6434
-398
No files matched your search
@@ -29,6 +29,8 @@ Use these commands once the dev prerequisites above are satisfied.
|
||||
```bash
|
||||
source .venv/bin/activate # macOS/Linux
|
||||
uv pip install -r application/requirements.txt # or: pip install -r application/requirements.txt
|
||||
# Optional docling parser engine (OCR / read_document structured output):
|
||||
# uv pip install -r application/requirements-docling.txt
|
||||
```
|
||||
|
||||
Run the API. For local dev, prefer the ASGI entrypoint under uvicorn — it
|
||||
|
||||
+25
-2
@@ -23,8 +23,8 @@ RUN wget -q -O - https://sh.rustup.rs | sh -s -- -y
|
||||
# Clean up to reduce container size
|
||||
RUN apt-get remove --purge -y wget unzip && apt-get autoremove -y && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy requirements.txt
|
||||
COPY requirements.txt .
|
||||
# Copy requirements manifests
|
||||
COPY requirements.txt requirements-docling.txt ./
|
||||
|
||||
# Setup Python virtual environment
|
||||
RUN python3.12 -m venv /venv
|
||||
@@ -37,6 +37,15 @@ 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, 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, also opt-in) without it.
|
||||
ARG INSTALL_DOCLING=false
|
||||
RUN if [ "$INSTALL_DOCLING" = "true" ]; then \
|
||||
pip install --no-cache-dir -r requirements-docling.txt; \
|
||||
fi
|
||||
|
||||
# Final Stage
|
||||
FROM ubuntu:24.04 as final
|
||||
@@ -53,6 +62,20 @@ RUN apt-get update && \
|
||||
ln -s /usr/bin/python3.12 /usr/bin/python && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Optional tesseract OCR engine (OCR_ENABLED=true with OCR_ENGINE=tesseract,
|
||||
# the default engine) — OFF by default like every other OCR dependency; OCR
|
||||
# itself is off unless configured. Opt in with --build-arg
|
||||
# INSTALL_TESSERACT=true (setup.sh writes it to .env when OCR is enabled);
|
||||
# ~35 MB of system packages. Extra language packs are a deployment concern
|
||||
# (apt: tesseract-ocr-<lang>, then list them in OCR_LANGS). A DeepSeek-OCR
|
||||
# endpoint (OCR_ENGINE=deepseek) needs none of this.
|
||||
ARG INSTALL_TESSERACT=false
|
||||
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/*; \
|
||||
fi
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -102,7 +102,8 @@ class ReadDocumentTool(Tool):
|
||||
"enum": ["markdown", "text", "structured", "chunks"],
|
||||
"description": "Shape of the parsed result (default: markdown). Note: "
|
||||
"`structured` always uses the Docling engine regardless of `engine` "
|
||||
"(the `fast` engine is markdown/text only).",
|
||||
"(requires the optional docling install; the other engines are "
|
||||
"markdown/text only).",
|
||||
},
|
||||
"ocr": {
|
||||
"type": "string",
|
||||
@@ -115,9 +116,11 @@ class ReadDocumentTool(Tool):
|
||||
},
|
||||
"engine": {
|
||||
"type": "string",
|
||||
"enum": ["auto", "docling", "fast"],
|
||||
"description": "Parser engine (default: auto). `fast` is a lighter "
|
||||
"markdown/text-only engine; it is ignored when `output='structured'`, "
|
||||
"enum": ["auto", "docling", "anydoc", "fast"],
|
||||
"description": "Parser engine (default: auto = the server's configured "
|
||||
"engine). `anydoc` is the fast Markdown converter, `docling` the "
|
||||
"layout-model engine (optional install, needed for tables/OCR), "
|
||||
"`fast` a plain-text legacy path. Ignored when `output='structured'`, "
|
||||
"which always uses Docling.",
|
||||
},
|
||||
"max_chars": {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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__))))
|
||||
@@ -118,20 +118,106 @@ class Settings(BaseSettings):
|
||||
UPLOAD_MAX_ARCHIVE_DEPTH: int = Field(default=3, ge=0)
|
||||
PARSE_PDF_AS_IMAGE: bool = False
|
||||
PARSE_IMAGE_REMOTE: bool = False
|
||||
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
|
||||
# Pages docling buffers in flight; its default of 100 drives worker RSS to ~3 GB on a mid-size PDF.
|
||||
# Document parser for source ingestion, chat attachments and the
|
||||
# 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 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"
|
||||
# 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
|
||||
DOCLING_COMPILE_TORCH_MODELS: bool = False
|
||||
DOCLING_TABULAR_MAX_BYTES: int = 2_000_000
|
||||
DOCLING_MARKUP_MAX_BYTES: int = 8_000_000
|
||||
# Chars-per-page floor below which an OCR'd parse is treated as a docling dropout rather than
|
||||
# content: retried once on a fresh converter, then failed loudly instead of indexing an empty
|
||||
# document. Long-running workers were seen returning zero chars per page with no error. 0 disables.
|
||||
DOCLING_OCR_MIN_CHARS_PER_PAGE: int = 20
|
||||
# Read PDF *attachments* via their text layer (pypdfium2), falling back to docling when there
|
||||
# is none. Attachments go into a prompt, where docling's structure does not earn its tens of
|
||||
# seconds per file. Source ingestion is unaffected and always uses docling.
|
||||
# HTML/XHTML larger than this (bytes) are head-truncated before the
|
||||
# markdownify parser runs (the anydoc engine's HTML path). The tree that
|
||||
# path builds costs ~50x the input — 30 MB of HTML measured at 1.6 GB RSS —
|
||||
# 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 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: an optional install like
|
||||
# every OCR dependency (build with INSTALL_TESSERACT=true, or apt/brew
|
||||
# install tesseract-ocr for a local run). 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.
|
||||
# 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"
|
||||
# Seconds allowed per page request to the DeepSeek endpoint, on both
|
||||
# backends (native sends pages one at a time; docling's VLM pipeline
|
||||
# keeps its own concurrency). 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.
|
||||
# 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.
|
||||
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
|
||||
# less than the tens of seconds per file it costs; source ingestion is
|
||||
# unaffected and always uses docling, because chunking and retrieval do
|
||||
# depend on that structure.
|
||||
ATTACHMENT_PDF_TEXT_FAST_PATH: bool = True
|
||||
# Median chars per sampled page below which a PDF is treated as a scan and handed to docling.
|
||||
# Measured on real uploads: scans at 0-17 chars/page, text-layer documents at 433-6834.
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"""In-process document parsing for the ``read_document`` tool, run on the Celery parsing worker.
|
||||
|
||||
``parse_document_bytes`` turns untrusted document bytes into a bounded, shaped
|
||||
result (markdown/text/structured/chunks) using the BACKEND parsers (Docling by
|
||||
default). It applies the same untrusted-content safeguards as uploads — an
|
||||
extension whitelist, a byte cap, ``safe_filename`` staging into a temp file, and
|
||||
temp cleanup — so a hostile filename or document is treated as inert data.
|
||||
result (markdown/text/structured/chunks) using the BACKEND parsers (the engine
|
||||
selected by ``DOC_PARSER_ENGINE``; ``structured`` output always needs Docling).
|
||||
It applies the same untrusted-content safeguards as uploads — an extension
|
||||
whitelist, a byte cap, ``safe_filename`` staging into a temp file, and temp
|
||||
cleanup — so a hostile filename or document is treated as inert data.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -40,7 +41,7 @@ _MAX_PAGE_SELECTOR_TOKENS = 20_000
|
||||
|
||||
_VALID_OUTPUTS = ("markdown", "text", "structured", "chunks")
|
||||
_VALID_OCR = ("auto", "on", "off")
|
||||
_VALID_ENGINES = ("auto", "docling", "fast")
|
||||
_VALID_ENGINES = ("auto", "docling", "anydoc", "fast")
|
||||
|
||||
|
||||
def truncate_text_head_tail(text: str, max_bytes: Optional[int] = None) -> str:
|
||||
@@ -102,7 +103,13 @@ def _max_input_bytes() -> int:
|
||||
return int(getattr(settings, "SANDBOX_MAX_INPUT_BYTES", 25 * 1024 * 1024))
|
||||
|
||||
|
||||
_ZIP_CONTAINER_EXTENSIONS = frozenset({".docx", ".xlsx", ".pptx", ".epub"})
|
||||
# Every zip-packaged format a parser map can route: OOXML and its macro/
|
||||
# binary variants, OpenDocument, EPUB. Chat attachments have no extension
|
||||
# whitelist, so the anydoc engine can receive any of these.
|
||||
_ZIP_CONTAINER_EXTENSIONS = frozenset({
|
||||
".docx", ".docm", ".xlsx", ".xlsm", ".xlsb", ".pptx", ".pptm", ".ppsx", ".ppsm",
|
||||
".odt", ".ods", ".odp", ".epub",
|
||||
})
|
||||
|
||||
|
||||
def _zip_bomb_reason(source: Union[bytes, str, Path], suffix: str) -> Optional[str]:
|
||||
@@ -162,7 +169,15 @@ 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:
|
||||
"""Resolve ``auto`` to the server's ``DOC_PARSER_ENGINE``; other values pass through."""
|
||||
if engine != "auto":
|
||||
return engine
|
||||
configured = getattr(settings, "DOC_PARSER_ENGINE", None) or "anydoc"
|
||||
return str(configured).strip().lower()
|
||||
|
||||
|
||||
def _pick_parser(suffix: str, *, ocr_enabled: bool, engine: str):
|
||||
@@ -171,25 +186,44 @@ def _pick_parser(suffix: str, *, ocr_enabled: bool, engine: str):
|
||||
legacy = _legacy_parser_for(suffix)
|
||||
if legacy is not None:
|
||||
return legacy
|
||||
extractor = get_default_file_extractor(ocr_enabled=ocr_enabled)
|
||||
from application.parser.file.anydoc_parser import ANYDOC_GAINED_SUFFIXES
|
||||
|
||||
if suffix in ANYDOC_GAINED_SUFFIXES:
|
||||
# anydoc-only office formats have no legacy parser. Falling through
|
||||
# would run the configured engine the caller explicitly opted out
|
||||
# of; images/audio/.txt still fall through (no legacy alternative).
|
||||
return None
|
||||
if engine in ("docling", "anydoc"):
|
||||
extractor = get_default_file_extractor(ocr_enabled=ocr_enabled, engine=engine)
|
||||
else:
|
||||
extractor = get_default_file_extractor(ocr_enabled=ocr_enabled)
|
||||
return extractor.get(suffix)
|
||||
|
||||
|
||||
def _legacy_parser_for(suffix: str):
|
||||
"""Return a non-Docling parser for ``suffix`` (the ``fast`` engine), or None."""
|
||||
from application.parser.file.docs_parser import DocxParser, PDFParser
|
||||
from application.parser.file.epub_parser import EpubParser
|
||||
from application.parser.file.html_parser import HTMLParser
|
||||
from application.parser.file.json_parser import JSONParser
|
||||
from application.parser.file.markdown_parser import MarkdownParser
|
||||
from application.parser.file.pptx_parser import PPTXParser
|
||||
from application.parser.file.rst_parser import RstParser
|
||||
from application.parser.file.tabular_parser import ExcelParser, PandasCSVParser
|
||||
|
||||
legacy = {
|
||||
".pdf": PDFParser,
|
||||
".docx": DocxParser,
|
||||
".pptx": PPTXParser,
|
||||
".csv": PandasCSVParser,
|
||||
".xlsx": ExcelParser,
|
||||
".html": HTMLParser,
|
||||
".xhtml": HTMLParser,
|
||||
".epub": EpubParser,
|
||||
".md": MarkdownParser,
|
||||
".mdx": MarkdownParser,
|
||||
".rst": RstParser,
|
||||
".json": JSONParser,
|
||||
}
|
||||
cls = legacy.get(suffix)
|
||||
return cls() if cls is not None else None
|
||||
@@ -205,6 +239,17 @@ def _parse_to_text(parser: Any, path: Path) -> str:
|
||||
return str(parsed)
|
||||
|
||||
|
||||
def _is_native_ocr_parser(parser: Any) -> bool:
|
||||
"""True for the native OCR PDF parser (OCR_BACKEND=native): a docling table pass would OCR the scan again."""
|
||||
if parser is None:
|
||||
return False
|
||||
try:
|
||||
from application.parser.file.ocr_parser import NativeOcrPdfParser
|
||||
except Exception:
|
||||
return False
|
||||
return isinstance(parser, NativeOcrPdfParser)
|
||||
|
||||
|
||||
def _is_docling_parser(parser: Any) -> bool:
|
||||
"""True when ``parser`` is Docling-backed (collecting tables would otherwise re-convert)."""
|
||||
if parser is None:
|
||||
@@ -491,7 +536,29 @@ def _shape(
|
||||
}
|
||||
|
||||
parser = _pick_parser(suffix, ocr_enabled=ocr_enabled, engine=engine)
|
||||
wants_tables = include_tables and engine != "fast" and output != "chunks"
|
||||
# Under OCR_BACKEND=native with the docling engine the PDF parser is the
|
||||
# native OCR parser wrapping a DoclingParser as its text_parser. A PDF
|
||||
# with a text layer on every page is handed to that parser untouched, so
|
||||
# when tables are wanted run it directly: the single docling conversion
|
||||
# below then yields both the markdown and the tables. Scans and mixed
|
||||
# documents keep the native parser (and the exclusion just below).
|
||||
if include_tables and output != "chunks" and _is_native_ocr_parser(parser):
|
||||
delegate = parser.text_layer_delegate(path)
|
||||
if _is_docling_parser(delegate):
|
||||
parser = delegate
|
||||
# Tables come from a Docling conversion, so they are only collected when
|
||||
# a Docling parser is actually in play: under anydoc/fast a table pass
|
||||
# would be a second, full docling conversion that costs more than the
|
||||
# whole read, and under OCR_BACKEND=native the PDF parser is the native
|
||||
# OCR parser (docling only as its text_parser), where a vanilla
|
||||
# DocumentConverter pass would OCR the scan a second time with docling's
|
||||
# default engine, ignoring OCR_ENGINE.
|
||||
wants_tables = (
|
||||
include_tables
|
||||
and output != "chunks"
|
||||
and _effective_engine(engine) == "docling"
|
||||
and not _is_native_ocr_parser(parser)
|
||||
)
|
||||
|
||||
# A Docling-backed parser already converts the whole document to produce its text.
|
||||
# When tables are also requested, reuse that single conversion for both the markdown
|
||||
@@ -513,7 +580,21 @@ def _shape(
|
||||
|
||||
if parser is None:
|
||||
# A whitelisted extension with no dedicated parser (e.g. .txt) reads as plain
|
||||
# text, matching SimpleDirectoryReader's standard-read fallback.
|
||||
# text, matching SimpleDirectoryReader's standard-read fallback. Binary
|
||||
# office formats that only anydoc reads must not: without anydoc they
|
||||
# would come back as OLE/zip bytes decoded as text.
|
||||
from application.parser.file.anydoc_parser import ANYDOC_GAINED_SUFFIXES
|
||||
|
||||
if suffix in ANYDOC_GAINED_SUFFIXES:
|
||||
if engine == "fast":
|
||||
return {
|
||||
"error": f"The fast engine has no parser for {suffix} files; "
|
||||
"use engine='anydoc' or 'auto'."
|
||||
}
|
||||
return {
|
||||
"error": f"No parser is available for {suffix} files: firecrawl-anydoc "
|
||||
"is not installed (pip install firecrawl-anydoc)"
|
||||
}
|
||||
text = path.read_text(errors="ignore")
|
||||
else:
|
||||
text = _parse_to_text(parser, path)
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
"""anydoc parser.
|
||||
|
||||
Converts documents to GitHub-Flavored Markdown with `firecrawl-anydoc
|
||||
<https://github.com/firecrawl/anydoc>`_: a single Rust extension with no ML
|
||||
models and no Python dependencies. Measured against docling on a 20-document
|
||||
corpus it was 20-160x faster per file (1.2 s vs 193 s on a 150-page annual
|
||||
report), imported in 6 ms instead of 3.9 s, and peaked at 107 MB RSS instead
|
||||
of 2.3 GB, with equivalent output on clean office files, CSV and text-layer
|
||||
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, 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
|
||||
from typing import Dict, List, Optional, Tuple, Union
|
||||
|
||||
from application.core.settings import settings
|
||||
from application.parser.file.base_parser import (
|
||||
BaseParser,
|
||||
DocumentParseError,
|
||||
delegate_parse,
|
||||
module_available,
|
||||
)
|
||||
|
||||
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, ...] = (
|
||||
# Word processing
|
||||
".pdf",
|
||||
".docx",
|
||||
".docm",
|
||||
".doc",
|
||||
".odt",
|
||||
".rtf",
|
||||
# Presentations
|
||||
".pptx",
|
||||
".pptm",
|
||||
".ppsx",
|
||||
".ppsm",
|
||||
".ppt",
|
||||
".pps",
|
||||
".pot",
|
||||
".odp",
|
||||
# Spreadsheets / tabular
|
||||
".xlsx",
|
||||
".xlsm",
|
||||
".xlsb",
|
||||
".xls",
|
||||
".ods",
|
||||
".csv",
|
||||
)
|
||||
|
||||
|
||||
# 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"})
|
||||
|
||||
# Spreadsheets anydoc may refuse on its fixed, non-configurable limits
|
||||
# (2M XML nodes per part, 128 MiB decompressed per part — a ~265k-row sheet
|
||||
# trips them) that the plain tabular parsers read fine in a few hundred MB.
|
||||
# For these a ResourceLimitError is a reason to delegate, not to fail.
|
||||
_TABULAR_SUFFIXES = frozenset({".csv", ".xlsx", ".xlsm", ".xlsb", ".xls", ".ods"})
|
||||
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).
|
||||
|
||||
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
|
||||
if isinstance(parser, DoclingParser):
|
||||
return True
|
||||
return isinstance(getattr(parser, "text_parser", None), DoclingParser)
|
||||
|
||||
|
||||
class AnydocParser(BaseParser):
|
||||
"""Markdown conversion via ``anydoc.to_markdown`` with a typed fallback.
|
||||
|
||||
Attributes:
|
||||
fallback_parser: Parser used when anydoc cannot convert the file
|
||||
(scanned PDF, unsupported or malformed input). ``None`` means
|
||||
such files raise ``DocumentParseError``.
|
||||
last_engine: Name of the engine that produced the most recent parse:
|
||||
``"anydoc"``, or the fallback parser's ``last_engine`` / class
|
||||
name when it was delegated to. Mirrors ``PdfiumTextParser`` so
|
||||
the attachment worker records what actually ran.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
fallback_parser: Optional[BaseParser] = None,
|
||||
parser_config: Optional[Dict] = None,
|
||||
) -> None:
|
||||
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
|
||||
# (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
|
||||
# native extension fails to load (glibc/arch mismatch) has a spec but
|
||||
# no importable module, and that must fail here — where the ingest
|
||||
# task treats it as a setup error — not as a bare ImportError from
|
||||
# ``parse_file`` mid-batch, which ``load_data`` does not catch.
|
||||
try:
|
||||
import anydoc # noqa: F401
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"firecrawl-anydoc is required for AnydocParser. "
|
||||
"Install it with: pip install firecrawl-anydoc"
|
||||
) from exc
|
||||
fallback = self.fallback_parser
|
||||
return {"fallback_parser": type(fallback).__name__ if fallback else None}
|
||||
|
||||
def parse_file(self, file: Path, errors: str = "ignore") -> Union[str, List[str]]:
|
||||
"""Convert ``file`` to Markdown.
|
||||
|
||||
Args:
|
||||
file: Path to the document.
|
||||
errors: Decoding error policy; anydoc decodes internally, so this
|
||||
is only forwarded to the fallback parser.
|
||||
|
||||
Returns:
|
||||
The document as Markdown.
|
||||
|
||||
Raises:
|
||||
DocumentParseError: When neither anydoc nor the fallback could
|
||||
produce text for the file, or anydoc hit a resource limit.
|
||||
"""
|
||||
import anydoc
|
||||
|
||||
path = Path(file)
|
||||
self._last_warnings = None
|
||||
self._last_ocr_pages = None
|
||||
try:
|
||||
content = anydoc.to_markdown(str(path))
|
||||
except anydoc.ResourceLimitError as exc:
|
||||
# anydoc refused because the file is too expensive (declared
|
||||
# decompression size, nesting depth, node count). For office
|
||||
# documents a heavier engine would spend exactly what was just
|
||||
# refused, so this is terminal. Spreadsheets are the exception:
|
||||
# anydoc's cell/node limits sit below what ordinary business
|
||||
# exports reach, and the tabular fallback (docling's gate hands
|
||||
# oversized sheets to pandas/openpyxl) is lighter, not heavier.
|
||||
if path.suffix.lower() in _TABULAR_SUFFIXES and self.fallback_parser is not None:
|
||||
return self._delegate(path, errors, f"{type(exc).__name__}: {exc}")
|
||||
self.last_engine = None
|
||||
raise DocumentParseError(
|
||||
f"Failed to parse {path.name}: {type(exc).__name__}: {exc}"
|
||||
) from exc
|
||||
except anydoc.ConvertError as exc:
|
||||
# Typed, per-document failures another engine may still handle:
|
||||
# UnsupportedError (scanned PDF / unknown format), MalformedError,
|
||||
# EncryptedError, MissingPartError.
|
||||
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."
|
||||
) from exc
|
||||
except Exception as exc:
|
||||
logger.error(f"anydoc failed on {path.name}: {exc}", exc_info=True)
|
||||
raise DocumentParseError(
|
||||
f"Failed to parse {path.name} with anydoc: {exc}"
|
||||
) from exc
|
||||
|
||||
if not content or not content.strip():
|
||||
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 _finish_pdf(self, path: Path, content: str, errors: str) -> Union[str, List[str]]:
|
||||
"""Post-process a successful anydoc PDF conversion.
|
||||
|
||||
Three 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. 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.
|
||||
"""
|
||||
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"
|
||||
)
|
||||
content = self._ocr_scanned_pages(path, content)
|
||||
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 _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. Pages are OCR'd
|
||||
one at a time so a failure on one page costs that page, not the OCR
|
||||
of every other; the anydoc text is always kept — a partial document
|
||||
beats a rejected upload, and the loud-failure path stays reserved
|
||||
for fully scanned files. anydoc's PDF markdown carries no page
|
||||
separators, so the OCR'd pages are appended after the text pages
|
||||
rather than spliced into position.
|
||||
"""
|
||||
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()
|
||||
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: List[str] = []
|
||||
for index in indices:
|
||||
try:
|
||||
text = fallback.ocr_pages(path, [index]).get(index, "")
|
||||
except Exception: # noqa: BLE001 - one page's failure must not drop the others
|
||||
logger.warning(
|
||||
"OCR of page %d of %s failed; skipping that page",
|
||||
index + 1,
|
||||
path.name,
|
||||
exc_info=True,
|
||||
)
|
||||
continue
|
||||
if text and text.strip():
|
||||
extra.append(text)
|
||||
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 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):
|
||||
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
|
||||
) -> 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
|
||||
raise DocumentParseError(f"Failed to parse {path.name}: {reason}")
|
||||
logger.warning(
|
||||
f"anydoc could not convert {path.name} ({reason}); "
|
||||
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 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 "
|
||||
f"{type(fallback).__name__} extracted almost nothing{hint}"
|
||||
)
|
||||
self.last_engine = getattr(fallback, "last_engine", None) or type(fallback).__name__
|
||||
return result
|
||||
@@ -1,9 +1,14 @@
|
||||
"""Base parser and config class."""
|
||||
|
||||
import importlib.util
|
||||
import logging
|
||||
import sys
|
||||
from abc import abstractmethod
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Union
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DocumentParseError(Exception):
|
||||
"""A file could not be converted to text.
|
||||
@@ -57,3 +62,72 @@ class BaseParser:
|
||||
"""Return parser-specific metadata for the most recently parsed file."""
|
||||
_ = file
|
||||
return {}
|
||||
|
||||
|
||||
def module_available(name: str) -> bool:
|
||||
"""Whether top-level module ``name`` can be imported, without importing it.
|
||||
|
||||
Cheap enough to call while building a parser map. Honors ``sys.modules``
|
||||
first so tests that stub a module in (or block one out with ``None``)
|
||||
are seen the same way the import system would see them; a stub with no
|
||||
``__spec__`` counts as present rather than raising.
|
||||
|
||||
Args:
|
||||
name: Top-level module name, e.g. ``"docling"``.
|
||||
|
||||
Returns:
|
||||
True when an import of ``name`` would succeed.
|
||||
"""
|
||||
if name in sys.modules:
|
||||
return sys.modules[name] is not None
|
||||
try:
|
||||
return importlib.util.find_spec(name) is not None
|
||||
except (ImportError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def delegate_parse(
|
||||
parser: "BaseParser", file: Path, errors: str
|
||||
) -> Union[str, List[str]]:
|
||||
"""Run a fallback parser, normalizing its failures to ``DocumentParseError``.
|
||||
|
||||
Used wherever one parser hands a file to another (docling to the plain
|
||||
tabular parsers for oversized sheets, anydoc to docling/legacy parsers
|
||||
for files it cannot convert). Wrapping matters twice over: the Celery
|
||||
tasks that own these paths list ``DocumentParseError`` in
|
||||
``dont_autoretry_for``, so a deterministic content error fails once
|
||||
instead of retrying four times; and ``SimpleDirectoryReader.load_data``
|
||||
catches only ``DocumentParseError``, so one poison file is skipped into
|
||||
``failed_files`` instead of aborting an entire multi-file ingest.
|
||||
|
||||
Args:
|
||||
parser: The parser to delegate to; initialized here if needed.
|
||||
file: Path to the file being parsed.
|
||||
errors: Decoding error policy, forwarded to the parser.
|
||||
|
||||
Returns:
|
||||
The parsed text, or list of row strings.
|
||||
|
||||
Raises:
|
||||
DocumentParseError: If the fallback parser fails for any reason.
|
||||
"""
|
||||
try:
|
||||
if not parser.parser_config_set:
|
||||
parser.init_parser()
|
||||
return parser.parse_file(file, errors)
|
||||
except DocumentParseError:
|
||||
raise
|
||||
except ImportError:
|
||||
# A fallback whose dependency is missing is a deployment problem, not
|
||||
# a bad document: let it reach the ingest task's setup-error path
|
||||
# instead of blaming every file with "could not be read".
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Fallback parse of {Path(file).name} with "
|
||||
f"{type(parser).__name__} failed: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
raise DocumentParseError(
|
||||
f"Failed to parse {Path(file).name}: the file could not be read."
|
||||
) from e
|
||||
+288
-78
@@ -4,7 +4,7 @@ from pathlib import Path
|
||||
from typing import Callable, Dict, List, Optional, Tuple, Union
|
||||
|
||||
from application.parser.file.base import BaseReader
|
||||
from application.parser.file.base_parser import BaseParser, DocumentParseError
|
||||
from application.parser.file.base_parser import BaseParser, DocumentParseError, module_available
|
||||
from application.parser.file.docs_parser import DocxParser, PDFParser
|
||||
from application.parser.file.epub_parser import EpubParser
|
||||
from application.parser.file.html_parser import HTMLParser
|
||||
@@ -49,28 +49,140 @@ def _wrap_pdf_fast_path(pdf_parser: BaseParser) -> BaseParser:
|
||||
)
|
||||
|
||||
|
||||
def get_default_file_extractor(
|
||||
ocr_enabled: Optional[bool] = None,
|
||||
pdf_text_fast_path: bool = False,
|
||||
) -> Dict[str, BaseParser]:
|
||||
"""Get the default file extractor.
|
||||
def _gained_format_entries() -> Dict[str, BaseParser]:
|
||||
"""Anydoc-only formats (legacy/macro Office, OpenDocument, RTF) for every map.
|
||||
|
||||
Uses docling parsers by default for advanced document processing.
|
||||
Falls back to standard parsers if docling is not installed.
|
||||
anydoc is a core dependency, so these suffixes are parseable under both
|
||||
engines. When anydoc is somehow missing the entries are omitted and
|
||||
``SimpleDirectoryReader.load_data`` rejects such files with a
|
||||
``DocumentParseError`` naming the install, rather than reading OLE/zip
|
||||
bytes as text.
|
||||
"""
|
||||
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 _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:
|
||||
ocr_enabled: Enable OCR in the docling parsers. Defaults to
|
||||
``settings.DOCLING_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,
|
||||
which suits attachments (read into a prompt) but not source
|
||||
ingestion (chunked and embedded for retrieval).
|
||||
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 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.
|
||||
"""
|
||||
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:
|
||||
pdf_parser = _wrap_pdf_fast_path(pdf_parser)
|
||||
return {
|
||||
".pdf": pdf_parser,
|
||||
".docx": DocxParser(),
|
||||
".csv": PandasCSVParser(),
|
||||
".xlsx": ExcelParser(),
|
||||
".epub": EpubParser(),
|
||||
".md": MarkdownParser(),
|
||||
".rst": RstParser(),
|
||||
".html": HTMLParser(),
|
||||
".xhtml": HTMLParser(),
|
||||
".mdx": MarkdownParser(),
|
||||
".json": JSONParser(),
|
||||
".pptx": PPTXParser(),
|
||||
**images,
|
||||
**_build_audio_parser_mapping(),
|
||||
**_gained_format_entries(),
|
||||
}
|
||||
|
||||
|
||||
def _docling_file_extractor(
|
||||
ocr_enabled: bool,
|
||||
pdf_text_fast_path: bool = False,
|
||||
*,
|
||||
missing_log_level: int = logging.WARNING,
|
||||
) -> 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
|
||||
that handed out docling parsers on a docling-less install would abort
|
||||
the whole ingest on its first file instead of degrading.
|
||||
|
||||
Args:
|
||||
ocr_enabled: Enable OCR in the docling PDF/image parsers.
|
||||
pdf_text_fast_path: Put the pypdfium2 text-layer parser in front of
|
||||
the PDF parser (attachments only; see ``get_default_file_extractor``).
|
||||
missing_log_level: Level at which a missing docling install is logged;
|
||||
informational when docling is only the *fallback* engine.
|
||||
|
||||
Returns:
|
||||
Dict[str, BaseParser]: Parser keyed by lower-case file suffix.
|
||||
"""
|
||||
try:
|
||||
if not module_available("docling"):
|
||||
raise ImportError("docling is not installed")
|
||||
from application.parser.file.docling_parser import (
|
||||
DoclingPDFParser,
|
||||
DoclingDocxParser,
|
||||
@@ -83,71 +195,160 @@ def get_default_file_extractor(
|
||||
DoclingVTTParser,
|
||||
DoclingXMLParser,
|
||||
)
|
||||
if ocr_enabled is None:
|
||||
ocr_enabled = settings.DOCLING_OCR_ENABLED
|
||||
pdf_parser: BaseParser = DoclingPDFParser(ocr_enabled=ocr_enabled)
|
||||
if pdf_text_fast_path:
|
||||
pdf_parser = _wrap_pdf_fast_path(pdf_parser)
|
||||
return {
|
||||
# Documents
|
||||
".pdf": pdf_parser,
|
||||
".docx": DoclingDocxParser(),
|
||||
".pptx": DoclingPPTXParser(),
|
||||
".xlsx": DoclingXLSXParser(),
|
||||
# Web formats
|
||||
".html": DoclingHTMLParser(),
|
||||
".xhtml": DoclingHTMLParser(),
|
||||
# Data formats
|
||||
".csv": DoclingCSVParser(),
|
||||
".json": JSONParser(), # Keep JSON parser (specialized handling)
|
||||
# Text/markup formats
|
||||
".md": MarkdownParser(), # Keep markdown parser (specialized handling)
|
||||
".mdx": MarkdownParser(),
|
||||
".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(),
|
||||
# Media/subtitles
|
||||
".vtt": DoclingVTTParser(),
|
||||
**_build_audio_parser_mapping(),
|
||||
# Specialized XML formats
|
||||
".xml": DoclingXMLParser(),
|
||||
# Formats docling doesn't support - use standard parsers
|
||||
".epub": EpubParser(),
|
||||
}
|
||||
except ImportError:
|
||||
logging.warning(
|
||||
"docling is not installed. Using standard parsers. "
|
||||
"For advanced document parsing, install with: pip install docling"
|
||||
logging.log(
|
||||
missing_log_level,
|
||||
"docling is not installed. Using standard parsers%s. For layout-model "
|
||||
"parsing, install with: pip install -r application/requirements-docling.txt",
|
||||
" with native OCR" if ocr_enabled else "",
|
||||
)
|
||||
# Fallback to standard parsers
|
||||
legacy_pdf: BaseParser = PDFParser()
|
||||
if pdf_text_fast_path:
|
||||
legacy_pdf = _wrap_pdf_fast_path(legacy_pdf)
|
||||
return {
|
||||
".pdf": legacy_pdf,
|
||||
".docx": DocxParser(),
|
||||
".csv": PandasCSVParser(),
|
||||
".xlsx": ExcelParser(),
|
||||
".epub": EpubParser(),
|
||||
".md": MarkdownParser(),
|
||||
".rst": RstParser(),
|
||||
".html": HTMLParser(),
|
||||
".mdx": MarkdownParser(),
|
||||
".json": JSONParser(),
|
||||
".pptx": PPTXParser(),
|
||||
".png": ImageParser(),
|
||||
".jpg": ImageParser(),
|
||||
".jpeg": ImageParser(),
|
||||
**_build_audio_parser_mapping(),
|
||||
}
|
||||
return _legacy_file_extractor(pdf_text_fast_path, 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 {
|
||||
# Documents
|
||||
".pdf": pdf_parser,
|
||||
".docx": DoclingDocxParser(),
|
||||
".pptx": DoclingPPTXParser(),
|
||||
".xlsx": DoclingXLSXParser(),
|
||||
# Web formats
|
||||
".html": DoclingHTMLParser(),
|
||||
".xhtml": DoclingHTMLParser(),
|
||||
# Data formats
|
||||
".csv": DoclingCSVParser(),
|
||||
".json": JSONParser(), # Keep JSON parser (specialized handling)
|
||||
# Text/markup formats
|
||||
".md": MarkdownParser(), # Keep markdown parser (specialized handling)
|
||||
".mdx": MarkdownParser(),
|
||||
".rst": RstParser(),
|
||||
".adoc": DoclingAsciiDocParser(),
|
||||
".asciidoc": DoclingAsciiDocParser(),
|
||||
# Images: OCR'd by the selected backend when OCR is on, else ImageParser
|
||||
**images,
|
||||
# Media/subtitles
|
||||
".vtt": DoclingVTTParser(),
|
||||
**_build_audio_parser_mapping(),
|
||||
# Specialized XML formats
|
||||
".xml": DoclingXMLParser(),
|
||||
# Formats docling doesn't support - use standard parsers
|
||||
".epub": EpubParser(),
|
||||
# Formats only anydoc reads (legacy/macro Office, OpenDocument, RTF)
|
||||
**_gained_format_entries(),
|
||||
}
|
||||
|
||||
|
||||
def _anydoc_file_extractor(ocr_enabled: bool, pdf_text_fast_path: bool = False) -> Dict[str, BaseParser]:
|
||||
"""anydoc parser map, layered over the docling (or legacy) map.
|
||||
|
||||
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 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
|
||||
own scanned-PDF detection, so putting pypdfium2 in front of it would
|
||||
only lose structure.
|
||||
|
||||
Args:
|
||||
ocr_enabled: Enable OCR in the docling fallback/image parsers.
|
||||
pdf_text_fast_path: Honored only when anydoc is not installed and the
|
||||
map degrades to the base engine.
|
||||
|
||||
Returns:
|
||||
Dict[str, BaseParser]: Parser keyed by lower-case file suffix.
|
||||
"""
|
||||
from application.parser.file.anydoc_parser import (
|
||||
ANYDOC_SUFFIXES,
|
||||
AnydocParser,
|
||||
anydoc_available,
|
||||
)
|
||||
from application.parser.file.html_parser import HTMLMarkdownParser
|
||||
|
||||
if not anydoc_available():
|
||||
logging.warning(
|
||||
"firecrawl-anydoc is not installed; DOC_PARSER_ENGINE=anydoc is using "
|
||||
"the %s parsers instead. Install with: pip install firecrawl-anydoc",
|
||||
"docling" if module_available("docling") else "standard",
|
||||
)
|
||||
return _docling_file_extractor(ocr_enabled, pdf_text_fast_path)
|
||||
|
||||
base = _docling_file_extractor(
|
||||
ocr_enabled, pdf_text_fast_path=False, missing_log_level=logging.INFO
|
||||
)
|
||||
extractor = dict(base)
|
||||
for suffix in ANYDOC_SUFFIXES:
|
||||
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
|
||||
|
||||
|
||||
def get_default_file_extractor(
|
||||
ocr_enabled: Optional[bool] = None,
|
||||
pdf_text_fast_path: bool = False,
|
||||
engine: Optional[str] = None,
|
||||
) -> Dict[str, BaseParser]:
|
||||
"""Get the default file extractor.
|
||||
|
||||
The engine is ``settings.DOC_PARSER_ENGINE`` unless overridden:
|
||||
|
||||
* ``anydoc`` (default): firecrawl-anydoc for office/PDF/tabular formats,
|
||||
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 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,
|
||||
which suits attachments (read into a prompt) but not source
|
||||
ingestion (chunked and embedded for retrieval). A no-op under the
|
||||
anydoc engine, which is already that fast and keeps structure.
|
||||
engine: ``"anydoc"`` or ``"docling"``; ``None`` reads the setting.
|
||||
|
||||
Returns:
|
||||
Dict[str, BaseParser]: Parser keyed by lower-case file suffix.
|
||||
"""
|
||||
if ocr_enabled is None:
|
||||
ocr_enabled = settings.OCR_ENABLED
|
||||
selected = (engine or getattr(settings, "DOC_PARSER_ENGINE", None) or "anydoc")
|
||||
selected = str(selected).strip().lower()
|
||||
if selected == "docling":
|
||||
return _docling_file_extractor(ocr_enabled, pdf_text_fast_path)
|
||||
if selected != "anydoc":
|
||||
logging.warning("Unknown DOC_PARSER_ENGINE %r; using anydoc", selected)
|
||||
return _anydoc_file_extractor(ocr_enabled, pdf_text_fast_path)
|
||||
|
||||
|
||||
# For backwards compatibility
|
||||
@@ -311,6 +512,15 @@ class SimpleDirectoryReader(BaseReader):
|
||||
data = parser.parse_file(input_file, errors=self.errors)
|
||||
parser_metadata = parser.get_file_metadata(input_file)
|
||||
else:
|
||||
from application.parser.file.anydoc_parser import ANYDOC_GAINED_SUFFIXES
|
||||
|
||||
if suffix_lower in ANYDOC_GAINED_SUFFIXES:
|
||||
# Binary office formats only anydoc reads; without it
|
||||
# the standard read would index OLE/zip bytes as text.
|
||||
raise DocumentParseError(
|
||||
f"No parser is available for {suffix_lower} files: "
|
||||
"firecrawl-anydoc is not installed (pip install firecrawl-anydoc)"
|
||||
)
|
||||
# do standard read
|
||||
with open(input_file, "r", errors=self.errors) as f:
|
||||
data = f.read()
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import os
|
||||
|
||||
from application.parser.file.anydoc_parser import ANYDOC_GAINED_SUFFIXES
|
||||
from application.stt.constants import SUPPORTED_AUDIO_EXTENSIONS
|
||||
|
||||
|
||||
@@ -18,6 +19,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")
|
||||
|
||||
@@ -10,12 +10,20 @@ import importlib.util
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple, Union
|
||||
|
||||
from application.parser.file.base_parser import BaseParser, DocumentParseError
|
||||
from application.parser.file.base_parser import (
|
||||
BaseParser,
|
||||
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__)
|
||||
@@ -67,6 +75,131 @@ def _apply_inference_settings() -> None:
|
||||
inference.compile_torch_models = settings.DOCLING_COMPILE_TORCH_MODELS
|
||||
|
||||
|
||||
|
||||
|
||||
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: Docker builds need "
|
||||
"INSTALL_TESSERACT=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
|
||||
|
||||
from application.parser.file.ocr_parser import tesseract_languages
|
||||
|
||||
langs = (
|
||||
languages
|
||||
or [lang.strip() for lang in settings.OCR_LANGS.split("+") if lang.strip()]
|
||||
or ["eng"]
|
||||
)
|
||||
# docling swallows tesseract's per-region failures: a language list
|
||||
# with no installed pack logs an error per region and exports ''
|
||||
# which the dropout guard then indexes as "text-sparse". Drop the
|
||||
# packs that are not installed up front and say so.
|
||||
installed = tesseract_languages()
|
||||
if installed is not None:
|
||||
missing = [lang for lang in langs if lang not in installed]
|
||||
if missing:
|
||||
kept = [lang for lang in langs if lang in installed]
|
||||
if not kept and "eng" in installed:
|
||||
kept = ["eng"]
|
||||
logger.warning(
|
||||
"OCR_LANGS lists tesseract language pack(s) that are not installed: %s; "
|
||||
"OCR-ing with %s (install tesseract-ocr-<lang> for each missing pack)",
|
||||
"+".join(missing),
|
||||
"+".join(kept) if kept else "the requested list anyway",
|
||||
)
|
||||
langs = kept or langs
|
||||
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.
|
||||
|
||||
@@ -118,46 +251,6 @@ def _exceeds_tabular_gate(file: Path) -> bool:
|
||||
return _tabular_content_size(file) > max_bytes
|
||||
|
||||
|
||||
def _delegate(
|
||||
parser: BaseParser, file: Path, errors: str
|
||||
) -> Union[str, List[str]]:
|
||||
"""Run a lightweight fallback parser, normalizing its failures.
|
||||
|
||||
Anything the fallback raises is converted to ``DocumentParseError``,
|
||||
which matters twice over. The Celery tasks that own these paths list
|
||||
``DocumentParseError`` in ``dont_autoretry_for``, so a deterministic
|
||||
content error now fails once instead of retrying four times (each retry
|
||||
re-downloading the file from S3); and ``SimpleDirectoryReader.load_data``
|
||||
catches only ``DocumentParseError``, so one poison spreadsheet is skipped
|
||||
into ``failed_files`` instead of aborting an entire multi-file ingest.
|
||||
|
||||
This restores the symmetry with ``DoclingParser.parse_file``, whose own
|
||||
failures are already wrapped — the delegation branch bypassed that.
|
||||
|
||||
Args:
|
||||
parser: The lightweight parser to delegate to.
|
||||
file: Path to the file being parsed.
|
||||
errors: Decoding error policy, forwarded to the parser.
|
||||
|
||||
Returns:
|
||||
The parsed text, or list of row strings.
|
||||
|
||||
Raises:
|
||||
DocumentParseError: If the fallback parser fails for any reason.
|
||||
"""
|
||||
try:
|
||||
return parser.parse_file(file, errors)
|
||||
except DocumentParseError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Lightweight parse of {file.name} failed: {e}", exc_info=True
|
||||
)
|
||||
raise DocumentParseError(
|
||||
f"Failed to parse {file.name}: the file could not be read."
|
||||
) from e
|
||||
|
||||
|
||||
def _capped_markup_copy(file: Path) -> Optional[str]:
|
||||
"""Head-truncate an oversized markup file to a temp copy for docling.
|
||||
|
||||
@@ -242,7 +335,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,
|
||||
)
|
||||
)
|
||||
@@ -339,7 +432,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
|
||||
|
||||
@@ -353,7 +446,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,
|
||||
):
|
||||
@@ -363,29 +456,36 @@ 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
|
||||
# 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):
|
||||
"""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
|
||||
@@ -400,6 +500,11 @@ 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()
|
||||
|
||||
pipeline_options = PdfPipelineOptions(
|
||||
do_ocr=self.ocr_enabled,
|
||||
do_table_structure=self.table_structure,
|
||||
@@ -407,13 +512,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
|
||||
@@ -429,17 +536,64 @@ 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
|
||||
# docling's spec default is 90 s per request, which a laptop-hosted
|
||||
# 3B model overruns; honour the same per-page budget the native
|
||||
# backend gives the endpoint.
|
||||
vlm_options.timeout = float(settings.OCR_DEEPSEEK_TIMEOUT)
|
||||
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(
|
||||
"docling is required for DoclingParser. "
|
||||
"Install it with: pip install docling"
|
||||
"Install it with: pip install -r application/requirements-docling.txt"
|
||||
)
|
||||
|
||||
# Create converter with hybrid OCR (smart: text direct, bitmaps OCR'd)
|
||||
@@ -450,34 +604,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.
|
||||
|
||||
@@ -505,8 +636,25 @@ 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. Only the PDF
|
||||
and image parsers construct with OCR on — the office/markup parsers
|
||||
never OCR, so their born-digital text must not be rewritten. On a
|
||||
hybrid-OCR PDF the collapse still covers the whole export, native
|
||||
text included; deliberate spaces inside CJK runs are rare enough
|
||||
there that indexing scans correctly wins.
|
||||
"""
|
||||
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:
|
||||
@@ -687,7 +835,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)
|
||||
@@ -700,7 +848,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,
|
||||
):
|
||||
@@ -708,31 +856,60 @@ 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,
|
||||
)
|
||||
|
||||
|
||||
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."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(export_format="markdown")
|
||||
super().__init__(ocr_enabled=False, export_format="markdown")
|
||||
|
||||
|
||||
class DoclingPPTXParser(DoclingParser):
|
||||
"""Docling-based PPTX parser."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(export_format="markdown")
|
||||
super().__init__(ocr_enabled=False, export_format="markdown")
|
||||
|
||||
|
||||
class DoclingXLSXParser(DoclingParser):
|
||||
"""Docling-based XLSX parser with table structure."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(table_structure=True, export_format="markdown")
|
||||
super().__init__(ocr_enabled=False, table_structure=True, export_format="markdown")
|
||||
|
||||
def parse_file(self, file: Path, errors: str = "ignore") -> Union[str, List[str]]:
|
||||
"""Parse an XLSX file, delegating oversized files to ``ExcelParser``."""
|
||||
@@ -751,7 +928,7 @@ class DoclingHTMLParser(DoclingParser):
|
||||
"""Docling-based HTML parser."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(export_format="markdown")
|
||||
super().__init__(ocr_enabled=False, export_format="markdown")
|
||||
|
||||
def parse_file(self, file: Path, errors: str = "ignore") -> Union[str, List[str]]:
|
||||
"""Parse HTML, truncating oversized element-dense markup first."""
|
||||
@@ -759,7 +936,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.
|
||||
@@ -768,14 +945,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,
|
||||
)
|
||||
@@ -785,7 +962,7 @@ class DoclingCSVParser(DoclingParser):
|
||||
"""Docling-based CSV parser."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(table_structure=True, export_format="markdown")
|
||||
super().__init__(ocr_enabled=False, table_structure=True, export_format="markdown")
|
||||
|
||||
def parse_file(self, file: Path, errors: str = "ignore") -> Union[str, List[str]]:
|
||||
"""Parse a CSV file, delegating oversized files to the plain ``CSVParser``."""
|
||||
@@ -804,21 +981,21 @@ class DoclingMarkdownParser(DoclingParser):
|
||||
"""Docling-based Markdown parser."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(export_format="markdown")
|
||||
super().__init__(ocr_enabled=False, export_format="markdown")
|
||||
|
||||
|
||||
class DoclingAsciiDocParser(DoclingParser):
|
||||
"""Docling-based AsciiDoc parser."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(export_format="markdown")
|
||||
super().__init__(ocr_enabled=False, export_format="markdown")
|
||||
|
||||
|
||||
class DoclingVTTParser(DoclingParser):
|
||||
"""Docling-based WebVTT (video text tracks) parser."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(export_format="markdown")
|
||||
super().__init__(ocr_enabled=False, export_format="markdown")
|
||||
|
||||
def parse_file(self, file: Path, errors: str = "ignore") -> Union[str, List[str]]:
|
||||
"""Parse WebVTT, truncating oversized cue-dense tracks first."""
|
||||
@@ -829,4 +1006,4 @@ class DoclingXMLParser(DoclingParser):
|
||||
"""Docling-based XML parser (USPTO, JATS)."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(export_format="markdown")
|
||||
super().__init__(ocr_enabled=False, export_format="markdown")
|
||||
@@ -1,12 +1,160 @@
|
||||
"""HTML parser.
|
||||
|
||||
Contains parser for html files.
|
||||
"""HTML parsers.
|
||||
|
||||
``HTMLParser`` returns the visible text of a page (the ``fast`` engine and
|
||||
the legacy no-docling map rely on that plain-text contract).
|
||||
``HTMLMarkdownParser`` returns Markdown instead and is what the anydoc
|
||||
engine maps ``.html``/``.xhtml`` to: anydoc has no HTML support, and
|
||||
markdownify — already a dependency of the web crawler — keeps links, GFM
|
||||
tables, headings and byte-exact code blocks at a few ms per page.
|
||||
"""
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Dict, Union
|
||||
from typing import Dict, Optional, Tuple, Union
|
||||
|
||||
from application.core.settings import settings
|
||||
from application.parser.file.base_parser import BaseParser
|
||||
from application.utils import truncate_to_line_boundary
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# The crawler's conventions (``crawler_markdown.py`` / ``read_webpage.py``),
|
||||
# so file and web ingestion produce the same Markdown shape.
|
||||
MARKDOWNIFY_OPTIONS = {"heading_style": "ATX", "newline_style": "BACKSLASH"}
|
||||
|
||||
# Elements whose text is never document content. ``title`` is reported via
|
||||
# ``get_file_metadata`` instead of leaking in as a stray first line.
|
||||
_DROP_TAGS = ("title", "script", "style", "noscript", "template")
|
||||
# Attributes whose ``data:`` URIs would otherwise land in the Markdown as
|
||||
# link/image targets: one inline image is megabytes of base64 "text" for the
|
||||
# chunker and embedder (a 5 MB data-URI ``<img>`` measured 7 MB of output).
|
||||
_URI_ATTRIBUTES = ("src", "href", "srcset", "poster", "data")
|
||||
|
||||
|
||||
def html_to_markdown(html: Union[str, bytes]) -> str:
|
||||
"""Convert an HTML/XHTML document to Markdown.
|
||||
|
||||
Args:
|
||||
html: The markup. Pass the raw bytes when you have them: BeautifulSoup
|
||||
then decodes by the document's own BOM / ``<meta charset>``
|
||||
instead of the process locale, so a windows-1250 page keeps its
|
||||
diacritics.
|
||||
|
||||
Returns:
|
||||
Markdown with runs of blank lines collapsed to one.
|
||||
"""
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
return soup_to_markdown(BeautifulSoup(html, "html.parser"))
|
||||
|
||||
|
||||
def soup_to_markdown(soup) -> str:
|
||||
"""Convert a parsed ``BeautifulSoup`` tree to Markdown.
|
||||
|
||||
Destructive: the non-content elements in ``_DROP_TAGS`` (including
|
||||
``<title>``) are removed from ``soup``; read the title first.
|
||||
|
||||
Args:
|
||||
soup: The parsed document.
|
||||
|
||||
Returns:
|
||||
Markdown with runs of blank lines collapsed to one.
|
||||
"""
|
||||
from bs4 import CData, Declaration, ProcessingInstruction
|
||||
from markdownify import MarkdownConverter
|
||||
|
||||
for tag in soup.find_all(_DROP_TAGS):
|
||||
tag.decompose()
|
||||
# ``<?xml ...?>`` (every XHTML file), CDATA and stray declarations are
|
||||
# not text; markdownify would emit them as the document's first line.
|
||||
for node in soup.find_all(string=lambda s: isinstance(s, (ProcessingInstruction, CData, Declaration))):
|
||||
node.extract()
|
||||
for tag in soup.find_all(True):
|
||||
for attribute in _URI_ATTRIBUTES:
|
||||
value = tag.get(attribute)
|
||||
if isinstance(value, list):
|
||||
value = " ".join(value)
|
||||
if isinstance(value, str) and "data:" in value.lower():
|
||||
del tag[attribute]
|
||||
markdown = MarkdownConverter(**MARKDOWNIFY_OPTIONS).convert_soup(soup)
|
||||
return re.sub(r"\n{3,}", "\n\n", markdown).strip()
|
||||
|
||||
|
||||
def _soup_title(soup) -> Optional[str]:
|
||||
"""The document's ``<title>`` text, or None."""
|
||||
if soup.title and soup.title.string:
|
||||
return str(soup.title.string)
|
||||
return None
|
||||
|
||||
|
||||
def _trim_torn_utf8_tail(data: bytes) -> bytes:
|
||||
"""Drop a trailing partial multi-byte UTF-8 sequence left by a byte cut.
|
||||
|
||||
A cut that lands inside a multi-byte character matters more than one
|
||||
lost glyph: BeautifulSoup decodes strictly and, when the declared UTF-8
|
||||
fails on the torn tail, retries as windows-1252 — and if every other
|
||||
non-ASCII byte happens to be cp1252-valid (German umlauts are), the
|
||||
*whole* document comes back as mojibake. Only a UTF-8 tail is
|
||||
recognised; a single-byte page loses at most the final byte.
|
||||
|
||||
Args:
|
||||
data: A head window whose last bytes may be a torn sequence.
|
||||
|
||||
Returns:
|
||||
``data`` minus any trailing incomplete UTF-8 sequence.
|
||||
"""
|
||||
end = len(data)
|
||||
trailing = 0
|
||||
while end > 0 and trailing < 3 and 0x80 <= data[end - 1] < 0xC0:
|
||||
end -= 1
|
||||
trailing += 1
|
||||
if end == 0:
|
||||
return data
|
||||
lead = data[end - 1]
|
||||
if lead < 0xC0:
|
||||
return data
|
||||
need = 2 if lead < 0xE0 else 3 if lead < 0xF0 else 4
|
||||
if trailing + 1 < need:
|
||||
return data[: end - 1]
|
||||
return data
|
||||
|
||||
|
||||
def read_markup_head(file: Path, max_bytes: int) -> bytes:
|
||||
"""Read a markup file, head-truncated to ``max_bytes`` on a line boundary.
|
||||
|
||||
HTML has no lightweight fallback parser to bound it by other means (the
|
||||
way oversized CSV/XLSX go to the plain tabular parsers), and the soup +
|
||||
markdownify tree costs ~50x the input — 30 MB measured at 1.6 GB RSS,
|
||||
against a 100 MB upload cap. Parsing only the head keeps one upload from
|
||||
taking the ingest worker down; the cut lands on a line boundary when one
|
||||
is reasonably close, and the lenient HTML parser copes with the torn tail.
|
||||
|
||||
Args:
|
||||
file: Path to the markup file.
|
||||
max_bytes: Size gate; ``<= 0`` reads the whole file.
|
||||
|
||||
Returns:
|
||||
The file's bytes, or its first ``max_bytes`` when it is larger.
|
||||
"""
|
||||
with open(file, "rb") as fh:
|
||||
if max_bytes <= 0:
|
||||
return fh.read()
|
||||
head = fh.read(max_bytes + 1)
|
||||
if len(head) <= max_bytes:
|
||||
return head
|
||||
logger.warning(
|
||||
f"Markup {Path(file).name} exceeds MARKUP_MAX_BYTES ({max_bytes}); "
|
||||
f"parsing the first {max_bytes} bytes to bound memory"
|
||||
)
|
||||
head = head[:max_bytes]
|
||||
if head[:2] in (b"\xff\xfe", b"\xfe\xff"):
|
||||
# UTF-16 (BOM-declared): a byte-level line cut lands between the two
|
||||
# bytes of a code unit and the strict decode then fails outright, so
|
||||
# keep an even byte count and let the lenient parser take the torn
|
||||
# tail. (Only 2-byte units matter here; a torn surrogate pair is one
|
||||
# lost character.)
|
||||
return head[: len(head) - (len(head) % 2)]
|
||||
return _trim_torn_utf8_tail(truncate_to_line_boundary(head))
|
||||
|
||||
|
||||
class HTMLParser(BaseParser):
|
||||
@@ -40,3 +188,51 @@ class HTMLParser(BaseParser):
|
||||
if soup.title and soup.title.string:
|
||||
return {"title": str(soup.title.string)}
|
||||
return {}
|
||||
|
||||
|
||||
class HTMLMarkdownParser(HTMLParser):
|
||||
"""HTML/XHTML to Markdown, for chunking and retrieval.
|
||||
|
||||
Differs from ``HTMLParser`` in three ways: the body comes back as
|
||||
Markdown; the file is read as bytes so the document's own charset
|
||||
declaration wins over the process locale; and input is head-truncated at
|
||||
``MARKUP_MAX_BYTES`` (the docling HTML path has the same gate under
|
||||
``DOCLING_MARKUP_MAX_BYTES``).
|
||||
"""
|
||||
|
||||
def __init__(self, parser_config: Optional[Dict] = None) -> None:
|
||||
super().__init__(parser_config)
|
||||
# (path, title) of the most recent ``parse_file``, so the metadata
|
||||
# call that follows it does not build the soup a second time.
|
||||
self._last_title: Optional[Tuple[Path, Optional[str]]] = None
|
||||
|
||||
@staticmethod
|
||||
def _read(file: Path) -> bytes:
|
||||
return read_markup_head(Path(file), int(settings.MARKUP_MAX_BYTES))
|
||||
|
||||
def parse_file(self, file: Path, errors: str = "ignore") -> Union[str, list[str]]:
|
||||
"""Convert an HTML file to Markdown.
|
||||
|
||||
``errors`` is accepted for the ``BaseParser`` contract but unused:
|
||||
decoding is done by BeautifulSoup from the raw bytes.
|
||||
"""
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
_ = errors
|
||||
soup = BeautifulSoup(self._read(file), "html.parser")
|
||||
self._last_title = (Path(file), _soup_title(soup))
|
||||
return soup_to_markdown(soup)
|
||||
|
||||
def get_file_metadata(self, file: Path) -> Dict:
|
||||
"""Return the document title, decoded the same way as the body."""
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
last = self._last_title
|
||||
if last is not None and last[0] == Path(file):
|
||||
title = last[1]
|
||||
else:
|
||||
try:
|
||||
title = _soup_title(BeautifulSoup(self._read(file), "html.parser"))
|
||||
except OSError:
|
||||
return {}
|
||||
return {"title": title} if title else {}
|
||||
@@ -0,0 +1,872 @@
|
||||
"""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 functools
|
||||
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
|
||||
# Pixel budget for one rendered page. pypdfium2 allocates width*height*4
|
||||
# bytes up front with no cap of its own, so a 14400x14400 pt page (the PDF
|
||||
# maximum; a 191-byte file) at 200 dpi would ask for 40000x40000 px = 6 GiB.
|
||||
# 40 MP is ~10x a Letter page at 200 dpi and still OCRs an A0 poster at a
|
||||
# usable ~125 dpi; anything larger renders at the scale that fits.
|
||||
_MAX_RENDER_PIXELS = 40_000_000
|
||||
_TESSERACT_TIMEOUT_SECONDS = 300
|
||||
# tesseract reports a missing language pack on stderr and, when at least one
|
||||
# other requested pack loads, exits 0 and silently OCRs with what it has —
|
||||
# so the exit code alone cannot catch OCR_LANGS=eng+chi_sim without chi_sim.
|
||||
_TESSERACT_LANG_ERROR_RE = re.compile(r"Error opening data file|Failed loading language")
|
||||
_TESSERACT_LANG_RE = re.compile(r"^[A-Za-z0-9_/\-]+$")
|
||||
# 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 "
|
||||
"application/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 _has_alpha(image) -> bool:
|
||||
return "A" in image.getbands() or (image.mode == "P" and "transparency" in image.info)
|
||||
|
||||
|
||||
def _png_bytes(image) -> bytes:
|
||||
"""Encode a PIL image as PNG, flattening modes the engines cannot take.
|
||||
|
||||
Transparency is composited onto white: a plain ``convert("RGB")`` drops
|
||||
the alpha channel and leaves transparent pixels black, which turns dark
|
||||
text on a transparent background into an all-black page that OCRs to
|
||||
nothing.
|
||||
"""
|
||||
from PIL import Image
|
||||
|
||||
if _has_alpha(image):
|
||||
rgba = image.convert("RGBA")
|
||||
canvas = Image.new("RGB", rgba.size, "white")
|
||||
canvas.paste(rgba, mask=rgba.getchannel("A"))
|
||||
image = canvas
|
||||
elif image.mode not in ("RGB", "L"):
|
||||
image = image.convert("RGB")
|
||||
buffer = io.BytesIO()
|
||||
image.save(buffer, format="PNG")
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def fit_to_pixel_budget(image):
|
||||
"""Downscale a decoded image to ``_MAX_RENDER_PIXELS`` before OCR.
|
||||
|
||||
The same budget the PDF renderer applies: a 28 KB PNG can decode to
|
||||
81 MP (Pillow's bomb guard only trips at ~178 MP), and every extra
|
||||
pixel is paid again in the PNG re-encode and inside tesseract.
|
||||
"""
|
||||
from PIL import Image
|
||||
|
||||
width, height = image.size
|
||||
scale = render_scale(width, height, 72)
|
||||
if scale >= 1.0:
|
||||
return image
|
||||
if image.mode not in ("RGB", "L", "RGBA", "LA"):
|
||||
image = image.convert("RGBA" if _has_alpha(image) else "RGB")
|
||||
logger.warning(
|
||||
"Image is %dx%d px; downscaling to %.0f%% to stay within %d MP before OCR",
|
||||
width,
|
||||
height,
|
||||
scale * 100,
|
||||
_MAX_RENDER_PIXELS // 1_000_000,
|
||||
)
|
||||
return image.resize(
|
||||
(max(1, int(width * scale)), max(1, int(height * scale))), Image.Resampling.BILINEAR
|
||||
)
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
def tesseract_languages() -> Optional[frozenset]:
|
||||
"""Language packs the ``tesseract`` binary reports (``--list-langs``), or None when unknown.
|
||||
|
||||
Cached for the process: packs are installed with the image, not at runtime.
|
||||
"""
|
||||
if shutil.which("tesseract") is None:
|
||||
return None
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
["tesseract", "--list-langs"], capture_output=True, timeout=30, check=False
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return None
|
||||
output = completed.stdout.decode("utf-8", "replace") + "\n" + completed.stderr.decode("utf-8", "replace")
|
||||
langs = frozenset(
|
||||
line.strip() for line in output.splitlines() if _TESSERACT_LANG_RE.match(line.strip())
|
||||
)
|
||||
return langs or None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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 (Docker: build with "
|
||||
"INSTALL_TESSERACT=true; local: apt/brew install tesseract-ocr), "
|
||||
"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
|
||||
stderr = completed.stderr.decode("utf-8", "replace").strip()
|
||||
if _TESSERACT_LANG_ERROR_RE.search(stderr):
|
||||
# Exit code 0 here means tesseract dropped the missing pack and
|
||||
# OCR'd with the rest: Chinese scans would come out as garbage
|
||||
# with no signal. Fail the file and name the fix instead.
|
||||
raise OcrUnavailableError(
|
||||
f"tesseract could not load a language pack for OCR_LANGS={self._language_arg()!r}: "
|
||||
f"{stderr[:300]}. Install the tesseract-ocr-<lang> package for every language listed."
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
raise DocumentParseError(f"tesseract failed (exit {completed.returncode}): {stderr[:300]}")
|
||||
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.exceptions.JSONDecodeError as exc:
|
||||
# Subclasses RequestException too, so it must be caught first or a
|
||||
# proxy's HTML error page is reported as a connection failure.
|
||||
raise DocumentParseError(f"DeepSeek-OCR endpoint {self.url} returned a non-JSON body") from exc
|
||||
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
|
||||
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_scale(page_width_pt: float, page_height_pt: float, dpi: int) -> float:
|
||||
"""Render scale for a page of the given size at ``dpi``, capped by ``_MAX_RENDER_PIXELS``.
|
||||
|
||||
The cap is what stands between an oversized (or hostile) MediaBox and a
|
||||
multi-gigabyte bitmap allocation in the ingest worker.
|
||||
"""
|
||||
scale = dpi / 72.0
|
||||
area = max(1.0, float(page_width_pt)) * max(1.0, float(page_height_pt))
|
||||
max_scale = (_MAX_RENDER_PIXELS / area) ** 0.5
|
||||
return min(scale, max_scale)
|
||||
|
||||
|
||||
def _render_page(page, dpi: int):
|
||||
"""Render a pypdfium2 page to a PIL image at ``dpi``, within the pixel budget."""
|
||||
width_pt, height_pt = page.get_size()
|
||||
scale = render_scale(width_pt, height_pt, dpi)
|
||||
if scale < dpi / 72.0:
|
||||
logger.warning(
|
||||
"Page is %.0fx%.0f pt; rendering at %.0f dpi instead of %d to stay within %d MP",
|
||||
width_pt,
|
||||
height_pt,
|
||||
scale * 72.0,
|
||||
dpi,
|
||||
_MAX_RENDER_PIXELS // 1_000_000,
|
||||
)
|
||||
return page.render(scale=scale).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 _page_has_image(page) -> bool:
|
||||
"""Whether the page draws at least one image XObject (something OCR could read)."""
|
||||
import pypdfium2.raw as pdfium_c
|
||||
|
||||
try:
|
||||
# pypdfium2's default max_depth=2 misses images inside nested Form
|
||||
# XObjects, which print drivers and InDesign produce routinely.
|
||||
objects = page.get_objects(filter=(pdfium_c.FPDF_PAGEOBJ_IMAGE,), max_depth=16)
|
||||
return next(iter(objects), None) is not None
|
||||
except Exception: # noqa: BLE001 - treat an unreadable page as image-bearing
|
||||
return True
|
||||
|
||||
|
||||
def scanned_page_indices(path: Path, min_chars: int = DEFAULT_TEXT_LAYER_MIN_CHARS) -> List[int]:
|
||||
"""0-based indices of the pages that look scanned: no text layer, or a thin one over an image.
|
||||
|
||||
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. A page with no text at all always qualifies. A page with a
|
||||
thin text layer (under ``min_chars``: a Bates stamp on a scan, a page
|
||||
number under a full-page figure) qualifies only when it also draws an
|
||||
image — a cover page reading just "Annual Report 2025" has nothing to OCR,
|
||||
and OCR-ing it would duplicate text the converter already read. Returns []
|
||||
when the file cannot be probed — the caller then keeps what it has rather
|
||||
than failing a successful parse.
|
||||
"""
|
||||
import pypdfium2 as pdfium
|
||||
|
||||
floor = max(1, int(min_chars))
|
||||
indices: List[int] = []
|
||||
try:
|
||||
pdf = pdfium.PdfDocument(str(path))
|
||||
try:
|
||||
for index in range(len(pdf)):
|
||||
page = pdf[index]
|
||||
try:
|
||||
textpage = page.get_textpage()
|
||||
try:
|
||||
count = max(0, textpage.count_chars())
|
||||
finally:
|
||||
textpage.close()
|
||||
if count == 0 or (count < floor and _page_has_image(page)):
|
||||
indices.append(index)
|
||||
finally:
|
||||
page.close()
|
||||
finally:
|
||||
pdf.close()
|
||||
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 indices
|
||||
|
||||
|
||||
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 text_layer_delegate(self, file: Path) -> Optional[BaseParser]:
|
||||
"""The ``text_parser`` that ``parse_file`` would hand ``file`` to, or None.
|
||||
|
||||
None when there is no text parser, the file cannot be opened, or any
|
||||
page falls below ``min_text_chars`` (those are OCR'd here). Callers
|
||||
that want a by-product of the delegate's conversion (docling tables)
|
||||
can run it directly instead of converting the document twice.
|
||||
"""
|
||||
if self.text_parser is None:
|
||||
return None
|
||||
try:
|
||||
import pypdfium2 as pdfium
|
||||
|
||||
pdf = pdfium.PdfDocument(str(Path(file)))
|
||||
except Exception: # noqa: BLE001 - a probe; parse_file reports the real error
|
||||
return None
|
||||
try:
|
||||
counts = self._text_layer_counts(pdf)
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
finally:
|
||||
pdf.close()
|
||||
if counts and all(count >= self.min_text_chars for count in counts):
|
||||
return self.text_parser
|
||||
return None
|
||||
|
||||
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_bounded()
|
||||
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.
|
||||
|
||||
Only TIFF frames are pages (multi-page faxes and scans); every other
|
||||
multi-frame format is an animation (GIF, WebP, APNG) whose frames
|
||||
repeat one picture, so only the first is OCR'd — a 50-frame WebP
|
||||
would otherwise cost 50 tesseract runs and then be rejected as an
|
||||
empty multi-page document.
|
||||
|
||||
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:
|
||||
frames = ImageSequence.Iterator(image) if path.suffix.lower() in (".tif", ".tiff") else [image]
|
||||
for frame in frames:
|
||||
texts.append((engine.ocr_image(fit_to_pixel_budget(frame)) 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)
|
||||
@@ -0,0 +1,280 @@
|
||||
"""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, Optional, Union
|
||||
|
||||
_OBJ_HEADER = re.compile(rb"\d+\s+\d+\s+obj")
|
||||
_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_PROBLEM_PREFIX = "PDF declares CJK fonts"
|
||||
|
||||
_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 _stream_bodies(raw: bytes) -> Iterator[bytes]:
|
||||
"""The raw bytes between each ``stream`` / ``endstream`` keyword pair.
|
||||
|
||||
A ``find`` walk rather than a regex: ``stream\\r?\\n(.*?)\\r?\\nendstream``
|
||||
misses every stream whose data runs straight into ``endstream`` with no
|
||||
EOL (common in the wild), and when it misses one its lazy group scans on
|
||||
to the *next* stream's terminator — quadratic on large files (a 10 MB
|
||||
file measured 32 s, matching a fifth of its streams) and a merged,
|
||||
undecodable body for the rest.
|
||||
"""
|
||||
pos = 0
|
||||
while True:
|
||||
pos = raw.find(b"stream", pos)
|
||||
if pos < 0:
|
||||
return
|
||||
if raw[max(0, pos - 3):pos] == b"end":
|
||||
pos += 6
|
||||
continue
|
||||
start = pos + 6
|
||||
if raw[start:start + 2] == b"\r\n":
|
||||
start += 2
|
||||
elif raw[start:start + 1] in (b"\n", b"\r"):
|
||||
start += 1
|
||||
end = raw.find(b"endstream", start)
|
||||
if end < 0:
|
||||
return
|
||||
body = raw[start:end]
|
||||
if body.endswith(b"\r\n"):
|
||||
body = body[:-2]
|
||||
elif body.endswith((b"\n", b"\r")):
|
||||
body = body[:-1]
|
||||
pos = end + 9
|
||||
yield body
|
||||
|
||||
|
||||
def _object_bodies(raw: bytes) -> Iterator[bytes]:
|
||||
"""The bytes between each ``N G obj`` header and the next ``endobj``.
|
||||
|
||||
A forward walk for the same reason as ``_stream_bodies``: the regex
|
||||
``\\d+\\s+\\d+\\s+obj(.*?)endobj`` rescans to end-of-file for every header
|
||||
that has no ``endobj`` after it, so a malformed (or crafted) PDF with many
|
||||
such headers costs headers x size — 2000 orphan headers in 1 MB measured
|
||||
13.5 s, on the default upload path. Advancing past each ``endobj`` keeps
|
||||
the walk linear and yields the same bodies.
|
||||
"""
|
||||
pos = 0
|
||||
while True:
|
||||
header = _OBJ_HEADER.search(raw, pos)
|
||||
if header is None:
|
||||
return
|
||||
end = raw.find(b"endobj", header.end())
|
||||
if end < 0:
|
||||
return
|
||||
yield raw[header.end():end]
|
||||
pos = end + 6
|
||||
|
||||
|
||||
def _decompressed_streams(raw: bytes) -> Iterator[bytes]:
|
||||
"""Each Flate stream in ``raw`` that decompresses, one at a time, capped."""
|
||||
for body in _stream_bodies(raw):
|
||||
try:
|
||||
inflated = zlib.decompressobj().decompress(body, _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 body in _object_bodies(data):
|
||||
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"{_CJK_PROBLEM_PREFIX} but the extracted text contains only "
|
||||
f"{cjk} CJK character(s)"
|
||||
)
|
||||
return problems
|
||||
|
||||
|
||||
def _text_layer_cjk_chars(file: Path, up_to: int) -> Optional[int]:
|
||||
"""CJK characters in the text layer as pypdfium2 reads it, stopping at ``up_to``.
|
||||
|
||||
pdfium ships Adobe's predefined CMaps, so it is an independent reference
|
||||
for whether the document *has* CJK text to lose. None when pypdfium2 is
|
||||
unavailable or the file cannot be read that way.
|
||||
"""
|
||||
try:
|
||||
import pypdfium2 as pdfium
|
||||
except ImportError:
|
||||
return None
|
||||
try:
|
||||
pdf = pdfium.PdfDocument(str(file))
|
||||
except Exception: # noqa: BLE001 - a reference probe, not a parse
|
||||
return None
|
||||
count = 0
|
||||
try:
|
||||
for index in range(len(pdf)):
|
||||
page = pdf[index]
|
||||
try:
|
||||
textpage = page.get_textpage()
|
||||
try:
|
||||
text = textpage.get_text_bounded()
|
||||
finally:
|
||||
textpage.close()
|
||||
finally:
|
||||
page.close()
|
||||
count += _cjk_chars(text, up_to - count)
|
||||
if count >= up_to:
|
||||
break
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
finally:
|
||||
pdf.close()
|
||||
return count
|
||||
|
||||
|
||||
def verify_pdf_file(file: Path, markdown: str) -> List[str]:
|
||||
"""``verify_extraction`` for a file on disk; unreadable files trust the output.
|
||||
|
||||
The CJK cross-check is confirmed against pdfium's own text layer: a
|
||||
stray ``/Ordering (Japan1)`` in an otherwise Latin document (a 48-page
|
||||
Quartz export in the corpus carries one among 258 Identity fonts) is not
|
||||
a dropped Chinese column, and must not send the file to a full docling
|
||||
re-parse.
|
||||
"""
|
||||
try:
|
||||
data = Path(file).read_bytes()
|
||||
except OSError:
|
||||
return []
|
||||
problems = verify_extraction(data, markdown)
|
||||
if any(problem.startswith(_CJK_PROBLEM_PREFIX) for problem in problems):
|
||||
reference = _text_layer_cjk_chars(Path(file), _MIN_CJK_CHARS)
|
||||
if reference is not None and reference < _MIN_CJK_CHARS:
|
||||
problems = [p for p in problems if not p.startswith(_CJK_PROBLEM_PREFIX)]
|
||||
return problems
|
||||
@@ -0,0 +1,105 @@
|
||||
"""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.
|
||||
|
||||
Without dot leaders a row needs at least two numeric columns: a single
|
||||
trailing number is what "Chapter 1", "Footnote 2", "ISO 9001" and
|
||||
"Version 3.0" look like, and three of those in a row are a list, not a
|
||||
table.
|
||||
"""
|
||||
leader = _LEADER.match(line)
|
||||
match = leader 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 leader is None and len(values) < 2:
|
||||
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("")
|
||||
# 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);
|
||||
# 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)
|
||||
@@ -110,8 +110,9 @@ class CrawlerLoader(BaseRemote):
|
||||
language = language_tag.get('lang', 'en') if language_tag else "en"
|
||||
|
||||
markdownified = markdownify(html_content, heading_style="ATX", newline_style="BACKSLASH")
|
||||
# Reduce sequences of more than two newlines to exactly three
|
||||
markdownified = re.sub(r'\n{3,}', '\n\n\n', markdownified)
|
||||
# Collapse runs of blank lines to a single one — the same shape
|
||||
# ``html_to_markdown`` gives uploaded HTML files.
|
||||
markdownified = re.sub(r'\n{3,}', '\n\n', markdownified)
|
||||
return title, language, markdownified
|
||||
|
||||
def _extract_links(self, html_content, current_url):
|
||||
|
||||
@@ -5,6 +5,7 @@ import tempfile
|
||||
import mimetypes
|
||||
from typing import List, Optional
|
||||
from application.core.url_validation import SSRFError, validate_url
|
||||
from application.parser.file.constants import SUPPORTED_SOURCE_DOCUMENT_EXTENSIONS
|
||||
from application.parser.remote.base import BaseRemote
|
||||
from application.parser.schema.base import Document
|
||||
|
||||
@@ -17,6 +18,61 @@ except ImportError:
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Plain-text suffixes the loader reads directly (no document parser).
|
||||
_TEXT_EXTENSIONS = {
|
||||
".txt",
|
||||
".md",
|
||||
".markdown",
|
||||
".rst",
|
||||
".json",
|
||||
".xml",
|
||||
".yaml",
|
||||
".yml",
|
||||
".py",
|
||||
".js",
|
||||
".ts",
|
||||
".jsx",
|
||||
".tsx",
|
||||
".java",
|
||||
".c",
|
||||
".cpp",
|
||||
".h",
|
||||
".hpp",
|
||||
".cs",
|
||||
".go",
|
||||
".rs",
|
||||
".rb",
|
||||
".php",
|
||||
".swift",
|
||||
".kt",
|
||||
".scala",
|
||||
".html",
|
||||
".css",
|
||||
".scss",
|
||||
".sass",
|
||||
".less",
|
||||
".sh",
|
||||
".bash",
|
||||
".zsh",
|
||||
".fish",
|
||||
".sql",
|
||||
".r",
|
||||
".m",
|
||||
".mat",
|
||||
".ini",
|
||||
".cfg",
|
||||
".conf",
|
||||
".config",
|
||||
".env",
|
||||
".gitignore",
|
||||
".dockerignore",
|
||||
".editorconfig",
|
||||
".log",
|
||||
".csv",
|
||||
".tsv",
|
||||
}
|
||||
|
||||
|
||||
class S3Loader(BaseRemote):
|
||||
"""Load documents from an AWS S3 bucket."""
|
||||
|
||||
@@ -128,58 +184,7 @@ class S3Loader(BaseRemote):
|
||||
|
||||
def is_text_file(self, file_path: str) -> bool:
|
||||
"""Determine if a file is a text file based on extension."""
|
||||
text_extensions = {
|
||||
".txt",
|
||||
".md",
|
||||
".markdown",
|
||||
".rst",
|
||||
".json",
|
||||
".xml",
|
||||
".yaml",
|
||||
".yml",
|
||||
".py",
|
||||
".js",
|
||||
".ts",
|
||||
".jsx",
|
||||
".tsx",
|
||||
".java",
|
||||
".c",
|
||||
".cpp",
|
||||
".h",
|
||||
".hpp",
|
||||
".cs",
|
||||
".go",
|
||||
".rs",
|
||||
".rb",
|
||||
".php",
|
||||
".swift",
|
||||
".kt",
|
||||
".scala",
|
||||
".html",
|
||||
".css",
|
||||
".scss",
|
||||
".sass",
|
||||
".less",
|
||||
".sh",
|
||||
".bash",
|
||||
".zsh",
|
||||
".fish",
|
||||
".sql",
|
||||
".r",
|
||||
".m",
|
||||
".mat",
|
||||
".ini",
|
||||
".cfg",
|
||||
".conf",
|
||||
".config",
|
||||
".env",
|
||||
".gitignore",
|
||||
".dockerignore",
|
||||
".editorconfig",
|
||||
".log",
|
||||
".csv",
|
||||
".tsv",
|
||||
}
|
||||
text_extensions = _TEXT_EXTENSIONS
|
||||
|
||||
file_lower = file_path.lower()
|
||||
for ext in text_extensions:
|
||||
@@ -197,7 +202,9 @@ class S3Loader(BaseRemote):
|
||||
|
||||
def is_supported_document(self, file_path: str) -> bool:
|
||||
"""Check if file is a supported document type for parsing."""
|
||||
document_extensions = {
|
||||
# The upload whitelist plus the historical S3 list, so an object the
|
||||
# upload API accepts is never silently skipped here.
|
||||
document_extensions = (set(SUPPORTED_SOURCE_DOCUMENT_EXTENSIONS) - _TEXT_EXTENSIONS) | {
|
||||
".pdf",
|
||||
".docx",
|
||||
".doc",
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# Optional docling parser engine.
|
||||
#
|
||||
# Needed only for: DOC_PARSER_ENGINE=docling, the docling OCR backend
|
||||
# (OCR_BACKEND=docling: layout-model hybrid OCR, ocrmac/rapidocr engines),
|
||||
# .adoc/.vtt/.xml attachment 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, which pins torch, transformers and
|
||||
# onnxruntime (rapidocr runs on the same onnxruntime fastembed uses):
|
||||
# pip install -r requirements.txt -r requirements-docling.txt
|
||||
# Docker: build with --build-arg INSTALL_DOCLING=true
|
||||
docling==2.119.0
|
||||
rapidocr==3.9.2
|
||||
@@ -12,15 +12,19 @@ cryptography==50.0.0
|
||||
dataclasses-json==0.6.7
|
||||
daytona==0.205.1
|
||||
defusedxml==0.7.1
|
||||
docling==2.119.0
|
||||
rapidocr==3.9.2
|
||||
onnxruntime==1.28.0
|
||||
# Default document converter (DOC_PARSER_ENGINE=anydoc). The docling engine
|
||||
# (OCR, layout/table models, read_document's structured output) is an
|
||||
# optional extra: pip install -r application/requirements-docling.txt
|
||||
firecrawl-anydoc==0.2.3
|
||||
docx2txt==0.9
|
||||
ddgs>=8.0.0
|
||||
fast-ebook
|
||||
elevenlabs==2.62.0
|
||||
Flask==3.1.3
|
||||
fastembed==0.8.0
|
||||
# fastembed's runtime: local embeddings execute on it, so it stays pinned in
|
||||
# core even though the docling extra's rapidocr runs on the same package.
|
||||
onnxruntime==1.28.0
|
||||
faiss-cpu==1.15.0
|
||||
fastmcp==3.4.6
|
||||
flask-restx==1.3.2
|
||||
@@ -95,10 +99,12 @@ sqlalchemy>=2.0,<3
|
||||
starlette>=1.0,<2
|
||||
tiktoken==0.13.0
|
||||
tokenizers==0.22.2
|
||||
# torch and transformers serve docling's model stack alone -- embeddings moved
|
||||
# to fastembed/onnxruntime. Still in core rather than requirements-docling.txt,
|
||||
# so installing the optional extra never re-resolves the shared pins; both can
|
||||
# move out with docling.
|
||||
torch==2.11.0
|
||||
tqdm==4.67.3
|
||||
# torch and transformers are here for docling's model stack only -- embeddings
|
||||
# moved to fastembed/onnxruntime. Both can go when docling does.
|
||||
# Capped <5.9.0 to match docling-core's own darwin pin: 5.9+ breaks docling's
|
||||
# PDF layout model on Apple Silicon ("Cannot convert a MPS Tensor to float64").
|
||||
transformers==5.8.1
|
||||
|
||||
@@ -1767,12 +1767,13 @@ def attachment_worker(self, file_info, user):
|
||||
scope={"kind": "attachment", "id": str(attachment_id)},
|
||||
)
|
||||
|
||||
# Attachments only: PDFs are read via their text layer where one
|
||||
# exists (see ``ATTACHMENT_PDF_TEXT_FAST_PATH``). Source ingestion
|
||||
# Attachments only: under the docling engine PDFs are read via their
|
||||
# text layer where one exists (``ATTACHMENT_PDF_TEXT_FAST_PATH``; a
|
||||
# no-op under anydoc, which is already that fast). Source ingestion
|
||||
# calls SimpleDirectoryReader without a ``file_extractor`` and so keeps
|
||||
# the docling default, which is what retrieval quality depends on.
|
||||
# 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())
|
||||
@@ -1805,7 +1806,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}
|
||||
|
||||
@@ -10,7 +10,13 @@ services:
|
||||
- backend
|
||||
|
||||
backend:
|
||||
build: ../application
|
||||
build:
|
||||
context: ../application
|
||||
args:
|
||||
INSTALL_DOCLING: ${INSTALL_DOCLING:-false}
|
||||
# Off by default; deployments running OCR_ENABLED=true with tesseract
|
||||
# must set INSTALL_TESSERACT=true before rebuilding (see docker-compose.yaml).
|
||||
INSTALL_TESSERACT: ${INSTALL_TESSERACT:-false}
|
||||
env_file:
|
||||
- ../.env
|
||||
environment:
|
||||
@@ -31,7 +37,13 @@ services:
|
||||
condition: service_healthy
|
||||
|
||||
worker:
|
||||
build: ../application
|
||||
build:
|
||||
context: ../application
|
||||
args:
|
||||
INSTALL_DOCLING: ${INSTALL_DOCLING:-false}
|
||||
# Off by default; deployments running OCR_ENABLED=true with tesseract
|
||||
# must set INSTALL_TESSERACT=true before rebuilding (see docker-compose.yaml).
|
||||
INSTALL_TESSERACT: ${INSTALL_TESSERACT:-false}
|
||||
# `parsing` queue carries read_document/parse_document; required for its await to resolve.
|
||||
command: celery -A application.app.celery worker -l INFO -Q docsgpt,parsing,embeddings
|
||||
env_file:
|
||||
|
||||
@@ -15,7 +15,19 @@ services:
|
||||
|
||||
backend:
|
||||
user: root
|
||||
build: ../application
|
||||
build:
|
||||
context: ../application
|
||||
args:
|
||||
# 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}
|
||||
# Bake the tesseract binary behind OCR_ENABLED=true (~35 MB): set
|
||||
# INSTALL_TESSERACT=true in ../.env or the shell (setup.sh does this
|
||||
# when OCR is enabled). Off by default, like docling. Upgrading a
|
||||
# deployment that already runs OCR_ENABLED=true with tesseract: add
|
||||
# INSTALL_TESSERACT=true to ../.env before rebuilding, or scanned
|
||||
# pages fail with an install hint.
|
||||
INSTALL_TESSERACT: ${INSTALL_TESSERACT:-false}
|
||||
env_file:
|
||||
- ../.env
|
||||
environment:
|
||||
@@ -38,7 +50,11 @@ services:
|
||||
|
||||
worker:
|
||||
user: root
|
||||
build: ../application
|
||||
build:
|
||||
context: ../application
|
||||
args:
|
||||
INSTALL_DOCLING: ${INSTALL_DOCLING:-false}
|
||||
INSTALL_TESSERACT: ${INSTALL_TESSERACT:-false}
|
||||
# Consumes the default queue AND the dedicated `parsing` (read_document /
|
||||
# parse_document) and `embeddings` (query embedding) queues. Without `parsing`
|
||||
# the read_document await never resolves; without `embeddings` every search
|
||||
|
||||
@@ -188,8 +188,10 @@ Daytona render output matches the Jupyter-backend output.
|
||||
|
||||
Document reading no longer runs in this sandbox. The `read_document` tool and the
|
||||
workflow native-file extract branch enqueue a `parse_document` Celery task that
|
||||
parses the document **in the backend** (Docling, already in
|
||||
`application/requirements.txt`) and awaits the result. The task is routed to a
|
||||
parses the document **in the backend** (the `DOC_PARSER_ENGINE` parser — anydoc
|
||||
by default; Docling only when the optional
|
||||
`application/requirements-docling.txt` extra is installed) and awaits the
|
||||
result. The task is routed to a
|
||||
dedicated **`parsing` queue** (`settings.DOCUMENT_PARSE_QUEUE`, default
|
||||
`"parsing"`) so a parse enqueued from inside a Celery worker (headless/scheduled
|
||||
agent) is served by a separate worker and never self-deadlocks the awaiting one.
|
||||
@@ -200,8 +202,13 @@ 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
|
||||
libraries) so OCR-heavy parsing runs on a separate, optionally larger pool.
|
||||
It takes its own env, so parse-heavy work runs on a separate, optionally larger
|
||||
pool. A GPU helps it only with the docling extra installed
|
||||
(`INSTALL_DOCLING=true`), whose layout and table models run on torch:
|
||||
`OCR_ENABLED=true` alone keeps the CPU-only native backend with the default
|
||||
`OCR_ENGINE=tesseract`, which GPU libraries do not accelerate. Setting
|
||||
`OCR_ENGINE=deepseek` instead moves the OCR cost onto the Ollama/vLLM endpoint
|
||||
and leaves this worker light.
|
||||
|
||||
**Dev / single-worker setups:** without a dedicated parsing worker the default
|
||||
worker must also consume `parsing`, or the tool's await never resolves:
|
||||
|
||||
@@ -94,6 +94,13 @@ To run the DocsGPT backend locally, you'll need to set up a Python environment a
|
||||
pip install -r application/requirements.txt
|
||||
```
|
||||
|
||||
Optionally add the docling parser engine (OCR, `structured` output for
|
||||
`read_document`; the default anydoc engine does not need it):
|
||||
|
||||
```bash
|
||||
pip install -r application/requirements-docling.txt
|
||||
```
|
||||
|
||||
5. **Run the Backend:**
|
||||
|
||||
For local development, run the ASGI composition under uvicorn. It serves the **whole** application, hot-reloads on source changes, and matches the production runtime:
|
||||
|
||||
@@ -48,7 +48,7 @@ The fastest way to try out DocsGPT is by using the public API endpoint. This req
|
||||
Navigate to the root directory of the DocsGPT repository in your terminal and run:
|
||||
|
||||
```bash
|
||||
docker compose -f deployment/docker-compose.yaml up -d
|
||||
docker compose --env-file .env -f deployment/docker-compose.yaml up -d
|
||||
```
|
||||
|
||||
The `-d` flag runs Docker Compose in detached mode (in the background).
|
||||
|
||||
@@ -199,6 +199,32 @@ These byte/count limits and the non-negative nesting depth can be overridden in
|
||||
The request limit intentionally applies only to public upload routes; internal
|
||||
worker index transfers use their own trusted service boundary.
|
||||
|
||||
## Document Parsing
|
||||
|
||||
Uploaded sources and chat attachments are converted to Markdown before
|
||||
chunking or prompting. See the [Document Parsing and OCR](/Guides/ocr) guide
|
||||
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, 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, both backends; optional system package — build with `INSTALL_TESSERACT=true` or `apt`/`brew` install it), `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` | Seconds allowed per page request to the DeepSeek endpoint, on both backends (the native backend sends pages 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 for the OCR dropout guard: a multi-page parse whose OCR returns nothing fails loudly instead of indexing an empty document (docling retries once on full-page OCR first); output below the floor but non-empty is indexed with a warning. `0` disables. Alias: `DOCLING_OCR_MIN_CHARS_PER_PAGE`. |
|
||||
|
||||
## Speech-to-Text Settings
|
||||
|
||||
DocsGPT can transcribe audio in two places:
|
||||
|
||||
@@ -16,7 +16,7 @@ Training on other documentation sources can greatly enhance the versatility and
|
||||
Make sure you have the document on which you want to train on ready with you on the device which you are using .You can also use links to the documentation to train on.
|
||||
|
||||
<Callout type="warning" emoji="⚠️">
|
||||
Note: Supported file formats include .pdf, .txt, .rst, .docx, .md, .mdx, .csv, .epub, .html, .json, .xlsx, .pptx, .png, .jpg, .jpeg, and audio files (.wav, .mp3, .m4a, .ogg, .webm). You can also train using the link of the documentation.
|
||||
Note: Supported file formats include .pdf, .txt, .rst, .md, .mdx, .html, .xhtml, .json, .epub, Office documents (.docx, .doc, .docm, .odt, .rtf), spreadsheets (.xlsx, .xls, .xlsm, .xlsb, .ods, .csv), presentations (.pptx, .ppt, .pptm, .pps, .ppsx, .ppsm, .pot, .odp), images (.png, .jpg, .jpeg), and audio files (.wav, .mp3, .m4a, .ogg, .webm). You can also train using the link of the documentation.
|
||||
|
||||
</Callout>
|
||||
|
||||
|
||||
+251
-19
@@ -1,21 +1,245 @@
|
||||
---
|
||||
title: OCR for Sources and Attachments
|
||||
description: How OCR works in DocsGPT, how to configure it, and what changes for source ingestion vs chat attachments.
|
||||
title: Document Parsing and OCR
|
||||
description: Which engine parses uploaded documents, how OCR works in DocsGPT, how to configure it, and what changes for source ingestion vs chat attachments.
|
||||
---
|
||||
|
||||
import { Callout } from 'nextra/components'
|
||||
|
||||
# Docling OCR for Sources and Attachments
|
||||
# Document Parsing and OCR for Sources and Attachments
|
||||
|
||||
DocsGPT uses Docling as the default parser layer for many document formats. OCR is optional and controlled by two settings:
|
||||
## Parser engine
|
||||
|
||||
DocsGPT converts uploaded documents to Markdown before chunking, embedding or
|
||||
handing them to a model. The engine is selected with one setting:
|
||||
|
||||
```env
|
||||
DOCLING_OCR_ENABLED=false
|
||||
DOCLING_OCR_ATTACHMENTS_ENABLED=false
|
||||
DOC_PARSER_ENGINE=anydoc
|
||||
```
|
||||
|
||||
- `DOCLING_OCR_ENABLED`: OCR behavior for Source Docs ingestion.
|
||||
- `DOCLING_OCR_ATTACHMENTS_ENABLED`: OCR behavior for chat attachments uploaded from the message box.
|
||||
- `anydoc` (default): [firecrawl-anydoc](https://github.com/firecrawl/anydoc),
|
||||
a Rust converter with no ML models. It reads PDF, DOCX, PPTX, XLSX and CSV
|
||||
in milliseconds with ~100 MB peak memory; HTML/XHTML is converted with
|
||||
`markdownify`, head-truncated at `MARKUP_MAX_BYTES`. Because anydoc is a
|
||||
core dependency, source uploads and the `read_document` tool now also
|
||||
accept its other formats — DOC, PPT/PPS/POT, XLS, ODT/ODS/ODP, RTF, XHTML
|
||||
and the macro-enabled Office variants — on every engine (they are in the
|
||||
`SUPPORTED_SOURCE_EXTENSIONS` whitelist; chat attachments are not
|
||||
whitelisted by extension and take them too). It never performs OCR:
|
||||
a scanned or image-only PDF is *detected* and handed to the fallback
|
||||
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,
|
||||
transformers, ONNX models) and seconds to minutes per PDF. Switching back is
|
||||
this one variable; nothing else changes.
|
||||
|
||||
With `DOC_PARSER_ENGINE=anydoc`, Docling still handles what anydoc cannot when
|
||||
it is installed: the fallback for files anydoc rejects, `.adoc`/`.vtt`/`.xml`
|
||||
chat attachments (those suffixes are not in the source-upload whitelist),
|
||||
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 tesseract
|
||||
|
||||
Nothing OCR-related ships in the base install: the default engine,
|
||||
`tesseract`, is a ~35 MB system package that you opt into like any other OCR
|
||||
dependency. Locally:
|
||||
|
||||
```bash
|
||||
# Debian/Ubuntu # macOS
|
||||
apt-get install tesseract-ocr tesseract-ocr-eng brew install tesseract
|
||||
```
|
||||
|
||||
Docker images build without it by default; opt in with the build argument:
|
||||
|
||||
```bash
|
||||
docker build --build-arg INSTALL_TESSERACT=true ./application
|
||||
```
|
||||
|
||||
`deployment/docker-compose.yaml` forwards the same switch, so setting
|
||||
`INSTALL_TESSERACT=true` in `.env` (or the shell) bakes tesseract plus the
|
||||
English pack into locally built backend and worker images; `setup.sh` writes
|
||||
it when you answer yes to the OCR question after choosing to build images
|
||||
locally. Pre-built Docker Hub images (`docker-compose-hub.yaml`) do not
|
||||
include it, so `setup.sh` leaves OCR at its default (off) for them; to OCR
|
||||
there, point `OCR_ENGINE=deepseek` at a DeepSeek-OCR endpoint or install
|
||||
`tesseract-ocr` in a derived image. With `OCR_ENABLED=true` and no binary on
|
||||
`PATH`, scanned pages fail with an install hint (text-layer documents are
|
||||
unaffected).
|
||||
|
||||
<Callout type="warning" emoji="⚠️">
|
||||
**Upgrading:** earlier images always included docling, and OCR ran through the
|
||||
RapidOCR engine bundled with it, so no system package was needed. The default
|
||||
image now ships neither docling nor tesseract. If your deployment uses the
|
||||
`tesseract` engine (the default) with `OCR_ENABLED=true` or
|
||||
`OCR_ATTACHMENTS_ENABLED=true`, add `INSTALL_TESSERACT=true` to `.env` before
|
||||
rebuilding, or OCR of scanned pages stops working after the rebuild.
|
||||
</Callout>
|
||||
|
||||
## Installing the docling engine
|
||||
|
||||
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` attachment parsing, or `read_document`'s `structured`
|
||||
output:
|
||||
|
||||
```bash
|
||||
pip install -r application/requirements-docling.txt
|
||||
```
|
||||
|
||||
Docker images build without it by default; opt in with the build argument:
|
||||
|
||||
```bash
|
||||
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` offers it as a follow-up to the
|
||||
OCR question. Compose reads build arguments from the shell or from the
|
||||
`.env` you pass with `--env-file .env` (not from the containers' `env_file`),
|
||||
so build with `docker compose --env-file .env -f deployment/docker-compose.yaml build`
|
||||
as `setup.sh` does. Pre-built Docker Hub images (`docker-compose-hub.yaml`)
|
||||
never include docling; install it in a derived image instead. 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.
|
||||
|
||||
## OCR
|
||||
|
||||
OCR is optional and controlled by two on/off settings, a backend and an
|
||||
engine choice:
|
||||
|
||||
```env
|
||||
OCR_ENABLED=false
|
||||
OCR_ATTACHMENTS_ENABLED=false
|
||||
OCR_BACKEND=auto
|
||||
OCR_ENGINE=tesseract
|
||||
```
|
||||
|
||||
- `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 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
|
||||
(see [Installing tesseract](#installing-tesseract)) is needed.
|
||||
|
||||
| `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` | 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 — an optional install like every OCR dependency (see [Installing tesseract](#installing-tesseract)); 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:
|
||||
|
||||
```env
|
||||
OCR_ENGINE=deepseek
|
||||
OCR_DEEPSEEK_URL=http://localhost:11434/v1/chat/completions # Ollama default
|
||||
OCR_DEEPSEEK_MODEL=deepseek-ocr:3b
|
||||
OCR_DEEPSEEK_TIMEOUT=300 # seconds per page request, both backends
|
||||
```
|
||||
|
||||
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. 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 honours the same timeout per request but keeps its own four
|
||||
concurrent requests.
|
||||
|
||||
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 `OCR_DEEPSEEK_TIMEOUT` per request. 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
|
||||
|
||||
@@ -24,7 +248,7 @@ DOCLING_OCR_ATTACHMENTS_ENABLED=false
|
||||
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. For PDFs and image formats, Docling parsers are used. 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.
|
||||
|
||||
@@ -32,23 +256,30 @@ DOCLING_OCR_ATTACHMENTS_ENABLED=false
|
||||
|
||||
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
|
||||
- bitmap/image regions: OCR only where needed
|
||||
- Image parser defaults to full-page OCR (the whole image is visual content).
|
||||
|
||||
By default, Docling parser classes use RapidOCR options (language default: `english`).
|
||||
The engine and its languages come from `OCR_ENGINE` and `OCR_LANGS` (see the
|
||||
table above). `INSTALL_TESSERACT=true` installs only the English tesseract
|
||||
pack; for other languages install their packs in the image (e.g.
|
||||
`apt-get install tesseract-ocr-chi-sim`) and list them in `OCR_LANGS`
|
||||
(`eng+chi_sim`).
|
||||
|
||||
<Callout type="info" emoji="ℹ️">
|
||||
Parser internals like OCR language and force-full-page OCR are currently set by code defaults, not separate `.env` settings.
|
||||
<Callout type="warning" emoji="⚠️">
|
||||
Upgrading from a RapidOCR-based deployment? RapidOCR covered English *and*
|
||||
Chinese with no configuration. The tesseract default only OCRs the languages
|
||||
in `OCR_LANGS` (`eng` out of the box), so CJK scans stop ingesting until
|
||||
their packs are installed and listed.
|
||||
</Callout>
|
||||
|
||||
### Model compilation
|
||||
@@ -79,18 +310,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.
|
||||
- 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`.
|
||||
|
||||
@@ -26,6 +26,23 @@ export const FILE_UPLOAD_ACCEPT: Record<string, string[]> = {
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation': [
|
||||
'.pptx',
|
||||
],
|
||||
'application/xhtml+xml': ['.xhtml'],
|
||||
'application/msword': ['.doc'],
|
||||
'application/vnd.ms-word.document.macroEnabled.12': ['.docm'],
|
||||
'application/vnd.oasis.opendocument.text': ['.odt'],
|
||||
'application/rtf': ['.rtf'],
|
||||
'text/rtf': ['.rtf'],
|
||||
'application/vnd.ms-powerpoint': ['.ppt', '.pps', '.pot'],
|
||||
'application/vnd.ms-powerpoint.presentation.macroEnabled.12': ['.pptm'],
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.slideshow': [
|
||||
'.ppsx',
|
||||
],
|
||||
'application/vnd.ms-powerpoint.slideshow.macroEnabled.12': ['.ppsm'],
|
||||
'application/vnd.oasis.opendocument.presentation': ['.odp'],
|
||||
'application/vnd.ms-excel': ['.xls'],
|
||||
'application/vnd.ms-excel.sheet.macroEnabled.12': ['.xlsm'],
|
||||
'application/vnd.ms-excel.sheet.binary.macroEnabled.12': ['.xlsb'],
|
||||
'application/vnd.oasis.opendocument.spreadsheet': ['.ods'],
|
||||
'image/png': ['.png'],
|
||||
'image/jpeg': ['.jpeg'],
|
||||
'image/jpg': ['.jpg'],
|
||||
@@ -45,6 +62,22 @@ export const FILE_UPLOAD_ACCEPT_ATTR = [
|
||||
'.epub',
|
||||
'.xlsx',
|
||||
'.pptx',
|
||||
'.xhtml',
|
||||
'.doc',
|
||||
'.docm',
|
||||
'.odt',
|
||||
'.rtf',
|
||||
'.ppt',
|
||||
'.pptm',
|
||||
'.pps',
|
||||
'.ppsx',
|
||||
'.ppsm',
|
||||
'.pot',
|
||||
'.odp',
|
||||
'.xls',
|
||||
'.xlsm',
|
||||
'.xlsb',
|
||||
'.ods',
|
||||
'.png',
|
||||
'.jpeg',
|
||||
'.jpg',
|
||||
@@ -76,6 +109,22 @@ export const SOURCE_FILE_TREE_ACCEPT_ATTR = [
|
||||
'.json',
|
||||
'.xlsx',
|
||||
'.pptx',
|
||||
'.xhtml',
|
||||
'.doc',
|
||||
'.docm',
|
||||
'.odt',
|
||||
'.rtf',
|
||||
'.ppt',
|
||||
'.pptm',
|
||||
'.pps',
|
||||
'.ppsx',
|
||||
'.ppsm',
|
||||
'.pot',
|
||||
'.odp',
|
||||
'.xls',
|
||||
'.xlsm',
|
||||
'.xlsb',
|
||||
'.ods',
|
||||
'.png',
|
||||
'.jpg',
|
||||
'.jpeg',
|
||||
@@ -105,6 +154,23 @@ export const ATTACHMENT_PARSER_EXTENSIONS: readonly string[] = [
|
||||
'.json',
|
||||
'.xlsx',
|
||||
'.pptx',
|
||||
// Read by the anydoc engine, a core backend dependency: legacy and
|
||||
// macro-enabled Office, OpenDocument, RTF.
|
||||
'.docm',
|
||||
'.doc',
|
||||
'.odt',
|
||||
'.rtf',
|
||||
'.pptm',
|
||||
'.ppt',
|
||||
'.pps',
|
||||
'.ppsx',
|
||||
'.ppsm',
|
||||
'.pot',
|
||||
'.odp',
|
||||
'.xlsm',
|
||||
'.xlsb',
|
||||
'.xls',
|
||||
'.ods',
|
||||
'.adoc',
|
||||
'.asciidoc',
|
||||
'.png',
|
||||
|
||||
@@ -765,7 +765,7 @@
|
||||
"start": "Chat starten",
|
||||
"name": "Name",
|
||||
"choose": "Dateien auswählen",
|
||||
"info": "Bitte lade .pdf, .txt, .rst, .csv, .xlsx, .docx, .md, .html, .epub, .json, .pptx, .zip hoch (max. 25 MB)",
|
||||
"info": "Bitte lade .pdf, .txt, .rst, .csv, .xlsx, .xlsm, .xlsb, .xls, .ods, .docx, .docm, .doc, .odt, .rtf, .md, .html, .xhtml, .png, .jpg, .jpeg, .epub, .json, .pptx, .pptm, .ppt, .pps, .ppsx, .ppsm, .pot, .odp, .zip hoch (max. 25 MB)",
|
||||
"uploadedFiles": "Hochgeladene Dateien",
|
||||
"cancel": "Abbrechen",
|
||||
"train": "Trainieren",
|
||||
|
||||
@@ -770,7 +770,7 @@
|
||||
"start": "Start Chatting",
|
||||
"name": "Name",
|
||||
"choose": "Choose Files",
|
||||
"info": "Please upload .pdf, .txt, .rst, .csv, .xlsx, .docx, .md, .html, .epub, .json, .pptx, .zip limited to 25mb",
|
||||
"info": "Please upload .pdf, .txt, .rst, .csv, .xlsx, .xlsm, .xlsb, .xls, .ods, .docx, .docm, .doc, .odt, .rtf, .md, .html, .xhtml, .png, .jpg, .jpeg, .epub, .json, .pptx, .pptm, .ppt, .pps, .ppsx, .ppsm, .pot, .odp, .zip limited to 25mb",
|
||||
"uploadedFiles": "Uploaded Files",
|
||||
"cancel": "Cancel",
|
||||
"train": "Train",
|
||||
|
||||
@@ -765,7 +765,7 @@
|
||||
"start": "Comenzar a chatear",
|
||||
"name": "Nombre",
|
||||
"choose": "Seleccionar Archivos",
|
||||
"info": "Por favor, sube archivos .pdf, .txt, .rst, .csv, .xlsx, .docx, .md, .html, .epub, .json, .pptx, .zip limitados a 25MB",
|
||||
"info": "Por favor, sube archivos .pdf, .txt, .rst, .csv, .xlsx, .xlsm, .xlsb, .xls, .ods, .docx, .docm, .doc, .odt, .rtf, .md, .html, .xhtml, .png, .jpg, .jpeg, .epub, .json, .pptx, .pptm, .ppt, .pps, .ppsx, .ppsm, .pot, .odp, .zip limitados a 25MB",
|
||||
"uploadedFiles": "Archivos Subidos",
|
||||
"cancel": "Cancelar",
|
||||
"train": "Entrenar",
|
||||
|
||||
@@ -765,7 +765,7 @@
|
||||
"start": "チャットを開始する",
|
||||
"name": "名前",
|
||||
"choose": "ファイルを選択",
|
||||
"info": "25MBまでの.pdf、.txt、.rst、.csv、.xlsx、.docx、.md、.html、.epub、.json、.pptx、.zipファイルをアップロードしてください",
|
||||
"info": "25MBまでの.pdf、.txt、.rst、.csv、.xlsx、.xlsm、.xlsb、.xls、.ods、.docx、.docm、.doc、.odt、.rtf、.md、.html、.xhtml、.png、.jpg、.jpeg、.epub、.json、.pptx、.pptm、.ppt、.pps、.ppsx、.ppsm、.pot、.odp、.zipファイルをアップロードしてください",
|
||||
"uploadedFiles": "アップロードされたファイル",
|
||||
"cancel": "キャンセル",
|
||||
"train": "トレーニング",
|
||||
|
||||
@@ -765,7 +765,7 @@
|
||||
"start": "Начать чат",
|
||||
"name": "Имя",
|
||||
"choose": "Выбрать файлы",
|
||||
"info": "Пожалуйста, загрузите файлы .pdf, .txt, .rst, .csv, .xlsx, .docx, .md, .html, .epub, .json, .pptx, .zip размером до 25 МБ",
|
||||
"info": "Пожалуйста, загрузите файлы .pdf, .txt, .rst, .csv, .xlsx, .xlsm, .xlsb, .xls, .ods, .docx, .docm, .doc, .odt, .rtf, .md, .html, .xhtml, .png, .jpg, .jpeg, .epub, .json, .pptx, .pptm, .ppt, .pps, .ppsx, .ppsm, .pot, .odp, .zip размером до 25 МБ",
|
||||
"uploadedFiles": "Загруженные файлы",
|
||||
"cancel": "Отмена",
|
||||
"train": "Тренировка",
|
||||
|
||||
@@ -765,7 +765,7 @@
|
||||
"start": "開始對話",
|
||||
"name": "名稱",
|
||||
"choose": "選擇檔案",
|
||||
"info": "請上傳限制為25MB的.pdf、.txt、.rst、.csv、.xlsx、.docx、.md、.html、.epub、.json、.pptx、.zip檔案",
|
||||
"info": "請上傳限制為25MB的.pdf、.txt、.rst、.csv、.xlsx、.xlsm、.xlsb、.xls、.ods、.docx、.docm、.doc、.odt、.rtf、.md、.html、.xhtml、.png、.jpg、.jpeg、.epub、.json、.pptx、.pptm、.ppt、.pps、.ppsx、.ppsm、.pot、.odp、.zip檔案",
|
||||
"uploadedFiles": "已上傳檔案",
|
||||
"cancel": "取消",
|
||||
"train": "訓練",
|
||||
|
||||
@@ -765,7 +765,7 @@
|
||||
"start": "开始聊天",
|
||||
"name": "名称",
|
||||
"choose": "选择文件",
|
||||
"info": "请上传限制为25MB的.pdf、.txt、.rst、.csv、.xlsx、.docx、.md、.html、.epub、.json、.pptx、.zip文件",
|
||||
"info": "请上传限制为25MB的.pdf、.txt、.rst、.csv、.xlsx、.xlsm、.xlsb、.xls、.ods、.docx、.docm、.doc、.odt、.rtf、.md、.html、.xhtml、.png、.jpg、.jpeg、.epub、.json、.pptx、.pptm、.ppt、.pps、.ppsx、.ppsm、.pot、.odp、.zip文件",
|
||||
"uploadedFiles": "已上传文件",
|
||||
"cancel": "取消",
|
||||
"train": "训练",
|
||||
|
||||
@@ -513,10 +513,29 @@ function Configure-DocProcessing {
|
||||
Write-ColorText "PDF-as-image parsing enabled." -ForegroundColor "Green"
|
||||
}
|
||||
|
||||
$ocr_enabled = Read-Host "Enable OCR for document processing (Docling)? (y/N)"
|
||||
# OCR needs the tesseract binary, an optional system package that only
|
||||
# locally built images can include (INSTALL_TESSERACT build arg). The
|
||||
# pre-built Docker Hub images ship without it, so there OCR stays off
|
||||
# (its default) rather than being switched on to fail on every scan.
|
||||
if ($COMPOSE_FILE -ne $COMPOSE_FILE_LOCAL) {
|
||||
Write-ColorText "OCR for scanned PDFs and images stays off: the pre-built Docker Hub images do not include tesseract. To use OCR, rerun setup and choose option 5 (build images locally), or use a DeepSeek-OCR endpoint by adding OCR_ENABLED=true, OCR_ENGINE=deepseek and OCR_DEEPSEEK_URL=<endpoint> to .env." -ForegroundColor "Yellow"
|
||||
return
|
||||
}
|
||||
|
||||
$ocr_enabled = Read-Host "Enable OCR for scanned PDFs and images? (y/N)"
|
||||
if ($ocr_enabled -eq "y" -or $ocr_enabled -eq "Y") {
|
||||
"DOCLING_OCR_ENABLED=true" | Add-Content -Path $ENV_FILE -Encoding utf8
|
||||
Write-ColorText "Docling OCR enabled." -ForegroundColor "Green"
|
||||
"OCR_ENABLED=true" | Add-Content -Path $ENV_FILE -Encoding utf8
|
||||
# Bakes tesseract into the locally built images (docker compose
|
||||
# --env-file .env build).
|
||||
"INSTALL_TESSERACT=true" | Add-Content -Path $ENV_FILE -Encoding utf8
|
||||
Write-ColorText "OCR enabled. tesseract will be built into the images (INSTALL_TESSERACT=true); for a DeepSeek-OCR endpoint instead, set OCR_ENGINE=deepseek and OCR_DEEPSEEK_URL=<endpoint> in .env." -ForegroundColor "Green"
|
||||
$docling_ocr = Read-Host "Also install the Docling layout engine for OCR (better tables/reading order, several GB heavier)? (y/N)"
|
||||
if ($docling_ocr -eq "y" -or $docling_ocr -eq "Y") {
|
||||
# Locally built images include docling via this build arg; it becomes
|
||||
# the OCR backend automatically (OCR_BACKEND=auto).
|
||||
"INSTALL_DOCLING=true" | Add-Content -Path $ENV_FILE -Encoding utf8
|
||||
Write-ColorText "Docling will be built into locally built images (docker compose --env-file .env build). Pre-built Docker Hub images do not include it." -ForegroundColor "Green"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -633,7 +652,14 @@ function Use-DocsPublicAPIEndpoint {
|
||||
throw "Docker compose pull failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
|
||||
& docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" up -d
|
||||
# Locally built images must be rebuilt rather than reused:
|
||||
# INSTALL_TESSERACT and INSTALL_DOCLING are build args, so a rerun that
|
||||
# switches OCR on would otherwise keep the image built without them.
|
||||
$up_args = @("up", "-d")
|
||||
if ($COMPOSE_FILE -eq $COMPOSE_FILE_LOCAL) {
|
||||
$up_args = @("up", "--build", "-d")
|
||||
}
|
||||
& docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" @up_args
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Docker compose up failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
|
||||
@@ -367,10 +367,29 @@ 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
|
||||
# OCR needs the tesseract binary, an optional system package that only
|
||||
# locally built images can include (INSTALL_TESSERACT build arg). The
|
||||
# pre-built Docker Hub images ship without it, so there OCR stays off
|
||||
# (its default) rather than being switched on to fail on every scan.
|
||||
if [[ "$COMPOSE_FILE" != "$COMPOSE_FILE_LOCAL" ]]; then
|
||||
echo -e "${YELLOW}OCR for scanned PDFs and images stays off: the pre-built Docker Hub images do not include tesseract. To use OCR, rerun setup and choose option 5 (build images locally), or use a DeepSeek-OCR endpoint by adding OCR_ENABLED=true, OCR_ENGINE=deepseek and OCR_DEEPSEEK_URL=<endpoint> to .env.${NC}"
|
||||
return
|
||||
fi
|
||||
|
||||
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"
|
||||
echo -e "${GREEN}Docling OCR enabled.${NC}"
|
||||
echo "OCR_ENABLED=true" >> "$ENV_FILE"
|
||||
# Bakes tesseract into the locally built images (docker compose
|
||||
# --env-file .env build).
|
||||
echo "INSTALL_TESSERACT=true" >> "$ENV_FILE"
|
||||
echo -e "${GREEN}OCR enabled. tesseract will be built into the images (INSTALL_TESSERACT=true); for a DeepSeek-OCR endpoint instead, set OCR_ENGINE=deepseek and OCR_DEEPSEEK_URL=<endpoint> in .env.${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 (docker compose --env-file .env build). Pre-built Docker Hub images do not include it.${NC}"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -455,7 +474,14 @@ use_docs_public_api_endpoint() {
|
||||
check_and_start_docker
|
||||
|
||||
echo -e "\n${NC}Starting Docker Compose...${NC}"
|
||||
docker compose --env-file "${ENV_FILE}" -f "${COMPOSE_FILE}" pull && docker compose --env-file "${ENV_FILE}" -f "${COMPOSE_FILE}" up -d
|
||||
# Locally built images must be rebuilt rather than reused: INSTALL_TESSERACT
|
||||
# and INSTALL_DOCLING are build args, so a rerun that switches OCR on would
|
||||
# otherwise keep the image that was built without them.
|
||||
up_args=(up -d)
|
||||
if [ "$COMPOSE_FILE" = "$COMPOSE_FILE_LOCAL" ]; then
|
||||
up_args=(up --build -d)
|
||||
fi
|
||||
docker compose --env-file "${ENV_FILE}" -f "${COMPOSE_FILE}" pull && docker compose --env-file "${ENV_FILE}" -f "${COMPOSE_FILE}" "${up_args[@]}"
|
||||
docker_compose_status=$? # Capture exit status of docker compose
|
||||
|
||||
echo "Docker Compose Exit Status: $docker_compose_status"
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
%PDF-1.4
|
||||
%“Œ‹ž ReportLab Generated PDF document (opensource)
|
||||
1 0 obj
|
||||
<<
|
||||
/F1 2 0 R /F2 3 0 R /F3 4 0 R /F4 5 0 R
|
||||
>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<<
|
||||
/BaseFont /Times-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
4 0 obj
|
||||
<<
|
||||
/BaseFont /STSong-Light /DescendantFonts [ <<
|
||||
/BaseFont /STSong-Light /CIDSystemInfo <<
|
||||
/Ordering (GB1) /Registry (Adobe) /Supplement 0
|
||||
>> /DW 1000 /FontDescriptor <<
|
||||
/Ascent 752 /CapHeight 737 /Descent -271 /Flags 6 /FontBBox [ -25 -254 1000 880 ] /FontName /STSongStd-Light
|
||||
/ItalicAngle 0 /Leading 148 /MaxWidth 1000 /MissingWidth 500 /StemH 91 /StemV 58
|
||||
/Type /FontDescriptor /XHeight 553
|
||||
>> /Subtype /CIDFontType0 /Type /Font
|
||||
/W [ 1 [ 207 270 342 467 462 797 710 239 374 ] 10 [ 374 423 605 238 375 238 334 462 ] 18 26 462 27 28 238
|
||||
29 31 605 32 [ 344 748 684 560 695 739 563 511 729 793
|
||||
318 312 666 526 896 758 772 544 772 628
|
||||
465 607 753 711 972 647 620 607 374 333
|
||||
374 606 500 239 417 503 427 529 415 264
|
||||
444 518 241 230 495 228 793 527 524 ] 81 [ 524 504 338 336 277 517 450 652 466 452
|
||||
407 370 258 370 605 ] ]
|
||||
>> ] /Encoding /UniGB-UCS2-H /Name /F3 /Subtype /Type0 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
5 0 obj
|
||||
<<
|
||||
/BaseFont /Times-Roman /Encoding /WinAnsiEncoding /Name /F4 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
6 0 obj
|
||||
<<
|
||||
/Contents 10 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 9 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
7 0 obj
|
||||
<<
|
||||
/PageMode /UseNone /Pages 9 0 R /Type /Catalog
|
||||
>>
|
||||
endobj
|
||||
8 0 obj
|
||||
<<
|
||||
/Author (\(anonymous\)) /CreationDate (D:20260822115950+02'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260822115950+02'00') /Producer (ReportLab PDF Library - \(opensource\))
|
||||
/Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False
|
||||
>>
|
||||
endobj
|
||||
9 0 obj
|
||||
<<
|
||||
/Count 1 /Kids [ 6 0 R ] /Type /Pages
|
||||
>>
|
||||
endobj
|
||||
10 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1647
|
||||
>>
|
||||
stream
|
||||
Gatm<gN):C&:O:SoKt6S$HmLQ&3Rqa-FV*"%Nm-aMW-4=#+,j_=@nmi2Z)t-)*e6=$[Ld,&=Q79:X>PPVJQYC5?'k&+i&d>B>799%,;FkdkJW*5heHuF@91dXh%AJZ6fb3i'sKhCnMdY'0VkEO4GA.p;"DJY62!"YAIse&.WT60%ek]="]O%+5Ot<#Fl%^o]il7fm2dGp=dm)m^:gDD9"d?pER14WDHmu']GE^2k$Vs`2KagWk")2f26\M]Bs+K@uNQtga3OQkLQ7H-hY4>1!4.4XoK+nl.ZlK#$d"IN+=9q.'8T'8cF3g*[-3:LS5c>fi!l0mSVa4QpjYgA<$0LC`2-c:@^;pCa)ekN>!I%&S3he7oPPIfI^JJ8"'-"K,>QL6DaesnS'ThOk/)63Q<B9b;^E^!3N(^L"g8T-_[_e%ED-JKcd/L:T#=nOk`'\l#7Q=,(RK;`0#S<T4BU:`HhPGE"*QrTjD+LciNhe\[arIY[:*6r!Z(b46[T;c]'eT2ib4:#PYiS4KbfJkaWh^D3%L,2kV2tNtL-eVVV!V=WcZC*LmZ1JF,Q`_WpI/i"9jUr1Gd(ISB[Id?[]g@-RLQD"d?"iI[81]"&B'nLTX>D<J'G5>]1d]+<&nMsjUuVm%-`_Dn^N7Y3@(%aO`>?OaQR7"S`I)*4n^gDS'@E((C?3'qDA#_!0S=@#*`D<h%J/PD"f$4CY;-(VXO;.Ngc^I+,-r\]qpUQHC*nl`bn!js*R3SkJqAMhH<a9;!@-5%1$9AH:,n;b&'!\_DddM7mc;^=s`,BOb&%SnDA7Rl;HLN*t%(`jGJS&".8T-M[a.`>blJkd8M[QOjG@Lffk@WSsIihjLDN`q?@n!YaFRc6etUSZ3TeZg#m?mL-MTMO5V)b]\Em&*E2^jCc`Xsk1<"JLgnj,EWu^Kg\"3.u<u5YCaES/ZDf5DpeMd24qDipat:7Oab*_jqT_s6MJ(i_J60;p<ik,/MGX2+u90)Xt!desS2A+)qeT6sUK/IJ'C=1<0u0>SaqZ<FhNoMfaf*!B2`NiLP;eB4?[`;/Y%o&Gm)'qO1JK0uckb!lh3>=t73#j/BK3<dLaZd96%gq]>W+8,V.,0D<(=M`#OG+,1-Fh</5[IM)#q0>k-!*=d[eI7&!b)UOUK$3XO.Y5HWgM[D<SAa`SCR9MN]H2Q[$5(+ZDUq;e-9V7T89Qo[Rm`4fG0o4@V.p48_A)/:trd:.`nh*S%-%oo[?Go6OFLjPL]dfcTW*/@I<%u%sqi96p7;=;:bYI+:AU5':P>$c<"Wh_I_.r/0:j+[",VmP^3"R7]Vuj1Bju_M4fQpgg+K1-[?e$i#W^p3[3m^DdEM$S$d\G^r%4.<YC,K9<kt81fnor-c7+%aA'BW<5M_bT$PN_@IR!Yssf=Ie",juu4f]0+Uq".M]_sN3S\fN4OR!lJL2(a=:b4Ff&7E;q)O_P#WqT?;^@'e:m]2q4Y\']#rYS`0J,.E<b/Dai'5?mlc6AhJna\JGg_:Yc:98:nsRLal!#c+EsM(<tb(J^cTEVjq!Ll`V)/=\n1?2VP!nUibVne=IuE;BNdBFoimp4D_?%=$%6*-QW3f0rnX&<H85'1fg=@QlClK`^N0$X\\#1DFp#6kjo8*>*l66RfIdu>~>endstream
|
||||
endobj
|
||||
xref
|
||||
0 11
|
||||
0000000000 65535 f
|
||||
0000000061 00000 n
|
||||
0000000122 00000 n
|
||||
0000000229 00000 n
|
||||
0000000337 00000 n
|
||||
0000001270 00000 n
|
||||
0000001379 00000 n
|
||||
0000001583 00000 n
|
||||
0000001651 00000 n
|
||||
0000001931 00000 n
|
||||
0000001990 00000 n
|
||||
trailer
|
||||
<<
|
||||
/ID
|
||||
[<6167f87b12d550221d4edd2d2c131d94><6167f87b12d550221d4edd2d2c131d94>]
|
||||
% ReportLab generated PDF document -- digest (opensource)
|
||||
|
||||
/Info 8 0 R
|
||||
/Root 7 0 R
|
||||
/Size 11
|
||||
>>
|
||||
startxref
|
||||
3729
|
||||
%%EOF
|
||||
@@ -0,0 +1,614 @@
|
||||
"""Tests for the anydoc parser (the default ``DOC_PARSER_ENGINE``).
|
||||
|
||||
Three behaviours matter and are asserted separately:
|
||||
|
||||
* a file anydoc converts comes back as Markdown, with ``last_engine`` set;
|
||||
* a file it refuses (a scanned PDF, an unknown format) goes to the fallback
|
||||
parser when one is configured, and every failure surfaces as
|
||||
``DocumentParseError`` — never as an empty document or a raw traceback;
|
||||
* the suffix list the parser map is built from matches what the installed
|
||||
anydoc actually accepts.
|
||||
"""
|
||||
|
||||
import importlib.machinery
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from application.parser.file.base_parser import BaseParser, DocumentParseError
|
||||
|
||||
anydoc = pytest.importorskip("anydoc")
|
||||
|
||||
from application.parser.file.anydoc_parser import ( # noqa: E402 — after importorskip
|
||||
ANYDOC_SUFFIXES,
|
||||
AnydocParser,
|
||||
anydoc_available,
|
||||
)
|
||||
|
||||
|
||||
# Long enough to clear the scanned-PDF near-empty guard (_MIN_SCAN_FALLBACK_CHARS).
|
||||
FALLBACK_TEXT = "fallback parser text output, long enough to clear the scanned-PDF guard."
|
||||
|
||||
|
||||
class _RecordingFallback(BaseParser):
|
||||
"""Stands in for docling / a legacy parser so delegation is observable."""
|
||||
|
||||
def __init__(self, result=FALLBACK_TEXT):
|
||||
super().__init__(parser_config={})
|
||||
self.calls = []
|
||||
self._result = result
|
||||
|
||||
def _init_parser(self):
|
||||
return {}
|
||||
|
||||
def parse_file(self, file, errors="ignore"):
|
||||
self.calls.append(file)
|
||||
return self._result
|
||||
|
||||
|
||||
class _ExplodingFallback(BaseParser):
|
||||
def _init_parser(self):
|
||||
return {}
|
||||
|
||||
def parse_file(self, file, errors="ignore"):
|
||||
raise RuntimeError("fallback blew up")
|
||||
|
||||
|
||||
def _scanned_pdf(path, pages=2):
|
||||
"""A PDF with pages but no text layer: what a scan looks like to a parser."""
|
||||
from pypdf import PdfWriter
|
||||
|
||||
writer = PdfWriter()
|
||||
for _ in range(pages):
|
||||
writer.add_blank_page(width=612, height=792)
|
||||
with open(path, "wb") as fh:
|
||||
writer.write(fh)
|
||||
return path
|
||||
|
||||
|
||||
def _fake_anydoc(monkeypatch, to_markdown):
|
||||
"""Install a stub ``anydoc`` module whose ``to_markdown`` is ``to_markdown``."""
|
||||
fake = types.ModuleType("anydoc")
|
||||
fake.__spec__ = importlib.machinery.ModuleSpec("anydoc", None)
|
||||
|
||||
class ConvertError(Exception):
|
||||
pass
|
||||
|
||||
class UnsupportedError(ConvertError):
|
||||
pass
|
||||
|
||||
class ResourceLimitError(ConvertError):
|
||||
pass
|
||||
|
||||
fake.ConvertError = ConvertError
|
||||
fake.UnsupportedError = UnsupportedError
|
||||
fake.ResourceLimitError = ResourceLimitError
|
||||
fake.to_markdown = to_markdown
|
||||
monkeypatch.setitem(sys.modules, "anydoc", fake)
|
||||
return fake
|
||||
|
||||
|
||||
# --- the suffix list is what anydoc really supports ---------------------------
|
||||
|
||||
|
||||
def test_every_listed_suffix_is_an_anydoc_format():
|
||||
for suffix in ANYDOC_SUFFIXES:
|
||||
assert anydoc.format_from_extension(suffix) is not None, suffix
|
||||
|
||||
|
||||
def test_epub_and_html_are_not_routed_to_anydoc():
|
||||
assert ".epub" not in ANYDOC_SUFFIXES
|
||||
assert ".html" not in ANYDOC_SUFFIXES
|
||||
|
||||
|
||||
# --- successful conversion -----------------------------------------------------
|
||||
|
||||
|
||||
def test_csv_converts_to_markdown_table(tmp_path):
|
||||
path = tmp_path / "items.csv"
|
||||
path.write_text("name,qty\nwidget,3\ngadget,5\n")
|
||||
parser = AnydocParser()
|
||||
parser.init_parser()
|
||||
|
||||
out = parser.parse_file(path)
|
||||
|
||||
assert isinstance(out, str)
|
||||
assert "widget" in out and "gadget" in out
|
||||
assert "|" in out # rendered as a GFM table, not flat text
|
||||
assert parser.last_engine == "anydoc"
|
||||
|
||||
|
||||
def test_xlsx_converts_with_cell_text(tmp_path):
|
||||
openpyxl = pytest.importorskip("openpyxl")
|
||||
path = tmp_path / "book.xlsx"
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.append(["city", "population"])
|
||||
ws.append(["Ljubljana", 295000])
|
||||
wb.save(path)
|
||||
|
||||
out = AnydocParser().parse_file(path)
|
||||
|
||||
assert "Ljubljana" in out
|
||||
assert "295000" in out
|
||||
|
||||
|
||||
def test_accepts_path_and_str(tmp_path):
|
||||
path = tmp_path / "a.csv"
|
||||
path.write_text("x,y\n1,2\n")
|
||||
assert AnydocParser().parse_file(str(path)) == AnydocParser().parse_file(path)
|
||||
|
||||
|
||||
# --- refusal routes to the fallback --------------------------------------------
|
||||
|
||||
|
||||
def test_scanned_pdf_delegates_to_fallback(tmp_path):
|
||||
path = _scanned_pdf(tmp_path / "scan.pdf")
|
||||
fallback = _RecordingFallback()
|
||||
parser = AnydocParser(fallback_parser=fallback)
|
||||
|
||||
out = parser.parse_file(path)
|
||||
|
||||
assert out == FALLBACK_TEXT
|
||||
assert fallback.calls == [path]
|
||||
assert parser.last_engine == "_RecordingFallback"
|
||||
|
||||
|
||||
def test_fallback_last_engine_is_reported_when_it_has_one(tmp_path):
|
||||
path = _scanned_pdf(tmp_path / "scan.pdf")
|
||||
fallback = _RecordingFallback()
|
||||
fallback.last_engine = "pypdfium2"
|
||||
|
||||
parser = AnydocParser(fallback_parser=fallback)
|
||||
parser.parse_file(path)
|
||||
|
||||
assert parser.last_engine == "pypdfium2"
|
||||
|
||||
|
||||
def test_scanned_pdf_without_fallback_is_document_parse_error(tmp_path):
|
||||
path = _scanned_pdf(tmp_path / "scan.pdf")
|
||||
parser = AnydocParser()
|
||||
|
||||
with pytest.raises(DocumentParseError, match="scan.pdf"):
|
||||
parser.parse_file(path)
|
||||
assert parser.last_engine is None
|
||||
|
||||
|
||||
def test_unknown_format_without_fallback_is_document_parse_error(tmp_path):
|
||||
path = tmp_path / "blob.xyz"
|
||||
path.write_bytes(b"hello")
|
||||
|
||||
with pytest.raises(DocumentParseError, match="blob.xyz"):
|
||||
AnydocParser().parse_file(path)
|
||||
|
||||
|
||||
def test_fallback_failure_is_document_parse_error(tmp_path):
|
||||
"""A fallback that raises must not leak a bare exception (load_data skips only DocumentParseError)."""
|
||||
path = _scanned_pdf(tmp_path / "scan.pdf")
|
||||
|
||||
with pytest.raises(DocumentParseError):
|
||||
AnydocParser(fallback_parser=_ExplodingFallback()).parse_file(path)
|
||||
|
||||
|
||||
def test_fallback_document_parse_error_passes_through(tmp_path):
|
||||
class _Refusing(BaseParser):
|
||||
def _init_parser(self):
|
||||
return {}
|
||||
|
||||
def parse_file(self, file, errors="ignore"):
|
||||
raise DocumentParseError("scan needs OCR")
|
||||
|
||||
path = _scanned_pdf(tmp_path / "scan.pdf")
|
||||
with pytest.raises(DocumentParseError, match="scan needs OCR"):
|
||||
AnydocParser(fallback_parser=_Refusing()).parse_file(path)
|
||||
|
||||
|
||||
def test_missing_file_is_document_parse_error(tmp_path):
|
||||
with pytest.raises(DocumentParseError, match="could not be read"):
|
||||
AnydocParser().parse_file(tmp_path / "missing.docx")
|
||||
|
||||
|
||||
def test_empty_output_delegates(tmp_path, monkeypatch):
|
||||
"""Whitespace-only output is treated as a failed conversion, not stored."""
|
||||
_fake_anydoc(monkeypatch, lambda path: " \n\n ")
|
||||
fallback = _RecordingFallback("real text")
|
||||
path = tmp_path / "x.docx"
|
||||
path.write_bytes(b"irrelevant")
|
||||
|
||||
out = AnydocParser(fallback_parser=fallback).parse_file(path)
|
||||
|
||||
assert out == "real text"
|
||||
assert fallback.calls == [path]
|
||||
|
||||
|
||||
def test_typed_convert_error_delegates(tmp_path, monkeypatch):
|
||||
fake = _fake_anydoc(monkeypatch, None)
|
||||
|
||||
def _refuse(path):
|
||||
raise fake.UnsupportedError("PDF has no extractable text; OCR is required")
|
||||
|
||||
fake.to_markdown = _refuse
|
||||
fallback = _RecordingFallback()
|
||||
path = tmp_path / "x.pdf"
|
||||
path.write_bytes(b"%PDF-1.4")
|
||||
|
||||
assert AnydocParser(fallback_parser=fallback).parse_file(path) == FALLBACK_TEXT
|
||||
|
||||
|
||||
def test_typed_convert_error_message_reaches_the_user(tmp_path, monkeypatch):
|
||||
fake = _fake_anydoc(monkeypatch, None)
|
||||
|
||||
def _refuse(path):
|
||||
raise fake.UnsupportedError("PDF has no extractable text; OCR is required")
|
||||
|
||||
fake.to_markdown = _refuse
|
||||
path = tmp_path / "x.pdf"
|
||||
path.write_bytes(b"%PDF-1.4")
|
||||
|
||||
with pytest.raises(DocumentParseError, match="OCR is required"):
|
||||
AnydocParser().parse_file(path)
|
||||
|
||||
|
||||
def test_resource_limit_is_terminal_even_with_fallback(tmp_path, monkeypatch):
|
||||
"""anydoc refusing a file as too expensive must not hand it to a heavier
|
||||
engine that would then spend exactly what was refused."""
|
||||
fake = _fake_anydoc(monkeypatch, None)
|
||||
|
||||
def _refuse(path):
|
||||
raise fake.ResourceLimitError(
|
||||
"resource limit exceeded (max_entry_bytes): word/document.xml declares 400400167 bytes"
|
||||
)
|
||||
|
||||
fake.to_markdown = _refuse
|
||||
fallback = _RecordingFallback()
|
||||
parser = AnydocParser(fallback_parser=fallback)
|
||||
path = tmp_path / "bomb.docx"
|
||||
path.write_bytes(b"PK")
|
||||
|
||||
with pytest.raises(DocumentParseError, match="max_entry_bytes"):
|
||||
parser.parse_file(path)
|
||||
assert fallback.calls == []
|
||||
assert parser.last_engine is None
|
||||
|
||||
|
||||
def test_unexpected_exception_is_document_parse_error(tmp_path, monkeypatch):
|
||||
def _crash(path):
|
||||
raise RuntimeError("panic")
|
||||
|
||||
_fake_anydoc(monkeypatch, _crash)
|
||||
path = tmp_path / "x.docx"
|
||||
path.write_bytes(b"irrelevant")
|
||||
|
||||
with pytest.raises(DocumentParseError, match="x.docx"):
|
||||
AnydocParser().parse_file(path)
|
||||
|
||||
|
||||
# --- init / availability --------------------------------------------------------
|
||||
|
||||
|
||||
def test_init_parser_records_fallback():
|
||||
parser = AnydocParser(fallback_parser=_RecordingFallback())
|
||||
parser.init_parser()
|
||||
assert parser.parser_config == {"fallback_parser": "_RecordingFallback"}
|
||||
|
||||
bare = AnydocParser()
|
||||
bare.init_parser()
|
||||
assert bare.parser_config == {"fallback_parser": None}
|
||||
|
||||
|
||||
def test_init_parser_without_anydoc_raises_import_error(monkeypatch):
|
||||
monkeypatch.setitem(sys.modules, "anydoc", None)
|
||||
assert not anydoc_available()
|
||||
with pytest.raises(ImportError, match="firecrawl-anydoc"):
|
||||
AnydocParser().init_parser()
|
||||
|
||||
|
||||
def test_init_parser_imports_for_real_not_just_find_spec(monkeypatch):
|
||||
"""A wheel whose native extension fails to load has a spec but no importable
|
||||
module; that must surface at init, not as a bare ImportError from parse_file
|
||||
mid-ingest (which load_data does not catch)."""
|
||||
from application.parser.file import anydoc_parser as mod
|
||||
|
||||
monkeypatch.setattr(mod, "anydoc_available", lambda: True)
|
||||
monkeypatch.setitem(sys.modules, "anydoc", None)
|
||||
|
||||
with pytest.raises(ImportError, match="firecrawl-anydoc"):
|
||||
AnydocParser().init_parser()
|
||||
|
||||
|
||||
# --- PDF trust check + tableize wiring (PR 3) -----------------------------------
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures"
|
||||
CID_PDF = FIXTURES / "nda_en_zh_cid_font.pdf"
|
||||
|
||||
|
||||
# Long enough to clear the near-empty guard a trust-check re-parse must pass.
|
||||
_REROUTE_TEXT = "docling reroute text, long enough to clear the near-empty reroute guard"
|
||||
|
||||
|
||||
class _FakeDoclingFallback:
|
||||
"""Registered as a DoclingParser subclass so ``_is_docling_backed`` is True."""
|
||||
|
||||
def __new__(cls):
|
||||
from application.parser.file.docling_parser import DoclingParser
|
||||
|
||||
class _Inner(DoclingParser):
|
||||
def __init__(self):
|
||||
super().__init__(ocr_enabled=False)
|
||||
self._parser_config = {}
|
||||
self.calls = []
|
||||
self.last_engine = None
|
||||
|
||||
def parse_file(self, file, errors="ignore"):
|
||||
self.calls.append(file)
|
||||
return _REROUTE_TEXT
|
||||
|
||||
return _Inner()
|
||||
|
||||
|
||||
def test_trust_flagged_pdf_reroutes_to_docling_fallback():
|
||||
fallback = _FakeDoclingFallback()
|
||||
parser = AnydocParser(fallback_parser=fallback)
|
||||
|
||||
out = parser.parse_file(CID_PDF)
|
||||
|
||||
assert out == _REROUTE_TEXT
|
||||
assert fallback.calls == [CID_PDF]
|
||||
assert parser.last_engine == "_Inner"
|
||||
assert parser.get_file_metadata(CID_PDF) == {} # rerouted, nothing to warn about
|
||||
|
||||
|
||||
def test_trust_flagged_pdf_without_docling_keeps_output_and_warns():
|
||||
parser = AnydocParser(fallback_parser=_RecordingFallback()) # not docling-backed
|
||||
|
||||
out = parser.parse_file(CID_PDF)
|
||||
|
||||
assert "NON-DISCLOSURE" in out.upper() or len(out) > 50 # anydoc's own output kept
|
||||
meta = parser.get_file_metadata(CID_PDF)
|
||||
assert "parse_warnings" in meta
|
||||
assert any("ToUnicode" in w for w in meta["parse_warnings"])
|
||||
assert any("CJK" in w for w in meta["parse_warnings"])
|
||||
assert parser.last_engine == "anydoc"
|
||||
|
||||
|
||||
def test_trust_check_disabled_stamps_nothing(monkeypatch):
|
||||
from application.parser.file import anydoc_parser as ap
|
||||
|
||||
monkeypatch.setattr(ap.settings, "PDF_TRUST_CHECK", False)
|
||||
parser = AnydocParser()
|
||||
|
||||
parser.parse_file(CID_PDF)
|
||||
|
||||
assert parser.get_file_metadata(CID_PDF) == {}
|
||||
|
||||
|
||||
def test_trust_reroute_failure_keeps_anydoc_output():
|
||||
fallback = _FakeDoclingFallback()
|
||||
|
||||
def _boom(file, errors="ignore"):
|
||||
raise RuntimeError("docling exploded")
|
||||
|
||||
fallback.parse_file = _boom
|
||||
parser = AnydocParser(fallback_parser=fallback)
|
||||
|
||||
out = parser.parse_file(CID_PDF)
|
||||
|
||||
assert "docling" not in out
|
||||
assert "parse_warnings" in parser.get_file_metadata(CID_PDF)
|
||||
assert parser.last_engine == "anydoc"
|
||||
|
||||
|
||||
def test_trust_reroute_near_empty_keeps_anydoc_output():
|
||||
"""A docling pipeline dropout ('' / '<!-- image -->') returns without
|
||||
raising; adopting it would swap anydoc's real text for an empty document."""
|
||||
fallback = _FakeDoclingFallback()
|
||||
fallback.parse_file = lambda file, errors="ignore": "<!-- image -->"
|
||||
parser = AnydocParser(fallback_parser=fallback)
|
||||
|
||||
out = parser.parse_file(CID_PDF)
|
||||
|
||||
assert "<!-- image -->" not in out
|
||||
assert "parse_warnings" in parser.get_file_metadata(CID_PDF)
|
||||
assert parser.last_engine == "anydoc"
|
||||
|
||||
|
||||
def test_warnings_reset_between_files(tmp_path):
|
||||
parser = AnydocParser()
|
||||
parser.parse_file(CID_PDF)
|
||||
assert parser.get_file_metadata(CID_PDF) != {}
|
||||
|
||||
clean = tmp_path / "clean.csv"
|
||||
clean.write_text("a,b\n1,2\n")
|
||||
parser.parse_file(clean)
|
||||
|
||||
assert parser.get_file_metadata(clean) == {}
|
||||
assert parser.get_file_metadata(CID_PDF) == {}
|
||||
|
||||
|
||||
def test_trust_check_errors_never_fail_the_parse(monkeypatch, tmp_path):
|
||||
def _explode(path, markdown):
|
||||
raise RuntimeError("scanner bug")
|
||||
|
||||
import application.parser.file.pdf_trust as pt
|
||||
|
||||
monkeypatch.setattr(pt, "verify_pdf_file", _explode)
|
||||
_fake_anydoc(monkeypatch, lambda path: "# converted fine")
|
||||
path = tmp_path / "x.pdf"
|
||||
path.write_bytes(b"%PDF-1.4 irrelevant")
|
||||
|
||||
assert AnydocParser().parse_file(path) == "# converted fine"
|
||||
|
||||
|
||||
def test_tableize_applied_when_enabled(monkeypatch, tmp_path):
|
||||
from application.parser.file import anydoc_parser as ap
|
||||
|
||||
monkeypatch.setattr(ap.settings, "ANYDOC_TABLEIZE", True)
|
||||
monkeypatch.setattr(ap.settings, "PDF_TRUST_CHECK", False)
|
||||
_fake_anydoc(
|
||||
monkeypatch,
|
||||
lambda path: "Cash ..... 1,234 900\nDebt ..... 2,000 1,500\nEquity ..... 900 800",
|
||||
)
|
||||
path = tmp_path / "x.pdf"
|
||||
path.write_bytes(b"%PDF-1.4 irrelevant")
|
||||
|
||||
out = AnydocParser().parse_file(path)
|
||||
|
||||
assert "| Cash | 1,234 | 900 |" in out
|
||||
|
||||
|
||||
def test_tableize_disabled_by_default(monkeypatch, tmp_path):
|
||||
from application.parser.file import anydoc_parser as ap
|
||||
|
||||
monkeypatch.setattr(ap.settings, "PDF_TRUST_CHECK", False)
|
||||
flat = "Cash ..... 1,234 900\nDebt ..... 2,000 1,500\nEquity ..... 900 800"
|
||||
_fake_anydoc(monkeypatch, lambda path: flat)
|
||||
path = tmp_path / "x.pdf"
|
||||
path.write_bytes(b"%PDF-1.4 irrelevant")
|
||||
|
||||
assert AnydocParser().parse_file(path) == flat
|
||||
|
||||
|
||||
def test_tableize_never_touches_docling_reroute(monkeypatch):
|
||||
from application.parser.file import anydoc_parser as ap
|
||||
|
||||
monkeypatch.setattr(ap.settings, "ANYDOC_TABLEIZE", True)
|
||||
fallback = _FakeDoclingFallback()
|
||||
parser = AnydocParser(fallback_parser=fallback)
|
||||
|
||||
out = parser.parse_file(CID_PDF)
|
||||
|
||||
assert out == _REROUTE_TEXT # verbatim, not post-processed
|
||||
|
||||
|
||||
# --- scanned-PDF near-empty guard (the OCR_ENGINE seam's loud-failure side) ------
|
||||
|
||||
|
||||
def test_scanned_pdf_with_near_empty_fallback_fails_loudly_when_ocr_off(tmp_path):
|
||||
"""A scan whose fallback (OCR off) extracts almost nothing must fail with
|
||||
an actionable message, not be stored as an empty document."""
|
||||
path = _scanned_pdf(tmp_path / "scan.pdf")
|
||||
fallback = _RecordingFallback(result=" ")
|
||||
fallback.ocr_enabled = False
|
||||
parser = AnydocParser(fallback_parser=fallback)
|
||||
|
||||
with pytest.raises(DocumentParseError, match="OCR_ENABLED"):
|
||||
parser.parse_file(path)
|
||||
assert parser.last_engine is None
|
||||
|
||||
|
||||
def test_scanned_pdf_with_near_empty_fallback_and_ocr_on_reports_it(tmp_path):
|
||||
path = _scanned_pdf(tmp_path / "scan.pdf")
|
||||
fallback = _RecordingFallback(result="x")
|
||||
fallback.ocr_enabled = True
|
||||
|
||||
with pytest.raises(DocumentParseError, match="even with OCR enabled"):
|
||||
AnydocParser(fallback_parser=fallback).parse_file(path)
|
||||
|
||||
|
||||
def test_scanned_pdf_with_substantial_fallback_output_passes(tmp_path):
|
||||
"""docling extracting a text layer anydoc refused (the HK-bill case) must
|
||||
keep working — the guard only fires on near-empty results."""
|
||||
path = _scanned_pdf(tmp_path / "scan.pdf")
|
||||
out = AnydocParser(fallback_parser=_RecordingFallback()).parse_file(path)
|
||||
assert out == FALLBACK_TEXT
|
||||
|
||||
|
||||
def test_non_scan_refusals_do_not_trigger_the_guard(tmp_path, monkeypatch):
|
||||
"""Only the OCR-required refusal implies 'content exists but needs OCR';
|
||||
a malformed file with a short fallback result stays a successful parse."""
|
||||
fake = _fake_anydoc(monkeypatch, None)
|
||||
|
||||
class MalformedError(fake.ConvertError):
|
||||
pass
|
||||
|
||||
fake.MalformedError = MalformedError
|
||||
|
||||
def _refuse(path):
|
||||
raise MalformedError("structurally unusable")
|
||||
|
||||
fake.to_markdown = _refuse
|
||||
path = tmp_path / "x.docx"
|
||||
path.write_bytes(b"irrelevant")
|
||||
|
||||
out = AnydocParser(fallback_parser=_RecordingFallback(result="tiny")).parse_file(path)
|
||||
assert out == "tiny"
|
||||
|
||||
|
||||
# --- spreadsheets: a resource limit delegates, other formats stay terminal ----
|
||||
|
||||
|
||||
def test_resource_limit_on_a_spreadsheet_delegates_to_the_tabular_fallback(tmp_path, monkeypatch):
|
||||
"""anydoc refuses a 265k-row sheet on fixed limits that openpyxl/pandas
|
||||
read fine, so for tabular suffixes the limit is a reason to fall back."""
|
||||
fake = _fake_anydoc(monkeypatch, None)
|
||||
|
||||
def _refuse(path):
|
||||
raise fake.ResourceLimitError("resource limit exceeded (max_xml_nodes): 2000000")
|
||||
|
||||
fake.to_markdown = _refuse
|
||||
fallback = _RecordingFallback()
|
||||
parser = AnydocParser(fallback_parser=fallback)
|
||||
path = tmp_path / "big.xlsx"
|
||||
path.write_bytes(b"PK")
|
||||
|
||||
assert parser.parse_file(path) == FALLBACK_TEXT
|
||||
assert fallback.calls == [path]
|
||||
assert parser.last_engine == "_RecordingFallback"
|
||||
|
||||
|
||||
def test_resource_limit_on_a_spreadsheet_without_fallback_is_terminal(tmp_path, monkeypatch):
|
||||
fake = _fake_anydoc(monkeypatch, None)
|
||||
|
||||
def _refuse(path):
|
||||
raise fake.ResourceLimitError("resource limit exceeded (max_entry_bytes)")
|
||||
|
||||
fake.to_markdown = _refuse
|
||||
path = tmp_path / "big.xlsx"
|
||||
path.write_bytes(b"PK")
|
||||
|
||||
with pytest.raises(DocumentParseError, match="max_entry_bytes"):
|
||||
AnydocParser().parse_file(path)
|
||||
|
||||
|
||||
# --- mixed documents: one page's OCR failure costs that page only ------------
|
||||
|
||||
|
||||
class _PerPageOcrFallback(BaseParser):
|
||||
ocr_enabled = True
|
||||
|
||||
def __init__(self, failing_index):
|
||||
super().__init__()
|
||||
self.failing_index = failing_index
|
||||
self.requests = []
|
||||
|
||||
def _init_parser(self):
|
||||
return {}
|
||||
|
||||
def parse_file(self, file, errors="ignore"):
|
||||
return "unused"
|
||||
|
||||
def ocr_pages(self, file, indices):
|
||||
self.requests.append(list(indices))
|
||||
if self.failing_index in indices:
|
||||
raise DocumentParseError("tesseract failed on this page")
|
||||
return {index: f"OCR TEXT PAGE {index + 1}" for index in indices}
|
||||
|
||||
|
||||
def test_one_failing_page_does_not_drop_the_other_pages_ocr(tmp_path, monkeypatch):
|
||||
pytest.importorskip("pypdfium2")
|
||||
pytest.importorskip("pypdf")
|
||||
_fake_anydoc(monkeypatch, lambda path: "Text pages read by anydoc.")
|
||||
fallback = _PerPageOcrFallback(failing_index=0)
|
||||
parser = AnydocParser(fallback_parser=fallback)
|
||||
path = _scanned_pdf(tmp_path / "mixed.pdf", pages=3) # every page probes as scanned
|
||||
|
||||
text = parser.parse_file(path)
|
||||
|
||||
assert fallback.requests == [[0], [1], [2]]
|
||||
assert "OCR TEXT PAGE 1" not in text
|
||||
assert "OCR TEXT PAGE 2" in text and "OCR TEXT PAGE 3" in text
|
||||
assert text.startswith("Text pages read by anydoc.")
|
||||
# (The font-less blank-page fixture also earns a trust-check warning.)
|
||||
assert parser.get_file_metadata(path)["ocr_pages"] == 2
|
||||
@@ -508,3 +508,257 @@ class TestGetDefaultFileExtractor:
|
||||
mock_fn.return_value = {".pdf": MagicMock(), ".md": MagicMock()}
|
||||
result = mock_fn()
|
||||
assert ".pdf" in result
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# DOC_PARSER_ENGINE switch
|
||||
# =====================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestParserEngineSwitch:
|
||||
"""``get_default_file_extractor`` honours ``DOC_PARSER_ENGINE`` / ``engine``."""
|
||||
|
||||
@pytest.fixture
|
||||
def settings(self):
|
||||
from application.core.settings import settings
|
||||
|
||||
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
|
||||
from application.parser.file.bulk import get_default_file_extractor
|
||||
from application.parser.file.html_parser import HTMLMarkdownParser
|
||||
|
||||
monkeypatch.setattr(settings, "DOC_PARSER_ENGINE", "anydoc")
|
||||
extractor = get_default_file_extractor()
|
||||
|
||||
for suffix in ANYDOC_SUFFIXES:
|
||||
assert isinstance(extractor[suffix], AnydocParser), suffix
|
||||
assert isinstance(extractor[".html"], HTMLMarkdownParser)
|
||||
assert isinstance(extractor[".xhtml"], HTMLMarkdownParser)
|
||||
# Specialised parsers are untouched by the engine choice.
|
||||
assert type(extractor[".md"]).__name__ == "MarkdownParser"
|
||||
assert type(extractor[".json"]).__name__ == "JSONParser"
|
||||
assert type(extractor[".epub"]).__name__ == "EpubParser"
|
||||
|
||||
def test_anydoc_falls_back_to_docling_when_installed(self):
|
||||
pytest.importorskip("anydoc")
|
||||
pytest.importorskip("docling")
|
||||
from application.parser.file.bulk import get_default_file_extractor
|
||||
|
||||
extractor = get_default_file_extractor(engine="anydoc")
|
||||
|
||||
assert type(extractor[".pdf"].fallback_parser).__name__ == "DoclingPDFParser"
|
||||
assert type(extractor[".csv"].fallback_parser).__name__ == "DoclingCSVParser"
|
||||
assert extractor[".doc"].fallback_parser is None # nothing else reads .doc
|
||||
# docling keeps the formats anydoc cannot read.
|
||||
assert type(extractor[".vtt"]).__name__ == "DoclingVTTParser"
|
||||
|
||||
def test_anydoc_fallback_honours_ocr_flag(self):
|
||||
pytest.importorskip("anydoc")
|
||||
pytest.importorskip("docling")
|
||||
from application.parser.file.bulk import get_default_file_extractor
|
||||
|
||||
extractor = get_default_file_extractor(engine="anydoc", ocr_enabled=True)
|
||||
|
||||
assert extractor[".pdf"].fallback_parser.ocr_enabled is True
|
||||
assert type(extractor[".png"]).__name__ == "DoclingImageParser"
|
||||
|
||||
def test_anydoc_falls_back_to_legacy_without_docling(self, monkeypatch):
|
||||
pytest.importorskip("anydoc")
|
||||
import sys
|
||||
|
||||
from application.parser.file.bulk import get_default_file_extractor
|
||||
|
||||
monkeypatch.setitem(sys.modules, "docling", None)
|
||||
extractor = get_default_file_extractor(engine="anydoc")
|
||||
|
||||
assert type(extractor[".pdf"].fallback_parser).__name__ == "PDFParser"
|
||||
assert type(extractor[".xlsx"].fallback_parser).__name__ == "ExcelParser"
|
||||
assert type(extractor[".png"]).__name__ == "ImageParser"
|
||||
|
||||
def test_docling_engine_keeps_docling_map(self):
|
||||
pytest.importorskip("docling")
|
||||
from application.parser.file.bulk import get_default_file_extractor
|
||||
|
||||
extractor = get_default_file_extractor(engine="docling")
|
||||
|
||||
assert type(extractor[".pdf"]).__name__ == "DoclingPDFParser"
|
||||
assert type(extractor[".html"]).__name__ == "DoclingHTMLParser"
|
||||
|
||||
def test_setting_selects_docling(self, settings, monkeypatch):
|
||||
pytest.importorskip("docling")
|
||||
from application.parser.file.bulk import get_default_file_extractor
|
||||
|
||||
monkeypatch.setattr(settings, "DOC_PARSER_ENGINE", "docling")
|
||||
assert type(get_default_file_extractor()[".pdf"]).__name__ == "DoclingPDFParser"
|
||||
|
||||
def test_unknown_engine_uses_anydoc(self):
|
||||
pytest.importorskip("anydoc")
|
||||
from application.parser.file.anydoc_parser import AnydocParser
|
||||
from application.parser.file.bulk import get_default_file_extractor
|
||||
|
||||
assert isinstance(get_default_file_extractor(engine="ghost")[".pdf"], AnydocParser)
|
||||
|
||||
def test_missing_anydoc_degrades_to_base_engine(self, monkeypatch):
|
||||
import sys
|
||||
|
||||
from application.parser.file.bulk import get_default_file_extractor
|
||||
|
||||
monkeypatch.setitem(sys.modules, "anydoc", None)
|
||||
extractor = get_default_file_extractor(engine="anydoc")
|
||||
|
||||
assert type(extractor[".pdf"]).__name__ in ("DoclingPDFParser", "PDFParser")
|
||||
assert type(extractor[".html"]).__name__ in ("DoclingHTMLParser", "HTMLParser")
|
||||
|
||||
def test_missing_docling_map_is_usable(self, monkeypatch):
|
||||
"""Regression: with docling absent the map handed out docling parsers whose
|
||||
``init_parser`` raised ``ImportError`` — not a ``DocumentParseError`` — so
|
||||
the first file aborted the whole ingest instead of degrading."""
|
||||
import sys
|
||||
|
||||
from application.parser.file.bulk import get_default_file_extractor
|
||||
|
||||
monkeypatch.setitem(sys.modules, "docling", None)
|
||||
extractor = get_default_file_extractor(engine="docling")
|
||||
|
||||
assert type(extractor[".pdf"]).__name__ == "PDFParser"
|
||||
assert ".xhtml" in extractor
|
||||
for suffix in (".pdf", ".docx", ".csv", ".xlsx", ".html", ".xhtml", ".pptx"):
|
||||
extractor[suffix].init_parser()
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# Gained formats (anydoc-only: legacy/macro Office, OpenDocument, RTF)
|
||||
# =====================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestGainedFormats:
|
||||
def test_every_supported_extension_has_a_parser_under_both_engines(self):
|
||||
"""The invariant that keeps constants.py and the parser maps in lockstep.
|
||||
|
||||
Without it, an extension accepted at upload but missing from the map
|
||||
falls to SimpleDirectoryReader's plain-text read — for a binary
|
||||
format that means mojibake silently ingested and embedded.
|
||||
(`.txt` is the one deliberate plain-text read.)
|
||||
"""
|
||||
pytest.importorskip("anydoc")
|
||||
from application.parser.file.bulk import get_default_file_extractor
|
||||
from application.parser.file.constants import (
|
||||
SUPPORTED_SOURCE_DOCUMENT_EXTENSIONS,
|
||||
)
|
||||
|
||||
for engine in ("anydoc", "docling"):
|
||||
extractor = get_default_file_extractor(engine=engine)
|
||||
missing = [
|
||||
suffix
|
||||
for suffix in SUPPORTED_SOURCE_DOCUMENT_EXTENSIONS
|
||||
if suffix != ".txt" and suffix not in extractor
|
||||
]
|
||||
assert missing == [], (engine, missing)
|
||||
|
||||
def test_gained_formats_map_to_anydoc_under_both_engines(self):
|
||||
pytest.importorskip("anydoc")
|
||||
from application.parser.file.anydoc_parser import (
|
||||
ANYDOC_GAINED_SUFFIXES,
|
||||
AnydocParser,
|
||||
)
|
||||
from application.parser.file.bulk import get_default_file_extractor
|
||||
|
||||
for engine in ("anydoc", "docling"):
|
||||
extractor = get_default_file_extractor(engine=engine)
|
||||
for suffix in ANYDOC_GAINED_SUFFIXES:
|
||||
assert isinstance(extractor[suffix], AnydocParser), (engine, suffix)
|
||||
|
||||
def test_gained_formats_never_fall_back_to_anydoc_itself(self):
|
||||
pytest.importorskip("anydoc")
|
||||
from application.parser.file.anydoc_parser import AnydocParser
|
||||
from application.parser.file.bulk import get_default_file_extractor
|
||||
|
||||
extractor = get_default_file_extractor(engine="anydoc")
|
||||
assert extractor[".doc"].fallback_parser is None
|
||||
assert extractor[".rtf"].fallback_parser is None
|
||||
# ...while the core five keep their real fallback.
|
||||
assert extractor[".pdf"].fallback_parser is not None
|
||||
assert not isinstance(extractor[".pdf"].fallback_parser, AnydocParser)
|
||||
|
||||
def test_gained_entries_absent_without_anydoc(self, monkeypatch):
|
||||
import sys
|
||||
|
||||
from application.parser.file.bulk import get_default_file_extractor
|
||||
|
||||
monkeypatch.setitem(sys.modules, "anydoc", None)
|
||||
extractor = get_default_file_extractor(engine="docling")
|
||||
assert ".doc" not in extractor # degrades to the pre-anydoc map
|
||||
|
||||
def test_rtf_converts_end_to_end(self, tmp_path):
|
||||
pytest.importorskip("anydoc")
|
||||
from application.parser.file.bulk import get_default_file_extractor
|
||||
|
||||
path = tmp_path / "note.rtf"
|
||||
path.write_text(r"{\rtf1\ansi Hello {\b bold} world.\par Second paragraph.}")
|
||||
parser = get_default_file_extractor()[".rtf"]
|
||||
parser.init_parser()
|
||||
|
||||
out = parser.parse_file(path)
|
||||
|
||||
assert "Hello **bold** world." in out
|
||||
assert "Second paragraph." in out
|
||||
|
||||
def test_odt_converts_end_to_end(self, tmp_path):
|
||||
pytest.importorskip("anydoc")
|
||||
import zipfile
|
||||
|
||||
from application.parser.file.bulk import get_default_file_extractor
|
||||
|
||||
path = tmp_path / "doc.odt"
|
||||
with zipfile.ZipFile(path, "w") as z:
|
||||
z.writestr(
|
||||
"mimetype",
|
||||
"application/vnd.oasis.opendocument.text",
|
||||
compress_type=zipfile.ZIP_STORED,
|
||||
)
|
||||
z.writestr(
|
||||
"META-INF/manifest.xml",
|
||||
'<?xml version="1.0"?><manifest:manifest xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0">'
|
||||
'<manifest:file-entry manifest:full-path="/" manifest:media-type="application/vnd.oasis.opendocument.text"/>'
|
||||
'<manifest:file-entry manifest:full-path="content.xml" manifest:media-type="text/xml"/></manifest:manifest>',
|
||||
)
|
||||
z.writestr(
|
||||
"content.xml",
|
||||
'<?xml version="1.0"?><office:document-content xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" '
|
||||
'xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"><office:body><office:text>'
|
||||
'<text:h text:outline-level="1">Title</text:h><text:p>Body text here.</text:p>'
|
||||
"</office:text></office:body></office:document-content>",
|
||||
)
|
||||
parser = get_default_file_extractor()[".odt"]
|
||||
|
||||
out = parser.parse_file(path)
|
||||
|
||||
assert "# Title" in out
|
||||
assert "Body text here." in out
|
||||
|
||||
|
||||
def test_gained_suffix_without_a_parser_is_rejected_not_read_as_text(tmp_path):
|
||||
"""Without anydoc an OLE .doc must not be indexed as decoded binary garbage."""
|
||||
from application.parser.file.base_parser import DocumentParseError
|
||||
from application.parser.file.bulk import SimpleDirectoryReader
|
||||
|
||||
path = tmp_path / "legacy.doc"
|
||||
path.write_bytes(b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1" + b"\x00" * 64)
|
||||
reader = SimpleDirectoryReader(input_files=[str(path)], file_extractor={".pdf": object()})
|
||||
|
||||
with pytest.raises(DocumentParseError, match="firecrawl-anydoc"):
|
||||
reader.load_data()
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Comprehensive tests for application/parser/file/docling_parser.py
|
||||
|
||||
Covers: DoclingParser (init, _init_parser, _get_ocr_options, _export_content,
|
||||
Covers: DoclingParser (init, _init_parser, OCR engine selection, _export_content,
|
||||
parse_file), subclass initialization, error handling.
|
||||
"""
|
||||
|
||||
@@ -27,8 +27,8 @@ class TestDoclingParserInit:
|
||||
assert parser.ocr_enabled is True
|
||||
assert parser.table_structure is True
|
||||
assert parser.export_format == "markdown"
|
||||
assert parser.use_rapidocr is True
|
||||
assert parser.ocr_languages == ["english"]
|
||||
assert parser.ocr_engine is None # None -> settings.OCR_ENGINE at build
|
||||
assert parser.ocr_languages is None # None -> the engine's own default
|
||||
assert parser.force_full_page_ocr is False
|
||||
assert parser._converter is None
|
||||
|
||||
@@ -39,14 +39,14 @@ class TestDoclingParserInit:
|
||||
ocr_enabled=False,
|
||||
table_structure=False,
|
||||
export_format="text",
|
||||
use_rapidocr=False,
|
||||
ocr_engine="rapidocr",
|
||||
ocr_languages=["german"],
|
||||
force_full_page_ocr=True,
|
||||
)
|
||||
assert parser.ocr_enabled is False
|
||||
assert parser.table_structure is False
|
||||
assert parser.export_format == "text"
|
||||
assert parser.use_rapidocr is False
|
||||
assert parser.ocr_engine == "rapidocr"
|
||||
assert parser.ocr_languages == ["german"]
|
||||
assert parser.force_full_page_ocr is True
|
||||
|
||||
@@ -90,44 +90,182 @@ class TestDoclingParserInitParser:
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestGetOCROptions:
|
||||
class TestOcrEngineSelection:
|
||||
"""``OCR_ENGINE`` resolution and per-engine option building."""
|
||||
|
||||
def test_returns_none_when_rapidocr_disabled(self):
|
||||
@pytest.fixture
|
||||
def settings(self):
|
||||
from application.core.settings import settings
|
||||
|
||||
return settings
|
||||
|
||||
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
|
||||
|
||||
monkeypatch.setattr(settings, "OCR_ENGINE", "auto")
|
||||
assert _resolve_ocr_engine(None) == "auto"
|
||||
|
||||
def test_unknown_engine_degrades_to_auto(self):
|
||||
from application.parser.file.docling_parser import _resolve_ocr_engine
|
||||
|
||||
assert _resolve_ocr_engine("easyocr") == "auto"
|
||||
|
||||
def test_tesseract_without_binary_degrades_to_auto(self, monkeypatch):
|
||||
from application.parser.file import docling_parser as dp
|
||||
|
||||
monkeypatch.setattr(dp.shutil, "which", lambda name: None)
|
||||
assert dp._resolve_ocr_engine("tesseract") == "auto"
|
||||
|
||||
def test_tesseract_with_binary_selected(self, monkeypatch):
|
||||
from application.parser.file import docling_parser as dp
|
||||
|
||||
monkeypatch.setattr(dp.shutil, "which", lambda name: "/usr/bin/tesseract")
|
||||
assert dp._resolve_ocr_engine("tesseract") == "tesseract"
|
||||
|
||||
def test_ocrmac_off_darwin_degrades_to_auto(self, monkeypatch):
|
||||
from application.parser.file import docling_parser as dp
|
||||
|
||||
monkeypatch.setattr(dp.sys, "platform", "linux")
|
||||
assert dp._resolve_ocr_engine("ocrmac") == "auto"
|
||||
|
||||
def test_rapidocr_missing_degrades_to_auto(self, monkeypatch):
|
||||
import sys
|
||||
|
||||
from application.parser.file import docling_parser as dp
|
||||
|
||||
monkeypatch.setitem(sys.modules, "rapidocr", None)
|
||||
assert dp._resolve_ocr_engine("rapidocr") == "auto"
|
||||
|
||||
def test_deepseek_passes_through(self):
|
||||
from application.parser.file.docling_parser import _resolve_ocr_engine
|
||||
|
||||
assert _resolve_ocr_engine("deepseek") == "deepseek"
|
||||
|
||||
def test_build_auto_returns_none(self):
|
||||
from application.parser.file.docling_parser import _build_ocr_options
|
||||
|
||||
assert _build_ocr_options("auto", None, True) is None
|
||||
|
||||
def test_build_tesseract_reads_ocr_langs(self, settings, monkeypatch):
|
||||
pytest.importorskip("docling")
|
||||
import application.parser.file.ocr_parser as op
|
||||
from application.parser.file.docling_parser import _build_ocr_options
|
||||
|
||||
# Pack inventory unknown: the resolved list is passed through untouched
|
||||
# (a host with tesseract but no chi_sim pack would otherwise drop it).
|
||||
monkeypatch.setattr(op, "tesseract_languages", lambda: None)
|
||||
monkeypatch.setattr(settings, "OCR_LANGS", "eng+chi_sim")
|
||||
options = _build_ocr_options("tesseract", None, True)
|
||||
|
||||
assert type(options).__name__ == "TesseractCliOcrOptions"
|
||||
assert options.lang == ["eng", "chi_sim"]
|
||||
assert options.force_full_page_ocr is True
|
||||
|
||||
def test_build_tesseract_explicit_languages_win(self, monkeypatch):
|
||||
pytest.importorskip("docling")
|
||||
import application.parser.file.ocr_parser as op
|
||||
from application.parser.file.docling_parser import _build_ocr_options
|
||||
|
||||
# Pack inventory unknown: the requested list is passed through untouched.
|
||||
monkeypatch.setattr(op, "tesseract_languages", lambda: None)
|
||||
options = _build_ocr_options("tesseract", ["deu"], False)
|
||||
assert options.lang == ["deu"]
|
||||
|
||||
def test_build_rapidocr(self):
|
||||
pytest.importorskip("docling")
|
||||
from application.parser.file.docling_parser import _build_ocr_options
|
||||
|
||||
options = _build_ocr_options("rapidocr", None, False)
|
||||
assert type(options).__name__ == "RapidOcrOptions"
|
||||
assert options.lang == ["english"]
|
||||
|
||||
def test_build_import_failure_returns_none(self, monkeypatch):
|
||||
import sys
|
||||
|
||||
from application.parser.file.docling_parser import _build_ocr_options
|
||||
|
||||
monkeypatch.setitem(sys.modules, "docling.datamodel.pipeline_options", None)
|
||||
assert _build_ocr_options("tesseract", ["eng"], False) is None
|
||||
assert _build_ocr_options("ocrmac", None, False) is None
|
||||
|
||||
def test_build_generic_failure_returns_none(self, monkeypatch):
|
||||
import sys
|
||||
import types
|
||||
|
||||
from application.parser.file.docling_parser import _build_ocr_options
|
||||
|
||||
fake = types.ModuleType("docling.datamodel.pipeline_options")
|
||||
|
||||
def _boom(**kwargs):
|
||||
raise RuntimeError("bad options")
|
||||
|
||||
fake.RapidOcrOptions = _boom
|
||||
monkeypatch.setitem(sys.modules, "docling.datamodel.pipeline_options", fake)
|
||||
assert _build_ocr_options("rapidocr", None, False) is None
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestDeepseekVlmConverter:
|
||||
"""OCR_ENGINE=deepseek swaps the whole pipeline for docling's VLM route."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _requires_docling(self):
|
||||
pytest.importorskip("docling")
|
||||
|
||||
def test_deepseek_builds_vlm_converter(self, monkeypatch):
|
||||
from application.core.settings import settings
|
||||
from application.parser.file.docling_parser import DoclingParser
|
||||
|
||||
parser = DoclingParser(use_rapidocr=False)
|
||||
assert parser._get_ocr_options() is None
|
||||
monkeypatch.setattr(settings, "OCR_DEEPSEEK_URL", "http://gpu-host:8000/v1/chat/completions")
|
||||
monkeypatch.setattr(settings, "OCR_DEEPSEEK_MODEL", "deepseek-ocr-x")
|
||||
|
||||
def test_returns_options_when_available(self):
|
||||
built = {}
|
||||
|
||||
def _capture_converter(format_options):
|
||||
built["format_options"] = format_options
|
||||
return MagicMock()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"docling.document_converter.DocumentConverter", _capture_converter
|
||||
)
|
||||
parser = DoclingParser(ocr_enabled=True, ocr_engine="deepseek")
|
||||
parser._create_converter()
|
||||
|
||||
from docling.datamodel.base_models import InputFormat
|
||||
from docling.pipeline.vlm_pipeline import VlmPipeline
|
||||
|
||||
pdf_option = built["format_options"][InputFormat.PDF]
|
||||
image_option = built["format_options"][InputFormat.IMAGE]
|
||||
assert pdf_option.pipeline_cls is VlmPipeline
|
||||
assert image_option.pipeline_cls is VlmPipeline
|
||||
vlm = pdf_option.pipeline_options.vlm_options
|
||||
assert vlm.url == "http://gpu-host:8000/v1/chat/completions"
|
||||
assert vlm.params["model"] == "deepseek-ocr-x"
|
||||
assert pdf_option.pipeline_options.enable_remote_services is True
|
||||
|
||||
def test_deepseek_ignored_when_ocr_disabled(self, monkeypatch):
|
||||
from application.parser.file.docling_parser import DoclingParser
|
||||
|
||||
parser = DoclingParser(use_rapidocr=True, ocr_languages=["english"])
|
||||
vlm_called = []
|
||||
monkeypatch.setattr(
|
||||
DoclingParser,
|
||||
"_create_vlm_converter",
|
||||
lambda self: vlm_called.append(True),
|
||||
)
|
||||
monkeypatch.setattr("docling.document_converter.DocumentConverter", MagicMock())
|
||||
DoclingParser(ocr_enabled=False, ocr_engine="deepseek")._create_converter()
|
||||
|
||||
mock_options = MagicMock()
|
||||
with patch(
|
||||
"application.parser.file.docling_parser.DoclingParser._get_ocr_options",
|
||||
return_value=mock_options,
|
||||
):
|
||||
result = parser._get_ocr_options()
|
||||
assert result is mock_options
|
||||
|
||||
def test_returns_none_on_import_error(self):
|
||||
from application.parser.file.docling_parser import DoclingParser
|
||||
|
||||
parser = DoclingParser(use_rapidocr=True)
|
||||
|
||||
# Simulate the ImportError path
|
||||
original = parser._get_ocr_options
|
||||
|
||||
def patched_get_ocr():
|
||||
try:
|
||||
raise ImportError("No RapidOcrOptions")
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
parser._get_ocr_options = patched_get_ocr
|
||||
assert parser._get_ocr_options() is None
|
||||
parser._get_ocr_options = original
|
||||
assert vlm_called == []
|
||||
|
||||
|
||||
# =====================================================================
|
||||
@@ -431,38 +569,57 @@ class TestDoclingSubclasses:
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestDoclingParserGaps:
|
||||
def test_get_ocr_options_import_error_returns_none(self):
|
||||
"""Cover lines 148-150: ImportError returns None."""
|
||||
from application.parser.file.docling_parser import DoclingParser
|
||||
|
||||
parser = DoclingParser(ocr_enabled=True, use_rapidocr=True)
|
||||
with patch.dict("sys.modules", {"docling.datamodel.pipeline_options": None}):
|
||||
# Force re-import to trigger ImportError
|
||||
with patch(
|
||||
"builtins.__import__", side_effect=ImportError("no module")
|
||||
):
|
||||
result = parser._get_ocr_options()
|
||||
assert result is None
|
||||
|
||||
def test_get_ocr_options_generic_error_returns_none(self):
|
||||
"""Cover lines 151-153: generic Exception returns None."""
|
||||
from application.parser.file.docling_parser import DoclingParser
|
||||
|
||||
parser = DoclingParser(ocr_enabled=True, use_rapidocr=True)
|
||||
with patch(
|
||||
"builtins.__import__",
|
||||
side_effect=RuntimeError("unexpected"),
|
||||
):
|
||||
result = parser._get_ocr_options()
|
||||
assert result is None
|
||||
|
||||
def test_csv_parser_init(self):
|
||||
"""Cover line 289: DoclingCSVParser.__init__ calls super."""
|
||||
from application.parser.file.docling_parser import DoclingCSVParser
|
||||
|
||||
parser = DoclingCSVParser()
|
||||
assert parser.export_format == "markdown"
|
||||
assert parser.ocr_enabled is True
|
||||
assert parser.ocr_enabled is False
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestNonOcrParsersLeaveTextAlone:
|
||||
"""Office/markup docling parsers never OCR, so the tesseract CJK
|
||||
post-processing must not touch their born-digital exports."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name",
|
||||
[
|
||||
"DoclingDocxParser",
|
||||
"DoclingPPTXParser",
|
||||
"DoclingXLSXParser",
|
||||
"DoclingHTMLParser",
|
||||
"DoclingCSVParser",
|
||||
"DoclingMarkdownParser",
|
||||
"DoclingAsciiDocParser",
|
||||
"DoclingVTTParser",
|
||||
"DoclingXMLParser",
|
||||
],
|
||||
)
|
||||
def test_ocr_is_off_by_construction(self, name):
|
||||
from application.parser.file import docling_parser as dp
|
||||
|
||||
assert getattr(dp, name)().ocr_enabled is False
|
||||
|
||||
def test_cjk_spaces_survive_in_a_docx_export(self, monkeypatch):
|
||||
from application.core.settings import settings
|
||||
from application.parser.file.docling_parser import DoclingDocxParser
|
||||
|
||||
monkeypatch.setattr(settings, "OCR_ENGINE", "tesseract")
|
||||
parser = DoclingDocxParser()
|
||||
# Simulate what _create_converter records for an OCR-on parser; the
|
||||
# docx parser is OCR-off so the collapse must stay inert regardless.
|
||||
parser._active_ocr_engine = "tesseract"
|
||||
text = "\u5f20 \u4e09 \u548c \u674e \u56db\n\uff21\uff22\uff23 \uff24\uff25\uff26"
|
||||
assert parser._postprocess_ocr_text(text) == text
|
||||
|
||||
def test_pdf_parser_with_tesseract_still_collapses(self):
|
||||
from application.parser.file.docling_parser import DoclingPDFParser
|
||||
|
||||
parser = DoclingPDFParser(ocr_enabled=True)
|
||||
parser._active_ocr_engine = "tesseract"
|
||||
assert parser._postprocess_ocr_text("\u4e92\u76f8 \u4fdd\u5bc6") == "\u4e92\u76f8\u4fdd\u5bc6"
|
||||
|
||||
|
||||
# =====================================================================
|
||||
@@ -910,6 +1067,12 @@ class TestApplyInferenceSettings:
|
||||
and buys nothing for one-shot parses.
|
||||
"""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _requires_docling(self):
|
||||
# These tests reach into docling.datamodel.settings for real; a base
|
||||
# install (docling is an optional extra) skips them.
|
||||
pytest.importorskip("docling")
|
||||
|
||||
def test_disables_torch_compile_by_default(self, monkeypatch):
|
||||
from application.parser.file.docling_parser import _apply_inference_settings
|
||||
|
||||
@@ -1041,7 +1204,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)
|
||||
|
||||
|
||||
@@ -1219,11 +1382,11 @@ class TestOCRDropoutGuard:
|
||||
assert create.call_count == 1
|
||||
|
||||
def test_guard_does_not_apply_to_non_ocr_formats(self):
|
||||
"""DOCX parsers inherit ocr_enabled=True but never OCR anything."""
|
||||
"""DOCX parsers construct with OCR off: they never OCR anything."""
|
||||
from application.parser.file.docling_parser import DoclingDocxParser
|
||||
|
||||
parser = DoclingDocxParser()
|
||||
assert parser.ocr_enabled is True
|
||||
assert parser.ocr_enabled is False
|
||||
converter = MagicMock()
|
||||
converter.convert.return_value = _mock_conversion("Hi", pages=1)
|
||||
parser._converter = converter
|
||||
@@ -1412,6 +1575,12 @@ class TestOCRDropoutHelpers:
|
||||
class TestForceFullPageOCRWiring:
|
||||
"""``force_full_page_ocr`` must reach the pipeline for any OCR engine."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _requires_docling(self):
|
||||
# Builds real ``docling.datamodel.pipeline_options``; a base install
|
||||
# (docling is an optional extra) skips these.
|
||||
pytest.importorskip("docling")
|
||||
|
||||
def _pipeline_options(self, monkeypatch, **kwargs):
|
||||
import docling.datamodel.pipeline_options as dpo
|
||||
|
||||
@@ -1430,11 +1599,11 @@ class TestForceFullPageOCRWiring:
|
||||
DoclingParser(**kwargs)._create_converter()
|
||||
return built[0]
|
||||
|
||||
def test_forced_without_rapidocr(self, monkeypatch):
|
||||
def test_forced_with_default_auto_options(self, monkeypatch):
|
||||
options = self._pipeline_options(
|
||||
monkeypatch,
|
||||
ocr_enabled=True,
|
||||
use_rapidocr=False,
|
||||
ocr_engine="auto",
|
||||
force_full_page_ocr=True,
|
||||
)
|
||||
assert options.ocr_options.force_full_page_ocr is True
|
||||
@@ -1443,7 +1612,101 @@ class TestForceFullPageOCRWiring:
|
||||
options = self._pipeline_options(
|
||||
monkeypatch,
|
||||
ocr_enabled=True,
|
||||
use_rapidocr=False,
|
||||
ocr_engine="auto",
|
||||
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])
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestTesseractLanguageFilter:
|
||||
def test_uninstalled_packs_are_dropped_with_a_warning(self, monkeypatch, caplog):
|
||||
pytest.importorskip("docling")
|
||||
import application.parser.file.ocr_parser as op
|
||||
from application.parser.file.docling_parser import _build_ocr_options
|
||||
|
||||
monkeypatch.setattr(op, "tesseract_languages", lambda: frozenset({"eng", "osd"}))
|
||||
with caplog.at_level("WARNING"):
|
||||
options = _build_ocr_options("tesseract", ["eng", "chi_sim"], False)
|
||||
assert options.lang == ["eng"]
|
||||
assert "chi_sim" in caplog.text
|
||||
|
||||
def test_all_packs_missing_falls_back_to_eng(self, monkeypatch):
|
||||
pytest.importorskip("docling")
|
||||
import application.parser.file.ocr_parser as op
|
||||
from application.parser.file.docling_parser import _build_ocr_options
|
||||
|
||||
monkeypatch.setattr(op, "tesseract_languages", lambda: frozenset({"eng"}))
|
||||
assert _build_ocr_options("tesseract", ["xyz"], False).lang == ["eng"]
|
||||
|
||||
def test_unknown_inventory_keeps_the_list(self, monkeypatch):
|
||||
pytest.importorskip("docling")
|
||||
import application.parser.file.ocr_parser as op
|
||||
from application.parser.file.docling_parser import _build_ocr_options
|
||||
|
||||
monkeypatch.setattr(op, "tesseract_languages", lambda: None)
|
||||
assert _build_ocr_options("tesseract", ["eng", "deu"], False).lang == ["eng", "deu"]
|
||||
@@ -54,3 +54,225 @@ def test_html_parser_metadata_without_title(tmp_path):
|
||||
|
||||
def test_html_parser_metadata_unreadable_file(tmp_path):
|
||||
assert HTMLParser().get_file_metadata(tmp_path / "missing.html") == {}
|
||||
|
||||
|
||||
# --- HTMLMarkdownParser: the anydoc engine's HTML path ---------------------------
|
||||
|
||||
from application.parser.file.html_parser import HTMLMarkdownParser, html_to_markdown # noqa: E402
|
||||
|
||||
RICH_HTML = """<html><head><title>Doc Title</title>
|
||||
<style>.x { color: red }</style><script>var secret = 1;</script></head>
|
||||
<body>
|
||||
<h1>Heading</h1>
|
||||
<p>See <a href="../ref.html#anchor">the ref</a> for details.</p>
|
||||
<table><tr><th>h1</th><th>h2</th></tr><tr><td>1</td><td>2</td></tr></table>
|
||||
<pre><code>typing.__name__, typing.__spec__.name</code></pre>
|
||||
</body></html>"""
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def rich_html_file(tmp_path):
|
||||
path = tmp_path / "rich.html"
|
||||
path.write_text(RICH_HTML)
|
||||
return path
|
||||
|
||||
|
||||
def test_markdown_parser_keeps_structure(rich_html_file):
|
||||
out = HTMLMarkdownParser().parse_file(rich_html_file)
|
||||
|
||||
assert isinstance(out, str)
|
||||
assert "# Heading" in out
|
||||
assert "[the ref](../ref.html#anchor)" in out # links kept, hrefs verbatim
|
||||
assert "| h1 | h2 |" in out and "| 1 | 2 |" in out # GFM table
|
||||
assert "typing.__name__, typing.__spec__.name" in out # code byte-exact
|
||||
|
||||
|
||||
def test_markdown_parser_drops_non_content(rich_html_file):
|
||||
out = HTMLMarkdownParser().parse_file(rich_html_file)
|
||||
|
||||
assert "var secret" not in out
|
||||
assert "color: red" not in out
|
||||
assert "Doc Title" not in out # title is metadata, not body text
|
||||
|
||||
|
||||
def test_markdown_parser_reports_title_metadata(rich_html_file):
|
||||
assert HTMLMarkdownParser().get_file_metadata(rich_html_file) == {"title": "Doc Title"}
|
||||
|
||||
|
||||
def test_markdown_parser_differs_from_plain_text_contract(rich_html_file):
|
||||
"""``HTMLParser`` stays plain text (the ``fast`` engine's contract); only the new class emits Markdown."""
|
||||
plain = HTMLParser().parse_file(rich_html_file)
|
||||
assert "# Heading" not in plain
|
||||
assert "[the ref]" not in plain
|
||||
|
||||
|
||||
def test_markdown_parser_handles_xhtml(tmp_path):
|
||||
path = tmp_path / "page.xhtml"
|
||||
path.write_text(
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" '
|
||||
'"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">\n'
|
||||
'<html xmlns="http://www.w3.org/1999/xhtml"><head><title>X</title></head>'
|
||||
"<body><h2>Section</h2><p>Body & entities.</p></body></html>"
|
||||
)
|
||||
out = HTMLMarkdownParser().parse_file(path)
|
||||
assert "## Section" in out
|
||||
assert "Body & entities." in out
|
||||
|
||||
|
||||
def test_markdown_parser_init():
|
||||
parser = HTMLMarkdownParser()
|
||||
assert not parser.parser_config_set
|
||||
parser.init_parser()
|
||||
assert parser.parser_config_set
|
||||
|
||||
|
||||
def test_html_to_markdown_collapses_blank_runs():
|
||||
out = html_to_markdown("<p>a</p><br/><br/><br/><br/><p>b</p>")
|
||||
assert "\n\n\n" not in out
|
||||
assert out.startswith("a") and out.endswith("b")
|
||||
|
||||
|
||||
# --- HTMLMarkdownParser: input bounding and decoding ------------------------------
|
||||
|
||||
|
||||
def test_markdown_parser_head_truncates_oversized_markup(tmp_path, monkeypatch):
|
||||
"""Markup past MARKUP_MAX_BYTES is not parsed: the soup+markdownify tree costs
|
||||
~50x the input, and the upload cap is 100 MB."""
|
||||
from application.core.settings import settings
|
||||
|
||||
monkeypatch.setattr(settings, "MARKUP_MAX_BYTES", 600)
|
||||
path = tmp_path / "big.html"
|
||||
body = "".join(f"<p>para {i}</p>\n" for i in range(300))
|
||||
path.write_text("<html><head><title>Big</title></head><body>\n" + body + "</body></html>")
|
||||
assert path.stat().st_size > 600
|
||||
|
||||
out = HTMLMarkdownParser().parse_file(path)
|
||||
|
||||
assert "para 0" in out
|
||||
assert "para 299" not in out
|
||||
assert HTMLMarkdownParser().get_file_metadata(path) == {"title": "Big"}
|
||||
|
||||
|
||||
def test_markdown_parser_gate_disabled_reads_everything(tmp_path, monkeypatch):
|
||||
from application.core.settings import settings
|
||||
|
||||
monkeypatch.setattr(settings, "MARKUP_MAX_BYTES", 0)
|
||||
path = tmp_path / "big.html"
|
||||
body = "".join(f"<p>para {i}</p>\n" for i in range(300))
|
||||
path.write_text("<html><body>\n" + body + "</body></html>")
|
||||
|
||||
out = HTMLMarkdownParser().parse_file(path)
|
||||
|
||||
assert "para 299" in out
|
||||
|
||||
|
||||
def test_markdown_parser_honours_declared_charset(tmp_path):
|
||||
"""The file is handed to BeautifulSoup as bytes so ``<meta charset>`` wins
|
||||
over the process locale (docling did the same; the text parser does not)."""
|
||||
path = tmp_path / "cp1250.html"
|
||||
path.write_bytes(
|
||||
'<html><head><meta charset="windows-1250"><title>Plzeň</title></head>'
|
||||
"<body><p>Jiří z Plzně</p></body></html>".encode("cp1250")
|
||||
)
|
||||
|
||||
assert "Jiří z Plzně" in HTMLMarkdownParser().parse_file(path)
|
||||
assert HTMLMarkdownParser().get_file_metadata(path) == {"title": "Plzeň"}
|
||||
|
||||
|
||||
def test_markdown_parser_metadata_unreadable_file(tmp_path):
|
||||
assert HTMLMarkdownParser().get_file_metadata(tmp_path / "missing.html") == {}
|
||||
|
||||
|
||||
def test_markdown_parser_cut_inside_utf8_char_does_not_mojibake_the_page(tmp_path, monkeypatch):
|
||||
"""A byte cut mid-character makes strict UTF-8 fail; BeautifulSoup would then
|
||||
retry as windows-1252, which *succeeds* on umlauts and garbles everything."""
|
||||
from application.core.settings import settings
|
||||
|
||||
path = tmp_path / "minified.html"
|
||||
body = "<p>Größe Übermaß schön für</p>" * 400 # no newlines: the cut is arbitrary
|
||||
path.write_bytes(
|
||||
('<html><head><meta charset="utf-8"><title>Größe</title></head><body>' + body + "</body></html>")
|
||||
.encode("utf-8")
|
||||
)
|
||||
for cap in (2001, 2002, 2003): # one of these lands inside a 2-byte sequence
|
||||
monkeypatch.setattr(settings, "MARKUP_MAX_BYTES", cap)
|
||||
out = HTMLMarkdownParser().parse_file(path)
|
||||
assert "Größe Übermaß schön für" in out, cap
|
||||
assert "Ã" not in out, cap
|
||||
|
||||
|
||||
def test_trim_torn_utf8_tail():
|
||||
from application.parser.file.html_parser import _trim_torn_utf8_tail as trim
|
||||
|
||||
e_acute, snowman, emoji = "é".encode(), "☃".encode(), "😀".encode()
|
||||
assert trim(b"abc") == b"abc"
|
||||
assert trim(b"abc" + e_acute) == b"abc" + e_acute # complete 2-byte
|
||||
assert trim(b"abc" + e_acute[:1]) == b"abc" # torn 2-byte
|
||||
assert trim(b"abc" + snowman[:2]) == b"abc" # torn 3-byte
|
||||
assert trim(b"abc" + emoji[:3]) == b"abc" # torn 4-byte
|
||||
assert trim(b"abc" + emoji) == b"abc" + emoji
|
||||
assert trim(b"") == b""
|
||||
assert trim(b"\xe8") == b"" # lone lead byte (also a cp1250 letter: at most one glyph lost)
|
||||
|
||||
|
||||
def test_markdown_parser_metadata_reuses_last_parse(tmp_path, rich_html_file, monkeypatch):
|
||||
"""The metadata call after parse_file must not build the soup again."""
|
||||
from application.parser.file import html_parser as mod
|
||||
|
||||
parser = HTMLMarkdownParser()
|
||||
parser.parse_file(rich_html_file)
|
||||
calls = []
|
||||
monkeypatch.setattr(mod, "read_markup_head", lambda *a, **k: calls.append(a) or b"")
|
||||
assert parser.get_file_metadata(rich_html_file) == {"title": "Doc Title"}
|
||||
assert calls == []
|
||||
# A different file is read afresh.
|
||||
other = tmp_path / "other.html"
|
||||
other.write_text("<title>Other</title>")
|
||||
parser.get_file_metadata(other)
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
# --- second-pass fixes: XML prolog, data URIs, UTF-16 heads ---------------------
|
||||
|
||||
|
||||
def test_xml_prolog_does_not_leak_into_markdown(tmp_path):
|
||||
from application.parser.file.html_parser import HTMLMarkdownParser
|
||||
|
||||
path = tmp_path / "doc.xhtml"
|
||||
path.write_bytes(
|
||||
b'<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE html>\n'
|
||||
b'<html xmlns="http://www.w3.org/1999/xhtml"><head><title>T</title></head>'
|
||||
b"<body><h1>Hi</h1><p>para</p><![CDATA[raw]]></body></html>"
|
||||
)
|
||||
text = HTMLMarkdownParser().parse_file(path)
|
||||
assert "xml version" not in text
|
||||
assert "raw" not in text
|
||||
assert text.startswith("# Hi")
|
||||
|
||||
|
||||
def test_data_uris_are_stripped_from_images_and_links():
|
||||
payload = "data:image/png;base64," + "A" * 200_000
|
||||
html = (
|
||||
f'<p>before</p><img src="{payload}" alt="chart"><a href="{payload}">dl</a>'
|
||||
f'<img srcset="{payload} 1x" alt="x"><p>after</p>'
|
||||
)
|
||||
text = html_to_markdown(html)
|
||||
assert "AAAA" not in text
|
||||
assert "before" in text and "after" in text
|
||||
assert len(text) < 200
|
||||
|
||||
|
||||
def test_utf16_head_keeps_an_even_byte_count(tmp_path, monkeypatch):
|
||||
from application.core.settings import settings
|
||||
from application.parser.file.html_parser import HTMLMarkdownParser, read_markup_head
|
||||
|
||||
body = "".join(f"<p>Zeile {i} Über Größe</p>\n" for i in range(200))
|
||||
path = tmp_path / "wide.html"
|
||||
path.write_bytes(("<html><body>" + body + "</body></html>").encode("utf-16")) # BOM-prefixed
|
||||
head = read_markup_head(path, 3001)
|
||||
assert len(head) % 2 == 0
|
||||
monkeypatch.setattr(settings, "MARKUP_MAX_BYTES", 3001)
|
||||
text = HTMLMarkdownParser().parse_file(path)
|
||||
assert "Zeile 0 Über Größe" in text
|
||||
assert "\x00" not in text
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,254 @@
|
||||
"""Tests for the PDF trust checks behind ``PDF_TRUST_CHECK``.
|
||||
|
||||
The checks target the two classes where anydoc drops text *silently*:
|
||||
composite (Type0) fonts without a ToUnicode map, and CJK-declaring PDFs
|
||||
whose extracted text carries almost no CJK. The scanner is byte-level and
|
||||
regex-based, so the synthetic fixtures below are minimal PDF fragments, not
|
||||
well-formed files; the one real fixture is the corpus document that loses
|
||||
its whole Chinese column in anydoc with no error.
|
||||
"""
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
|
||||
from application.parser.file.pdf_trust import (
|
||||
check_pdf_fonts,
|
||||
verify_extraction,
|
||||
verify_pdf_file,
|
||||
)
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures"
|
||||
|
||||
|
||||
def _obj(body: bytes) -> bytes:
|
||||
return b"1 0 obj" + body + b"endobj\n"
|
||||
|
||||
|
||||
def _stream(payload: bytes) -> bytes:
|
||||
return b"2 0 obj<</Filter/FlateDecode>>stream\n" + zlib.compress(payload) + b"\nendstream endobj\n"
|
||||
|
||||
|
||||
TYPE0_WITH_TOUNICODE = _obj(
|
||||
b"<</Type/Font/Subtype/Type0/BaseFont/AAAAAA+Simple/ToUnicode 9 0 R>>"
|
||||
)
|
||||
TYPE0_BARE = _obj(b"<</Type/Font/Subtype/Type0/BaseFont/AAAAAA+Bare>>")
|
||||
SIMPLE_FONT = _obj(b"<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>")
|
||||
GB1_ORDERING = _obj(b"<</Registry(Adobe)/Ordering(GB1)/Supplement 5>>")
|
||||
|
||||
|
||||
class TestCheckPdfFonts:
|
||||
def test_type0_with_tounicode_not_flagged(self):
|
||||
result = check_pdf_fonts(TYPE0_WITH_TOUNICODE)
|
||||
assert result["type0"] == 1
|
||||
assert result["type0_no_tounicode"] == 0
|
||||
assert not result["flagged"]
|
||||
|
||||
def test_type0_without_tounicode_flagged(self):
|
||||
result = check_pdf_fonts(TYPE0_BARE)
|
||||
assert result["type0_no_tounicode"] == 1
|
||||
assert result["flagged"]
|
||||
|
||||
def test_simple_font_not_flagged(self):
|
||||
result = check_pdf_fonts(SIMPLE_FONT)
|
||||
assert result["type0"] == 0
|
||||
assert result["has_fonts"]
|
||||
assert not result["flagged"]
|
||||
|
||||
def test_no_fonts_flagged(self):
|
||||
result = check_pdf_fonts(_obj(b"<</Type/Page>>"))
|
||||
assert not result["has_fonts"]
|
||||
assert result["flagged"]
|
||||
|
||||
def test_type0_inside_object_stream_is_seen(self):
|
||||
"""Object streams hold dicts with no obj markers; the scan must decompress them."""
|
||||
data = _stream(b"<</Type/Font/Subtype/Type0/BaseFont/BBBBBB+Packed>>")
|
||||
result = check_pdf_fonts(data)
|
||||
assert result["type0"] == 1
|
||||
assert result["type0_no_tounicode"] == 1
|
||||
assert result["flagged"]
|
||||
|
||||
def test_font_inside_object_stream_counts_as_has_fonts(self):
|
||||
"""A fully-compressed PDF with only simple fonts must not false-positive."""
|
||||
data = _stream(b"<</Type/Font/Subtype/TrueType/BaseFont/Arial>>")
|
||||
result = check_pdf_fonts(data)
|
||||
assert result["has_fonts"]
|
||||
assert not result["flagged"]
|
||||
|
||||
def test_cjk_ordering_detected_in_stream(self):
|
||||
data = _stream(b"<</Registry(Adobe)/Ordering(CNS1)/Supplement 4>>") + SIMPLE_FONT
|
||||
assert check_pdf_fonts(data)["expects_cjk"]
|
||||
|
||||
def test_cjk_font_names_alone_do_not_set_expectation(self):
|
||||
"""Regression for the Berkshire false positive: Word-exported Latin PDFs
|
||||
embed MS-Gothic subsets for stray full-width characters, and substring
|
||||
matching also hits FranklinGothic — neither means CJK *content*, and a
|
||||
false expectation reroutes a 150-page English report to the heavy
|
||||
engine. Only CIDSystemInfo /Ordering may set the expectation."""
|
||||
data = _obj(
|
||||
b"<</Type/Font/Subtype/TrueType/BaseFont/AKBHWD+MS-Gothic>>"
|
||||
) + _obj(b"<</Type/Font/Subtype/TrueType/BaseFont/KMBGKH+FranklinGothic-Roman>>")
|
||||
result = check_pdf_fonts(data)
|
||||
assert not result["expects_cjk"]
|
||||
assert not result["flagged"]
|
||||
|
||||
|
||||
class TestVerifyExtraction:
|
||||
def test_clean_pdf_and_output(self):
|
||||
assert verify_extraction(SIMPLE_FONT, "Plain English text.") == []
|
||||
|
||||
def test_bare_type0_reported(self):
|
||||
problems = verify_extraction(TYPE0_BARE + SIMPLE_FONT, "some text")
|
||||
assert len(problems) == 1
|
||||
assert "ToUnicode" in problems[0]
|
||||
|
||||
def test_cjk_expected_but_missing(self):
|
||||
problems = verify_extraction(GB1_ORDERING + SIMPLE_FONT, "English only output")
|
||||
assert len(problems) == 1
|
||||
assert "CJK" in problems[0]
|
||||
|
||||
def test_cjk_expected_and_present(self):
|
||||
text = "标题:本协议由双方共同签署生效" # 14 CJK chars
|
||||
assert verify_extraction(GB1_ORDERING + SIMPLE_FONT, text) == []
|
||||
|
||||
def test_both_problems_reported(self):
|
||||
problems = verify_extraction(TYPE0_BARE + GB1_ORDERING, "english")
|
||||
assert len(problems) == 2
|
||||
|
||||
|
||||
class TestRealFixture:
|
||||
"""The corpus PDF where anydoc silently drops the entire Chinese column."""
|
||||
|
||||
def test_cid_font_nda_is_flagged(self):
|
||||
data = (FIXTURES / "nda_en_zh_cid_font.pdf").read_bytes()
|
||||
result = check_pdf_fonts(data)
|
||||
assert result["type0_no_tounicode"] >= 1
|
||||
assert result["expects_cjk"]
|
||||
assert result["flagged"]
|
||||
|
||||
def test_cjkless_extraction_reports_both(self):
|
||||
data = (FIXTURES / "nda_en_zh_cid_font.pdf").read_bytes()
|
||||
problems = verify_extraction(data, "MUTUAL NON-DISCLOSURE AGREEMENT ...")
|
||||
assert any("ToUnicode" in p for p in problems)
|
||||
assert any("CJK" in p for p in problems)
|
||||
|
||||
|
||||
def test_flate_bomb_streams_are_capped():
|
||||
"""Flate reaches ~1000:1, so per-stream inflation must be capped — an
|
||||
uncapped decompress of a crafted PDF would OOM the ingest worker."""
|
||||
from application.parser.file.pdf_trust import _decompressed_streams, _STREAM_INFLATE_CAP
|
||||
|
||||
bomb = _stream(b"\0" * (_STREAM_INFLATE_CAP * 4))
|
||||
chunks = list(_decompressed_streams(bomb))
|
||||
assert chunks
|
||||
assert all(len(chunk) <= _STREAM_INFLATE_CAP for chunk in chunks)
|
||||
# The capped scan still completes and reports sanely alongside real objects.
|
||||
assert not check_pdf_fonts(SIMPLE_FONT + bomb)["flagged"]
|
||||
|
||||
|
||||
def test_verify_pdf_file_unreadable_trusts_output(tmp_path):
|
||||
assert verify_pdf_file(tmp_path / "missing.pdf", "text") == []
|
||||
|
||||
|
||||
# --- stream scanning is a keyword walk, not a regex -----------------------------
|
||||
|
||||
|
||||
class TestStreamWalk:
|
||||
def test_stream_without_eol_before_endstream_is_seen(self):
|
||||
body = zlib.compress(b"<</Type/Font/Subtype/Type0/BaseFont/X>>")
|
||||
data = b"2 0 obj<</Filter/FlateDecode>>stream\n" + body + b"endstream endobj\n"
|
||||
assert check_pdf_fonts(data)["type0"] == 1
|
||||
|
||||
def test_undecodable_stream_does_not_swallow_the_next_one(self):
|
||||
"""A non-Flate (image) stream before a font stream must not merge the two."""
|
||||
image = b"3 0 obj<</Filter/DCTDecode>>stream\n\xff\xd8 not zlib at all\nendstream endobj\n"
|
||||
font = _stream(b"<</Type/Font/Subtype/Type0/BaseFont/X/ToUnicode 9 0 R>>")
|
||||
result = check_pdf_fonts(image + font)
|
||||
assert result["type0"] == 1
|
||||
assert result["type0_no_tounicode"] == 0
|
||||
|
||||
def test_crlf_streams_decompress(self):
|
||||
body = zlib.compress(b"/Ordering (GB1)")
|
||||
data = b"2 0 obj<</Filter/FlateDecode>>stream\r\n" + body + b"\r\nendstream endobj\n"
|
||||
assert check_pdf_fonts(data)["expects_cjk"] is True
|
||||
|
||||
def test_large_file_with_many_streams_is_linear(self):
|
||||
"""1000 undecodable streams: the old regex went quadratic here."""
|
||||
import time
|
||||
|
||||
chunk = b"9 0 obj<</Length 30>>stream\n" + b"\x00" * 30 + b"endstream endobj\n"
|
||||
data = chunk * 1000 + TYPE0_BARE
|
||||
started = time.perf_counter()
|
||||
result = check_pdf_fonts(data)
|
||||
assert time.perf_counter() - started < 1.0
|
||||
assert result["type0_no_tounicode"] == 1
|
||||
|
||||
|
||||
class TestObjectWalk:
|
||||
def test_bodies_match_the_regex_reference(self):
|
||||
"""The forward walk yields exactly what ``\\d+\\s+\\d+\\s+obj(.*?)endobj`` did."""
|
||||
import re
|
||||
|
||||
from application.parser.file.pdf_trust import _object_bodies
|
||||
|
||||
reference = re.compile(rb"\d+\s+\d+\s+obj(.*?)endobj", re.DOTALL)
|
||||
data = (
|
||||
TYPE0_BARE
|
||||
+ b"7 0 obj no terminator here " # orphan header: body runs to the next endobj
|
||||
+ SIMPLE_FONT
|
||||
+ b"junk 12 0 obj<</A/B>>endobj"
|
||||
+ _stream(b"x")
|
||||
+ b"99 0 obj trailing orphan"
|
||||
)
|
||||
assert list(_object_bodies(data)) == [m.group(1) for m in reference.finditer(data)]
|
||||
|
||||
def test_orphan_headers_are_linear(self):
|
||||
"""Headers with no ``endobj`` after them: the old regex rescanned to EOF for
|
||||
each one (2000 in 1 MB took 13.5 s), pinning an ingest worker per upload."""
|
||||
import time
|
||||
|
||||
data = TYPE0_BARE + b"1 0 obj\n" * 20_000 + b"\x00" * 500_000
|
||||
started = time.perf_counter()
|
||||
result = check_pdf_fonts(data)
|
||||
assert time.perf_counter() - started < 1.0
|
||||
assert result["type0_no_tounicode"] == 1
|
||||
|
||||
|
||||
# --- the CJK cross-check is confirmed against pdfium's text layer --------------
|
||||
|
||||
|
||||
def _latin_pdf(path: Path) -> Path:
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("reportlab")
|
||||
from reportlab.pdfgen import canvas
|
||||
|
||||
c = canvas.Canvas(str(path))
|
||||
c.drawString(72, 700, "Plain English content, nothing else on the page.")
|
||||
c.showPage()
|
||||
c.save()
|
||||
return path
|
||||
|
||||
|
||||
def test_stray_cjk_ordering_without_cjk_text_is_not_flagged(tmp_path):
|
||||
"""A Quartz-style export declares Japan1 on one unused font: pdfium sees no
|
||||
CJK either, so anydoc dropped nothing and the file must not be rerouted."""
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("pypdfium2")
|
||||
path = _latin_pdf(tmp_path / "latin.pdf")
|
||||
with open(path, "ab") as fh:
|
||||
fh.write(b"\n% trailing junk\n" + _obj(b"<</Registry(Adobe)/Ordering(Japan1)/Supplement 6>>"))
|
||||
markdown = "Plain English content, nothing else on the page."
|
||||
|
||||
# The byte-level check alone still fires...
|
||||
assert any(p.startswith("PDF declares CJK fonts") for p in verify_extraction(path.read_bytes(), markdown))
|
||||
# ...and the file-level check withdraws it.
|
||||
assert [p for p in verify_pdf_file(path, markdown) if p.startswith("PDF declares CJK fonts")] == []
|
||||
|
||||
|
||||
def test_real_cjk_drop_survives_the_cross_check():
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("pypdfium2")
|
||||
fixture = FIXTURES / "nda_en_zh_cid_font.pdf"
|
||||
problems = verify_pdf_file(fixture, "English only, the Chinese column is gone.")
|
||||
assert any(p.startswith("PDF declares CJK fonts") for p in problems)
|
||||
@@ -153,9 +153,24 @@ def test_threshold_sends_sparse_text_to_fallback(tmp_path, parser_cls):
|
||||
|
||||
|
||||
# --- extractor wiring: the guard that source ingestion keeps docling ---------
|
||||
# (under the docling engine; the anydoc engine ignores the fast path, see below)
|
||||
|
||||
|
||||
def test_extractor_defaults_to_docling_for_pdf():
|
||||
@pytest.fixture
|
||||
def docling_engine(monkeypatch):
|
||||
# The wiring assertions below expect the docling map; on a base install
|
||||
# (docling is an optional extra) the engine degrades to the legacy
|
||||
# parsers and these tests do not apply.
|
||||
pytest.importorskip("docling")
|
||||
from application.core.settings import settings
|
||||
|
||||
monkeypatch.setattr(settings, "DOC_PARSER_ENGINE", "docling")
|
||||
# A developer .env with OCR on (and OCR_BACKEND=native) would otherwise
|
||||
# swap the native OCR parser in for the docling one these tests expect.
|
||||
monkeypatch.setattr(settings, "OCR_ENABLED", False)
|
||||
|
||||
|
||||
def test_extractor_defaults_to_docling_for_pdf(docling_engine):
|
||||
"""Sources must be unaffected: no fast path unless explicitly requested."""
|
||||
from application.parser.file.bulk import get_default_file_extractor
|
||||
|
||||
@@ -164,7 +179,7 @@ def test_extractor_defaults_to_docling_for_pdf():
|
||||
assert type(pdf_parser).__name__ == "DoclingPDFParser"
|
||||
|
||||
|
||||
def test_extractor_uses_fast_path_when_requested():
|
||||
def test_extractor_uses_fast_path_when_requested(docling_engine):
|
||||
from application.parser.file.bulk import get_default_file_extractor
|
||||
from application.parser.file.pdfium_parser import PdfiumTextParser
|
||||
|
||||
@@ -174,7 +189,20 @@ def test_extractor_uses_fast_path_when_requested():
|
||||
assert type(pdf_parser.fallback_parser).__name__ == "DoclingPDFParser"
|
||||
|
||||
|
||||
def test_fast_path_does_not_change_non_pdf_parsers():
|
||||
def test_anydoc_engine_ignores_fast_path(monkeypatch):
|
||||
"""anydoc already reads the text layer in milliseconds and keeps structure."""
|
||||
pytest.importorskip("anydoc")
|
||||
from application.core.settings import settings
|
||||
from application.parser.file.bulk import get_default_file_extractor
|
||||
|
||||
monkeypatch.setattr(settings, "DOC_PARSER_ENGINE", "anydoc")
|
||||
|
||||
pdf_parser = get_default_file_extractor(pdf_text_fast_path=True)[".pdf"]
|
||||
|
||||
assert type(pdf_parser).__name__ == "AnydocParser"
|
||||
|
||||
|
||||
def test_fast_path_does_not_change_non_pdf_parsers(docling_engine):
|
||||
from application.parser.file.bulk import get_default_file_extractor
|
||||
|
||||
plain = get_default_file_extractor()
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Tests for the dot-leader/whitespace table reconstruction (``ANYDOC_TABLEIZE``)."""
|
||||
from application.parser.file.tableize import tableize
|
||||
|
||||
DOT_LEADER = """Revenues
|
||||
Insurance premiums ............ 83,431 77,731
|
||||
Sales and service revenues ........ 24,660 23,406
|
||||
Freight rail transportation ......... 22,341 23,852
|
||||
Total revenues 130,432 124,989
|
||||
"""
|
||||
|
||||
|
||||
def test_dot_leader_run_becomes_table():
|
||||
out = tableize(DOT_LEADER)
|
||||
assert "| Insurance premiums | 83,431 | 77,731 |" in out
|
||||
assert "| Total revenues | 130,432 | 124,989 |" in out
|
||||
assert "| --- |" in out
|
||||
assert "Revenues" in out # heading line untouched
|
||||
|
||||
|
||||
def test_whitespace_only_rows_convert_too():
|
||||
md = "Alpha 1 2\nBeta 3 4\nGamma 5 6\n"
|
||||
out = tableize(md)
|
||||
assert "| Alpha | 1 | 2 |" in out
|
||||
|
||||
|
||||
def test_currency_symbol_merges_with_number():
|
||||
md = "Cash $ 1,234 900\nDebt $ 2,000 1,500\nEquity $ 900 800\n"
|
||||
out = tableize(md)
|
||||
assert "| Cash | $1,234 | 900 |" in out
|
||||
|
||||
|
||||
def test_parenthesised_negatives_and_dash():
|
||||
md = "Losses (30) —\nGains (12) —\nNet (42) —\n"
|
||||
out = tableize(md)
|
||||
assert "| Losses | (30) | — |" in out
|
||||
|
||||
|
||||
def test_short_run_is_left_alone():
|
||||
md = "Alpha 1 2\nBeta 3 4\n"
|
||||
assert "|" not in tableize(md)
|
||||
|
||||
|
||||
def test_mixed_widths_are_left_alone():
|
||||
md = "Alpha 1 2\nBeta 3\nGamma 5 6\n"
|
||||
assert "|" not in tableize(md)
|
||||
|
||||
|
||||
def test_prose_with_numbers_is_left_alone():
|
||||
md = "The division shipped 47 releases in 2023.\nIt hired 12 engineers this year alone.\nRevenue grew by a factor of 3 since 2019.\n"
|
||||
assert "|" not in tableize(md)
|
||||
|
||||
|
||||
def test_min_rows_boundary():
|
||||
two = "A 1 2\nB 3 4\n"
|
||||
three = two + "C 5 6\n"
|
||||
assert "|" not in tableize(two, min_rows=3)
|
||||
assert "|" in tableize(three, min_rows=3)
|
||||
|
||||
|
||||
def test_pipe_in_label_is_escaped():
|
||||
md = "Assets | current 1,234 900\nDebt 2,000 1,500\nEquity 900 800\n"
|
||||
out = tableize(md)
|
||||
assert "| Assets \\| current | 1,234 | 900 |" in out
|
||||
|
||||
|
||||
def test_dollar_prefixed_word_is_not_a_value():
|
||||
md = "Alpha $TBD 2\nBeta $TBD 4\nGamma $TBD 6\n"
|
||||
assert "|" not in tableize(md)
|
||||
|
||||
|
||||
def test_non_table_text_passes_through_verbatim():
|
||||
"""Only the trailing newline may differ (splitlines/join round-trip)."""
|
||||
md = "# Heading\n\nA paragraph with no numbers.\n\n- a list item\n"
|
||||
assert tableize(md) == md.rstrip("\n")
|
||||
|
||||
|
||||
def test_single_trailing_number_lines_are_not_a_table():
|
||||
"""Headings, footnotes and version lists look like 'word number' rows; leave them alone."""
|
||||
from application.parser.file.tableize import tableize
|
||||
|
||||
for block in (
|
||||
"Chapter 1\nChapter 2\nChapter 3",
|
||||
"Footnote 1\nFootnote 2\nFootnote 3",
|
||||
"ISO 9001\nISO 14001\nISO 27001",
|
||||
"Version 2.0\nVersion 3.0\nVersion 4.0",
|
||||
):
|
||||
assert tableize(block) == block
|
||||
|
||||
|
||||
def test_leader_rows_with_one_value_still_convert():
|
||||
from application.parser.file.tableize import tableize
|
||||
|
||||
block = "Revenue ...... 1,234\nCosts ...... 567\nProfit ...... 667"
|
||||
assert "| --- |" in tableize(block)
|
||||
@@ -903,3 +903,14 @@ class TestSSRFValidation:
|
||||
with pytest.raises(ValueError, match="Invalid S3 endpoint_url"):
|
||||
s3_loader.load_data(input_data)
|
||||
mock_boto3.client.assert_not_called()
|
||||
|
||||
|
||||
def test_is_supported_document_follows_the_upload_whitelist():
|
||||
from application.parser.file.constants import SUPPORTED_SOURCE_DOCUMENT_EXTENSIONS
|
||||
from application.parser.remote.s3_loader import S3Loader
|
||||
|
||||
for suffix in SUPPORTED_SOURCE_DOCUMENT_EXTENSIONS:
|
||||
key = f"bucket/file{suffix}"
|
||||
assert S3Loader.is_text_file(None, key) or S3Loader.is_supported_document(None, key), suffix
|
||||
assert S3Loader.is_supported_document(None, "bucket/file.doc")
|
||||
assert not S3Loader.is_supported_document(None, "bucket/file.exe")
|
||||
@@ -50,6 +50,19 @@ def test_zip_bomb_declared_size_over_cap_is_rejected(monkeypatch):
|
||||
assert "error" in out and "too much data" in out["error"]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize(
|
||||
"suffix", [".docm", ".xlsm", ".xlsb", ".pptm", ".ppsx", ".ppsm", ".odt", ".ods", ".odp"]
|
||||
)
|
||||
def test_zip_bomb_guard_covers_every_anydoc_zip_format(monkeypatch, suffix):
|
||||
"""Chat attachments have no extension whitelist, so every zip-packaged format
|
||||
the anydoc map routes must be gated the same way as .docx."""
|
||||
monkeypatch.setattr(dr.settings, "DOCUMENT_MAX_DECOMPRESSED_BYTES", 1000, raising=False)
|
||||
data = _make_zip({"content.xml": b"A" * 50_000})
|
||||
reason = dr._reject_zip_bomb(data, suffix)
|
||||
assert reason is not None and "too much data" in reason
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_zip_too_many_entries_is_rejected(monkeypatch):
|
||||
monkeypatch.setattr(dr.settings, "DOCUMENT_MAX_ARCHIVE_ENTRIES", 3, raising=False)
|
||||
@@ -186,7 +199,7 @@ class _FakeParser:
|
||||
@pytest.mark.unit
|
||||
def test_engine_picks_parser_and_coerces_list(monkeypatch):
|
||||
fake = _FakeParser(["chunk A", "chunk B"])
|
||||
monkeypatch.setattr(dr, "get_default_file_extractor", lambda ocr_enabled=None: {".pdf": fake})
|
||||
monkeypatch.setattr(dr, "get_default_file_extractor", lambda ocr_enabled=None, **kwargs: {".pdf": fake})
|
||||
out = parse_document_bytes(b"%PDF-1.4", "doc.pdf", output="text", engine="docling", include_tables=False)
|
||||
assert out["content"] == "chunk A\n\nchunk B"
|
||||
|
||||
@@ -213,6 +226,9 @@ def test_default_markdown_with_tables_converts_docling_once(monkeypatch):
|
||||
import sys
|
||||
import types
|
||||
|
||||
# engine=auto resolves to the server default; this test is about the docling path.
|
||||
monkeypatch.setattr(dr.settings, "DOC_PARSER_ENGINE", "docling")
|
||||
|
||||
from application.parser.file.docling_parser import DoclingParser
|
||||
|
||||
counter = {"instances": 0, "converts": 0}
|
||||
@@ -272,7 +288,7 @@ def test_default_markdown_with_tables_converts_docling_once(monkeypatch):
|
||||
# Only reached if the collapse path fails; the test asserts it doesn't.
|
||||
return "# parse-file content"
|
||||
|
||||
monkeypatch.setattr(dr, "get_default_file_extractor", lambda ocr_enabled=None: {".pdf": _FakeDoclingParser()})
|
||||
monkeypatch.setattr(dr, "get_default_file_extractor", lambda ocr_enabled=None, **kwargs: {".pdf": _FakeDoclingParser()})
|
||||
|
||||
# Defaults: output=markdown, engine=auto, include_tables=True -> the double-parse path.
|
||||
out = parse_document_bytes(b"%PDF-1.4", "doc.pdf")
|
||||
@@ -293,7 +309,7 @@ def test_default_markdown_with_tables_converts_docling_once(monkeypatch):
|
||||
@pytest.mark.unit
|
||||
def test_pages_slices_form_feed_blob(monkeypatch):
|
||||
fake = _FakeParser("page1\fpage2\fpage3")
|
||||
monkeypatch.setattr(dr, "get_default_file_extractor", lambda ocr_enabled=None: {".pdf": fake})
|
||||
monkeypatch.setattr(dr, "get_default_file_extractor", lambda ocr_enabled=None, **kwargs: {".pdf": fake})
|
||||
out = parse_document_bytes(b"%PDF", "doc.pdf", output="text", pages="2", engine="docling", include_tables=False)
|
||||
assert out["content"] == "page2"
|
||||
|
||||
@@ -443,7 +459,7 @@ def test_temp_file_cleaned_up_on_parser_error(monkeypatch):
|
||||
|
||||
fake = _FakeParser("x")
|
||||
fake.parse_file = lambda *a, **k: (_ for _ in ()).throw(RuntimeError("boom"))
|
||||
monkeypatch.setattr(dr, "get_default_file_extractor", lambda ocr_enabled=None: {".pdf": fake})
|
||||
monkeypatch.setattr(dr, "get_default_file_extractor", lambda ocr_enabled=None, **kwargs: {".pdf": fake})
|
||||
|
||||
out = parse_document_bytes(b"%PDF", "doc.pdf", output="text", engine="docling", include_tables=False)
|
||||
assert "error" in out and "parsing failed" in out["error"]
|
||||
@@ -455,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
|
||||
|
||||
|
||||
@@ -622,3 +638,183 @@ def test_structured_tables_render_blank_cells_as_strings(monkeypatch, tmp_path):
|
||||
assert all(isinstance(c, str) for c in cells), f"non-string cells: {cells!r}"
|
||||
assert "1001" in cells and "1001.0" not in cells
|
||||
assert "" in cells # blank cell renders as empty string, not "nan"/"None"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# engine=anydoc / auto: the default engine must never trigger a docling pass
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.mark.unit
|
||||
def test_anydoc_engine_is_accepted_and_passed_through(monkeypatch):
|
||||
fake = _FakeParser("anydoc text")
|
||||
seen = {}
|
||||
|
||||
def _extractor(ocr_enabled=None, **kwargs):
|
||||
seen.update(kwargs)
|
||||
return {".pdf": fake}
|
||||
|
||||
monkeypatch.setattr(dr, "get_default_file_extractor", _extractor)
|
||||
out = parse_document_bytes(b"%PDF-1.4", "doc.pdf", output="text", engine="anydoc", include_tables=False)
|
||||
assert out["content"] == "anydoc text"
|
||||
assert seen == {"engine": "anydoc"}
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_auto_engine_under_anydoc_skips_docling_table_pass(monkeypatch):
|
||||
"""Default read (markdown, engine=auto, include_tables) must not run a docling conversion
|
||||
just to harvest tables when the configured engine is anydoc — that pass would cost more
|
||||
than the whole read and re-import the stack anydoc exists to avoid."""
|
||||
monkeypatch.setattr(dr.settings, "DOC_PARSER_ENGINE", "anydoc")
|
||||
fake = _FakeParser("# md body")
|
||||
monkeypatch.setattr(dr, "get_default_file_extractor", lambda ocr_enabled=None, **kwargs: {".pdf": fake})
|
||||
|
||||
def _boom(*args, **kwargs):
|
||||
raise AssertionError("docling conversion attempted under the anydoc engine")
|
||||
|
||||
monkeypatch.setattr(dr, "_docling_structured", _boom)
|
||||
|
||||
out = parse_document_bytes(b"%PDF-1.4", "doc.pdf") # markdown, auto, include_tables=True
|
||||
|
||||
assert out["content"] == "# md body"
|
||||
assert "tables" not in out
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_explicit_docling_engine_still_collects_tables(monkeypatch):
|
||||
monkeypatch.setattr(dr.settings, "DOC_PARSER_ENGINE", "anydoc")
|
||||
fake = _FakeParser("body")
|
||||
monkeypatch.setattr(dr, "get_default_file_extractor", lambda ocr_enabled=None, **kwargs: {".pdf": fake})
|
||||
monkeypatch.setattr(
|
||||
dr,
|
||||
"_docling_structured",
|
||||
lambda *a, **k: {"markdown": "body", "tables": [{"markdown": "| t |"}], "structured": {}, "page_count": 1},
|
||||
)
|
||||
|
||||
out = parse_document_bytes(b"%PDF-1.4", "doc.pdf", engine="docling")
|
||||
|
||||
assert out["tables"] == [{"markdown": "| t |"}]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_structured_output_stays_docling_under_anydoc(monkeypatch):
|
||||
monkeypatch.setattr(dr.settings, "DOC_PARSER_ENGINE", "anydoc")
|
||||
calls = []
|
||||
|
||||
def _fake_structured(path, *, ocr_enabled, include_tables, parser=None):
|
||||
calls.append(path)
|
||||
return {"markdown": "# s", "tables": [], "structured": {"texts": []}, "page_count": 1}
|
||||
|
||||
monkeypatch.setattr(dr, "_docling_structured", _fake_structured)
|
||||
out = parse_document_bytes(b"%PDF-1.4", "doc.pdf", output="structured")
|
||||
assert out["output"] == "structured"
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
# --- second-pass routing fixes ---------------------------------------------------
|
||||
|
||||
|
||||
def test_fast_engine_covers_the_whole_legacy_map():
|
||||
"""`fast` must never fall through to the configured (anydoc/docling) map."""
|
||||
for suffix, name in {
|
||||
".xhtml": "HTMLParser",
|
||||
".pptx": "PPTXParser",
|
||||
".epub": "EpubParser",
|
||||
".rst": "RstParser",
|
||||
".json": "JSONParser",
|
||||
}.items():
|
||||
assert type(dr._legacy_parser_for(suffix)).__name__ == name
|
||||
|
||||
|
||||
@pytest.mark.parametrize("suffix", [".odt", ".doc", ".rtf", ".xls", ".ods", ".odp"])
|
||||
def test_fast_engine_is_terminal_for_anydoc_only_suffixes(monkeypatch, suffix):
|
||||
"""A suffix only anydoc reads has no legacy parser; `fast` must report that
|
||||
rather than silently running the configured engine the caller opted out of."""
|
||||
|
||||
def _boom(*args, **kwargs):
|
||||
raise AssertionError("fast fell through to the configured engine map")
|
||||
|
||||
monkeypatch.setattr(dr, "get_default_file_extractor", _boom)
|
||||
monkeypatch.setattr(dr, "_zip_bomb_reason", lambda *a, **k: None)
|
||||
|
||||
assert dr._pick_parser(suffix, ocr_enabled=False, engine="fast") is None
|
||||
out = parse_document_bytes(b"\xd0\xcf\x11\xe0 bytes", f"doc{suffix}", output="text", engine="fast")
|
||||
assert "fast engine" in out["error"] and suffix in out["error"]
|
||||
|
||||
|
||||
def test_fast_engine_still_falls_through_for_images_and_audio(monkeypatch):
|
||||
"""Formats with no legacy parser and no anydoc alternative keep the configured map."""
|
||||
seen = []
|
||||
monkeypatch.setattr(dr, "get_default_file_extractor", lambda **kwargs: seen.append(kwargs) or {})
|
||||
for suffix in (".png", ".jpg", ".mp3", ".txt"):
|
||||
dr._pick_parser(suffix, ocr_enabled=True, engine="fast")
|
||||
assert len(seen) == 4
|
||||
|
||||
|
||||
def test_tables_are_not_collected_when_the_pdf_parser_is_not_docling(monkeypatch):
|
||||
"""Under OCR_BACKEND=native the docling engine hands PDFs to the native OCR
|
||||
parser; a vanilla DocumentConverter table pass would OCR the scan again."""
|
||||
|
||||
from application.parser.file.ocr_parser import NativeOcrPdfParser
|
||||
|
||||
native = NativeOcrPdfParser()
|
||||
native._parser_config = {}
|
||||
monkeypatch.setattr(native, "parse_file", lambda path, errors="ignore": "native text")
|
||||
|
||||
calls = []
|
||||
monkeypatch.setattr(dr, "_pick_parser", lambda *a, **k: native)
|
||||
monkeypatch.setattr(dr, "_effective_engine", lambda engine: "docling")
|
||||
monkeypatch.setattr(dr, "_docling_structured", lambda *a, **k: calls.append(1) or {"tables": []})
|
||||
monkeypatch.setattr(dr, "_zip_bomb_reason", lambda *a, **k: None)
|
||||
|
||||
out = parse_document_bytes(b"%PDF-1.4", "scan.pdf", engine="docling", include_tables=True)
|
||||
|
||||
assert out["content"] == "native text"
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_tables_survive_native_ocr_delegation_for_text_only_pdfs(monkeypatch):
|
||||
"""OCR_BACKEND=native under the docling engine wraps a DoclingParser as the
|
||||
native parser's text_parser. A PDF with a text layer on every page is handed
|
||||
to that parser, so tables must ride that single conversion instead of being
|
||||
dropped by the scan exclusion."""
|
||||
|
||||
from application.parser.file.docling_parser import DoclingParser
|
||||
from application.parser.file.ocr_parser import NativeOcrPdfParser
|
||||
|
||||
class _Docling(DoclingParser):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._parser_config = {"ready": True}
|
||||
|
||||
docling = _Docling()
|
||||
native = NativeOcrPdfParser(text_parser=docling)
|
||||
native._parser_config = {}
|
||||
monkeypatch.setattr(native, "text_layer_delegate", lambda path: docling)
|
||||
monkeypatch.setattr(
|
||||
native, "parse_file", lambda path, errors="ignore": pytest.fail("native OCR ran on a text-only PDF")
|
||||
)
|
||||
|
||||
calls = []
|
||||
|
||||
def _fake_structured(path, *, ocr_enabled, include_tables, parser=None):
|
||||
calls.append(parser)
|
||||
return {"markdown": "# text pdf", "tables": [{"markdown": "| t |"}], "structured": {}, "page_count": 1}
|
||||
|
||||
monkeypatch.setattr(dr, "_pick_parser", lambda *a, **k: native)
|
||||
monkeypatch.setattr(dr, "_effective_engine", lambda engine: "docling")
|
||||
monkeypatch.setattr(dr, "_docling_structured", _fake_structured)
|
||||
monkeypatch.setattr(dr, "_zip_bomb_reason", lambda *a, **k: None)
|
||||
|
||||
out = parse_document_bytes(b"%PDF-1.4", "text.pdf", engine="docling", include_tables=True)
|
||||
|
||||
assert out["content"] == "# text pdf"
|
||||
assert out["tables"] == [{"markdown": "| t |"}]
|
||||
assert calls == [docling]
|
||||
|
||||
|
||||
def test_binary_office_suffix_without_anydoc_is_an_error_not_text(monkeypatch):
|
||||
monkeypatch.setattr(dr, "_pick_parser", lambda *a, **k: None)
|
||||
monkeypatch.setattr(dr, "_zip_bomb_reason", lambda *a, **k: None)
|
||||
|
||||
out = parse_document_bytes(b"\xd0\xcf\x11\xe0 OLE bytes", "legacy.doc", output="text")
|
||||
|
||||
assert "firecrawl-anydoc" in out["error"]
|
||||
@@ -12,3 +12,6 @@ jupyter-client>=8.0
|
||||
# (python-pptx and openpyxl already ship in application/requirements.txt).
|
||||
python-docx>=1.1
|
||||
reportlab>=4.0,<5
|
||||
# The optional docling engine stays installed in the test env so its parser
|
||||
# paths keep CI coverage even though production installs it on demand.
|
||||
-r ../application/requirements-docling.txt
|
||||
Reference in new issue
Block a user