"""Tier-1 fast PDF text extractor (pypdfium2). A permissively-licensed (Apache/BSD-2) fast path that extracts a PDF's text layer + page boundaries WITHOUT pymupdf4llm's expensive O(n^2) table/graphics analysis. For born-digital PDFs (the tier-0 classifier's ``fast`` verdict) this returns clean text in well under a second -- including the form/table PDFs that timed out under pymupdf4llm (e.g. ``Student 1a.pdf``: 120s timeout -> ~1s here). bbox is re-derived from the PDF bytes + ``page_boundaries`` by ``search/pdf_highlighter``, so this processor only needs to emit ``text`` and ``metadata["page_boundaries"]`` for chunk highlighting to keep working. It deliberately does NOT recover tables/layout; a low-quality result is meant to escalate to the ``structured`` tier (pymupdf4llm, graphics_limit-guarded) via the registry (B2 escalation wiring). """ import logging from collections.abc import Awaitable, Callable from typing import Any import anyio from .base import DocumentProcessor, ProcessingResult logger = logging.getLogger(__name__) def _extract(content: bytes) -> tuple[str, dict[str, Any]]: """Extract concatenated text + metadata from a PDF (runs in a worker thread). ``page_boundaries`` offsets index into the returned text, which is the page texts joined with no separator so the offsets stay exact (the contract ``search/pdf_highlighter`` and the chunker rely on). """ import pypdfium2 as pdfium # noqa: PLC0415 -- keep the native import lazy pdf = pdfium.PdfDocument(content) try: page_texts: list[str] = [] for i in range(len(pdf)): page = pdf[i] try: textpage = page.get_textpage() try: page_texts.append(textpage.get_text_bounded() or "") finally: textpage.close() finally: # Outer finally so the page handle is freed even if # get_textpage() raises on a corrupt page. page.close() doc_meta = pdf.get_metadata_dict() or {} finally: pdf.close() page_boundaries: list[dict[str, Any]] = [] offset = 0 for n, text in enumerate(page_texts, start=1): page_boundaries.append( {"page": n, "start_offset": offset, "end_offset": offset + len(text)} ) offset += len(text) full_text = "".join(page_texts) metadata: dict[str, Any] = { "page_count": len(page_texts), "page_boundaries": page_boundaries, } title = doc_meta.get("Title") if title: metadata["title"] = title return full_text, metadata class Pypdfium2FastProcessor(DocumentProcessor): """Tier-1 fast PDF text extractor backed by pypdfium2.""" @property def name(self) -> str: return "pypdfium2_fast" @property def tier(self) -> str: return "fast" @property def supported_mime_types(self) -> set[str]: return {"application/pdf"} async def process( self, content: bytes, content_type: str, filename: str | None = None, options: dict[str, Any] | None = None, progress_callback: ( Callable[[float, float | None, str | None], Awaitable[None]] | None ) = None, ) -> ProcessingResult: if progress_callback: await progress_callback(0, 100, "Extracting text (pypdfium2)") try: full_text, metadata = await anyio.to_thread.run_sync( # type: ignore[attr-defined] _extract, content ) except Exception as e: # Fast path is best-effort: a failure here escalates rather than # crashing the pipeline. pypdfium2 has no O(n^2) bomb, so this is a # genuinely malformed PDF, not a resource blowup. logger.warning( "pypdfium2 fast extract failed for %s: %s", filename or "", e ) return ProcessingResult( text="", metadata={"parse_failed_reason": "error"}, processor=self.name, success=False, error=f"{type(e).__name__}: {e}", ) metadata["file_size"] = len(content) if progress_callback: await progress_callback(100, 100, "Done") return ProcessingResult(text=full_text, metadata=metadata, processor=self.name) async def health_check(self) -> bool: try: import pypdfium2 # noqa: F401, PLC0415 -- availability probe return True except Exception: return False