Fixes batch 2

This commit is contained in:
Pavel committed 2026-09-04 13:38:29 +04:00
1 parent c17caf24ec
commit 4e14a79923
16 files changed
+278 -31

No files matched your search

+9 -7
View File
@@ -46,7 +46,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
# backend, read_document's structured output) — OFF by default: it pulls the
# layout/OCR model stack and adds gigabytes to the image. anydoc (in
# requirements.txt) is the default parser and needs none of it, and OCR runs
# natively on tesseract (below) without it.
# 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; \
@@ -67,12 +67,14 @@ RUN apt-get update && \
ln -s /usr/bin/python3.12 /usr/bin/python && \
rm -rf /var/lib/apt/lists/*
# OCR (OCR_ENABLED=true) runs on the system tesseract binary by default,
# with or without the docling extra, so ship it in every image (~35 MB).
# Opt out with --build-arg INSTALL_TESSERACT=false (e.g. a DeepSeek-OCR-only
# deployment). Extra language packs are a deployment concern
# (apt: tesseract-ocr-<lang>, then list them in OCR_LANGS).
ARG INSTALL_TESSERACT=true
# 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 && \
+3 -2
View File
@@ -165,8 +165,9 @@ class Settings(BaseSettings):
# menu):
# tesseract — recommended: best classic-engine accuracy (perfect EN word
# recall, 0.000 bilingual CER, 100% table cells), ~35 MB, CPU-only.
# Needs the system binary + language packs (the Docker image ships
# them unless built with INSTALL_TESSERACT=false). Both backends.
# 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.
+22
View File
@@ -186,6 +186,13 @@ def _pick_parser(suffix: str, *, ocr_enabled: bool, engine: str):
legacy = _legacy_parser_for(suffix)
if legacy is not None:
return legacy
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:
@@ -529,6 +536,16 @@ def _shape(
}
parser = _pick_parser(suffix, ocr_enabled=ocr_enabled, engine=engine)
# 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
@@ -569,6 +586,11 @@ def _shape(
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)"
+2 -2
View File
@@ -103,8 +103,8 @@ def _resolve_ocr_engine(requested: Optional[str]) -> str:
if engine == "tesseract" and shutil.which("tesseract") is None:
logger.warning(
"OCR_ENGINE=tesseract but no tesseract binary is on PATH (install "
"tesseract-ocr plus language packs; the Docker image ships them unless "
"built with INSTALL_TESSERACT=false); using docling auto-selection"
"tesseract-ocr plus language packs: Docker builds need "
"INSTALL_TESSERACT=true); using docling auto-selection"
)
return "auto"
if engine == "ocrmac" and (
+29 -3
View File
@@ -315,9 +315,9 @@ class TesseractEngine:
if not self.available():
raise OcrUnavailableError(
"OCR_ENGINE=tesseract but no tesseract binary is on PATH. Install "
"tesseract-ocr plus language packs (the Docker image ships them "
"unless built with INSTALL_TESSERACT=false), or set "
"OCR_ENGINE=deepseek / install the docling extra."
"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(
@@ -699,6 +699,32 @@ class NativeOcrPdfParser(BaseParser):
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
+24 -3
View File
@@ -29,7 +29,7 @@ import zlib
from pathlib import Path
from typing import Dict, Iterator, List, Optional, Union
_OBJ = re.compile(rb"\d+\s+\d+\s+obj(.*?)endobj", re.DOTALL)
_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"
@@ -99,6 +99,28 @@ def _stream_bodies(raw: bytes) -> Iterator[bytes]:
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):
@@ -145,8 +167,7 @@ def check_pdf_fonts(data: bytes) -> Dict[str, Union[int, bool]]:
if b"/ToUnicode" not in unit:
bare += 1
for match in _OBJ.finditer(data):
body = match.group(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