import re from pathlib import Path import fitz # PyMuPDF from app.core.logging import get_logger logger = get_logger(__name__) # If extracted text is fewer than this many characters it's almost certainly # a scanned / image-only PDF that we cannot handle in MVP. SCANNED_THRESHOLD = 100 class ExtractionError(Exception): """Raised when the PDF cannot be meaningfully extracted.""" def extract_text(path: Path) -> tuple[str, int]: """ Extract and clean text from a digital PDF. Returns ------- text : str Cleaned, whitespace-normalised body text. page_count : int Number of pages in the document. Raises ------ ExtractionError If the file is unreadable or appears to be a scanned/image PDF. """ try: doc = fitz.open(str(path)) except Exception as exc: logger.error("Could not open PDF %s: %s", path, exc) raise ExtractionError("Could not read this PDF.") from exc page_count = len(doc) raw_pages: list[str] = [] for page in doc: raw_pages.append(page.get_text("text")) # type: ignore[arg-type] doc.close() full_text = "\n".join(raw_pages) cleaned = _clean(full_text) if len(cleaned) < SCANNED_THRESHOLD: logger.warning( "PDF %s yielded only %d chars — likely scanned", path, len(cleaned) ) raise ExtractionError( "This looks like a scanned resume; MVP supports digital text PDFs only." ) logger.info("Extracted %d chars from %d-page PDF %s", len(cleaned), page_count, path.name) return cleaned, page_count def _clean(raw: str) -> str: """Normalise whitespace and remove common PDF artifacts.""" # Collapse runs of whitespace that aren't newlines text = re.sub(r"[ \t]+", " ", raw) # Collapse 3+ consecutive blank lines into 2 text = re.sub(r"\n{3,}", "\n\n", text) # Strip leading/trailing whitespace per line lines = [line.strip() for line in text.splitlines()] return "\n".join(lines).strip()