225 lines
7.4 KiB
Python
225 lines
7.4 KiB
Python
"""Document-level PDF routing (pdf-inspector-inspired, in-house).
|
|
|
|
Classifies TextBased / Scanned / ImageBased / Mixed and lists pages needing OCR.
|
|
Does not call Firecrawl or pdf-inspector — pure heuristics over pypdf + optional engine.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from dataclasses import dataclass, field
|
|
from enum import Enum
|
|
|
|
from pypdf import PdfReader
|
|
|
|
from app.services.convert import doc_cache
|
|
|
|
|
|
class PdfDocType(str, Enum):
|
|
text_based = "text_based"
|
|
scanned = "scanned"
|
|
image_based = "image_based"
|
|
mixed = "mixed"
|
|
|
|
|
|
class ScanStrategy(str, Enum):
|
|
early_exit = "early_exit"
|
|
full = "full"
|
|
sample = "sample"
|
|
|
|
|
|
@dataclass
|
|
class PdfRouteResult:
|
|
doc_type: PdfDocType
|
|
confidence: float
|
|
pages_needing_ocr: list[int] = field(default_factory=list)
|
|
pages_sampled: list[int] = field(default_factory=list)
|
|
strategy: str = "full"
|
|
|
|
def as_dict(self) -> dict:
|
|
return {
|
|
"doc_type": self.doc_type.value,
|
|
"confidence": round(self.confidence, 4),
|
|
"pages_needing_ocr": list(self.pages_needing_ocr),
|
|
"pages_sampled": list(self.pages_sampled),
|
|
"strategy": self.strategy,
|
|
}
|
|
|
|
|
|
def _strategy_from_env() -> tuple[ScanStrategy, int]:
|
|
raw = (os.environ.get("CONVERT_PDF_SCAN_STRATEGY") or "full").strip().lower()
|
|
try:
|
|
n = int(os.environ.get("CONVERT_PDF_SCAN_SAMPLE", "5"))
|
|
except ValueError:
|
|
n = 5
|
|
n = max(2, min(n, 50))
|
|
if raw in ("early_exit", "early-exit", "early"):
|
|
return ScanStrategy.early_exit, n
|
|
if raw in ("sample", "sampled"):
|
|
return ScanStrategy.sample, n
|
|
return ScanStrategy.full, n
|
|
|
|
|
|
def _page_needs_ocr(reader: PdfReader, index: int, data: bytes | None = None) -> tuple[bool, str]:
|
|
"""Heuristic: sparse / garbled extractable text → OCR. Returns (needs_ocr, reason)."""
|
|
from app.services.convert.layout.text_quality import is_garbled_text, page_text_needs_ocr
|
|
|
|
try:
|
|
page = reader.pages[index]
|
|
text = (
|
|
doc_cache.page_text(data, index) if data is not None else (page.extract_text() or "")
|
|
).strip()
|
|
except Exception:
|
|
return True, "extract_failed"
|
|
|
|
# Image XObjects hint
|
|
has_image = False
|
|
try:
|
|
resources = page.get("/Resources") or {}
|
|
if hasattr(resources, "get_object"):
|
|
resources = resources.get_object()
|
|
xobj = resources.get("/XObject") if resources else None
|
|
if xobj is not None:
|
|
if hasattr(xobj, "get_object"):
|
|
xobj = xobj.get_object()
|
|
for _name, ref in (xobj.items() if hasattr(xobj, "items") else []):
|
|
try:
|
|
obj = ref.get_object() if hasattr(ref, "get_object") else ref
|
|
if obj.get("/Subtype") == "/Image":
|
|
has_image = True
|
|
break
|
|
except Exception:
|
|
continue
|
|
except Exception:
|
|
pass
|
|
|
|
chars = len(text)
|
|
# Image-only / nearly empty → OCR. Decorative images beside real text → keep digital.
|
|
if chars < 12:
|
|
return True, "image_only" if has_image else "sparse_text"
|
|
# Broken ToUnicode / CID garbage (Huwiyati-class) must not count as "text_ok"
|
|
if is_garbled_text(text) or page_text_needs_ocr(text, image_coverage=0.2 if has_image else 0.0):
|
|
return True, "garbled_text"
|
|
return False, "text_ok"
|
|
|
|
|
|
def _sample_indices(n_pages: int, strategy: ScanStrategy, sample_n: int) -> list[int]:
|
|
if n_pages <= 0:
|
|
return []
|
|
if strategy == ScanStrategy.full or strategy == ScanStrategy.early_exit:
|
|
return list(range(n_pages))
|
|
# sample: first, last, evenly spaced middle
|
|
if n_pages <= sample_n:
|
|
return list(range(n_pages))
|
|
idxs = {0, n_pages - 1}
|
|
mid_slots = sample_n - 2
|
|
if mid_slots > 0:
|
|
step = (n_pages - 1) / (mid_slots + 1)
|
|
for i in range(1, mid_slots + 1):
|
|
idxs.add(min(n_pages - 1, max(0, int(round(i * step)))))
|
|
return sorted(idxs)
|
|
|
|
|
|
def route_pdf(data: bytes, *, strategy: ScanStrategy | None = None) -> PdfRouteResult:
|
|
"""Classify PDF and list 0-based page indices that need OCR."""
|
|
strat, sample_n = _strategy_from_env()
|
|
if strategy is not None:
|
|
strat = strategy
|
|
|
|
try:
|
|
reader = doc_cache.get_reader(data)
|
|
except Exception:
|
|
return PdfRouteResult(
|
|
doc_type=PdfDocType.scanned,
|
|
confidence=0.3,
|
|
pages_needing_ocr=[],
|
|
pages_sampled=[],
|
|
strategy=strat.value,
|
|
)
|
|
|
|
n_pages = len(reader.pages)
|
|
indices = _sample_indices(n_pages, strat, sample_n)
|
|
needing: list[int] = []
|
|
sampled: list[int] = []
|
|
text_ok = 0
|
|
image_heavy = 0
|
|
garbled_heavy = 0
|
|
|
|
for i in indices:
|
|
sampled.append(i)
|
|
needs, reason = _page_needs_ocr(reader, i, data)
|
|
if needs:
|
|
needing.append(i)
|
|
if reason in ("image_only", "hybrid_sparse"):
|
|
image_heavy += 1
|
|
if reason == "garbled_text":
|
|
garbled_heavy += 1
|
|
else:
|
|
text_ok += 1
|
|
if strat == ScanStrategy.early_exit and needing and not text_ok:
|
|
# First page already needs OCR and no prior text → treat as scanned early
|
|
break
|
|
|
|
# Expand early_exit / sample results to full page lists when mixed
|
|
if strat == ScanStrategy.early_exit and needing and text_ok == 0 and len(sampled) < n_pages:
|
|
# Likely fully scanned — mark all pages
|
|
needing = list(range(n_pages))
|
|
sampled = list(range(n_pages))
|
|
elif strat == ScanStrategy.sample and needing:
|
|
# Conservative: any sampled OCR page → include that page; do not invent others
|
|
pass
|
|
|
|
total = max(len(sampled), 1)
|
|
ocr_frac = len(needing) / total
|
|
text_frac = text_ok / total
|
|
garbled_frac = garbled_heavy / total
|
|
|
|
# Majority garbled / OCR-needed → promote whole document (hybrid RFP class)
|
|
if ocr_frac >= 0.5 and len(sampled) < n_pages:
|
|
needing = list(range(n_pages))
|
|
sampled = list(range(n_pages))
|
|
ocr_frac = 1.0
|
|
text_frac = 0.0
|
|
elif ocr_frac >= 0.5 and n_pages > 0 and len(needing) < n_pages:
|
|
needing = list(range(n_pages))
|
|
ocr_frac = 1.0
|
|
text_frac = 0.0
|
|
|
|
if ocr_frac >= 0.95:
|
|
if garbled_frac >= 0.4:
|
|
doc_type = PdfDocType.mixed if text_ok > 0 else PdfDocType.scanned
|
|
else:
|
|
doc_type = PdfDocType.image_based if image_heavy >= text_ok else PdfDocType.scanned
|
|
conf = 0.55 + 0.4 * ocr_frac
|
|
elif ocr_frac <= 0.05:
|
|
doc_type = PdfDocType.text_based
|
|
conf = 0.55 + 0.4 * text_frac
|
|
else:
|
|
doc_type = PdfDocType.mixed
|
|
conf = 0.5 + 0.3 * (1.0 - abs(0.5 - ocr_frac))
|
|
|
|
# Unique sorted
|
|
needing = sorted(set(needing))
|
|
return PdfRouteResult(
|
|
doc_type=doc_type,
|
|
confidence=min(1.0, conf),
|
|
pages_needing_ocr=needing,
|
|
pages_sampled=sampled,
|
|
strategy=strat.value,
|
|
)
|
|
|
|
|
|
def page_kind_hint(doc_type: PdfDocType, page_index: int, pages_needing_ocr: list[int]):
|
|
"""Map route into PageKind-compatible hint for a page."""
|
|
from app.services.convert.idm.model import PageKind
|
|
|
|
if page_index in pages_needing_ocr:
|
|
if doc_type == PdfDocType.mixed:
|
|
return PageKind.hybrid
|
|
return PageKind.scan
|
|
if doc_type == PdfDocType.text_based:
|
|
return PageKind.digital
|
|
if doc_type == PdfDocType.mixed:
|
|
return PageKind.digital
|
|
return PageKind.scan
|