Spaces:
Running
Running
| """ | |
| parser.py | |
| --------- | |
| Production-grade resume and JD file parser. | |
| Improvements: | |
| - pdfplumber as primary PDF engine (handles tables, multi-column layouts) | |
| - PyPDF2 as automatic fallback | |
| - DOCX: extracts headers, tables, and text boxes (None-safe style check) | |
| - Encoding detection for TXT files (UTF-8, UTF-16, Latin-1) | |
| - File size validation (max 10 MB) | |
| Author: SmartHire AI | |
| """ | |
| import io | |
| import logging | |
| from pathlib import Path | |
| from typing import Optional, Union | |
| logger = logging.getLogger(__name__) | |
| MAX_FILE_SIZE_MB = 10 | |
| MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024 | |
| # -- PDF Extraction --------------------------------------------------- | |
| def extract_text_from_pdf(file: Union[bytes, io.BytesIO]) -> str: | |
| """ | |
| Extract text from PDF using pdfplumber (primary) with PyPDF2 fallback. | |
| """ | |
| if isinstance(file, bytes): | |
| file = io.BytesIO(file) | |
| # Try pdfplumber first | |
| try: | |
| import pdfplumber | |
| file.seek(0) | |
| with pdfplumber.open(file) as pdf: | |
| pages_text = [] | |
| for page_num, page in enumerate(pdf.pages): | |
| page_text = page.extract_text(x_tolerance=3, y_tolerance=3) | |
| tables = page.extract_tables() | |
| table_text = "" | |
| for table in tables: | |
| for row in table: | |
| row_cells = [cell.strip() if cell else "" for cell in row] | |
| table_text += " ".join(row_cells) + "\n" | |
| combined = "" | |
| if page_text: | |
| combined += page_text | |
| if table_text: | |
| combined += "\n" + table_text | |
| if combined.strip(): | |
| pages_text.append(combined) | |
| else: | |
| logger.warning(f"Page {page_num + 1}: no text extracted") | |
| full_text = "\n\n".join(pages_text) | |
| if full_text.strip(): | |
| logger.info(f"pdfplumber: extracted {len(full_text)} chars from {len(pdf.pages)} pages") | |
| return full_text | |
| except ImportError: | |
| logger.warning("pdfplumber not installed -- trying PyPDF2") | |
| except Exception as e: | |
| logger.warning(f"pdfplumber failed ({e}) -- trying PyPDF2") | |
| # Fallback: PyPDF2 | |
| try: | |
| import PyPDF2 | |
| if isinstance(file, io.BytesIO): | |
| file.seek(0) | |
| else: | |
| file = io.BytesIO(file) | |
| reader = PyPDF2.PdfReader(file) | |
| text_parts = [] | |
| for page_num, page in enumerate(reader.pages): | |
| page_text = page.extract_text() | |
| if page_text: | |
| text_parts.append(page_text) | |
| else: | |
| logger.warning(f"PyPDF2: no text on page {page_num + 1}") | |
| full_text = "\n".join(text_parts) | |
| if full_text.strip(): | |
| logger.info(f"PyPDF2: extracted {len(full_text)} chars") | |
| return full_text | |
| raise ValueError("No text extracted from PDF. File may be image-based (scanned).") | |
| except ImportError: | |
| raise ImportError("No PDF parser installed. Run: pip install pdfplumber PyPDF2") | |
| except Exception as e: | |
| raise ValueError(f"PDF parsing failed: {e}") | |
| # -- DOCX Extraction -------------------------------------------------- | |
| def extract_text_from_docx(file: Union[bytes, io.BytesIO]) -> str: | |
| """ | |
| Extract text from DOCX including paragraphs, tables, and headers. | |
| Handles None styles safely (some DOCX files have unstyled paragraphs). | |
| """ | |
| try: | |
| from docx import Document | |
| except ImportError: | |
| raise ImportError("python-docx is required. Run: pip install python-docx") | |
| if isinstance(file, bytes): | |
| file = io.BytesIO(file) | |
| try: | |
| doc = Document(file) | |
| parts = [] | |
| for para in doc.paragraphs: | |
| text = para.text.strip() | |
| if not text: | |
| continue | |
| # FIX: para.style or para.style.name can be None in some DOCX files | |
| try: | |
| style_name = para.style.name if para.style and para.style.name else "" | |
| except Exception: | |
| style_name = "" | |
| if style_name.startswith("Heading"): | |
| parts.append(f"\n{text.upper()}\n") | |
| else: | |
| parts.append(text) | |
| # Extract table contents | |
| for table in doc.tables: | |
| for row in table.rows: | |
| row_cells = [] | |
| for cell in row.cells: | |
| cell_text = cell.text.strip() if cell.text else "" | |
| if cell_text: | |
| row_cells.append(cell_text) | |
| if row_cells: | |
| parts.append(" | ".join(row_cells)) | |
| full_text = "\n".join(parts) | |
| # Fallback: try reading body XML if no text found | |
| if not full_text.strip(): | |
| try: | |
| import re | |
| xml_content = doc.element.body.xml | |
| clean = re.sub(r'<[^>]+>', ' ', xml_content) | |
| clean = re.sub(r'\s+', ' ', clean).strip() | |
| if clean: | |
| logger.warning("DOCX: used XML fallback extraction") | |
| return clean | |
| except Exception: | |
| pass | |
| raise ValueError("No text extracted from DOCX — file may be empty or image-based.") | |
| logger.info(f"DOCX: extracted {len(full_text)} chars") | |
| return full_text | |
| except ValueError: | |
| raise | |
| except Exception as e: | |
| raise ValueError(f"DOCX parsing failed: {e}") | |
| # -- TXT Extraction --------------------------------------------------- | |
| def extract_text_from_txt(file: Union[bytes, io.BytesIO, str]) -> str: | |
| """ | |
| Extract text from TXT file with encoding detection. | |
| Tries UTF-8, UTF-16, Latin-1 in order. | |
| """ | |
| encodings = ["utf-8", "utf-16", "latin-1", "cp1252"] | |
| try: | |
| if isinstance(file, str): | |
| for enc in encodings: | |
| try: | |
| with open(file, "r", encoding=enc, errors="strict") as f: | |
| text = f.read() | |
| logger.info(f"TXT: read {len(text)} chars (encoding: {enc})") | |
| return text | |
| except (UnicodeDecodeError, UnicodeError): | |
| continue | |
| with open(file, "r", encoding="utf-8", errors="replace") as f: | |
| return f.read() | |
| elif isinstance(file, bytes): | |
| raw = file | |
| elif isinstance(file, io.BytesIO): | |
| raw = file.read() | |
| else: | |
| raise ValueError(f"Unsupported type: {type(file)}") | |
| for enc in encodings: | |
| try: | |
| text = raw.decode(enc) | |
| logger.info(f"TXT: decoded {len(text)} chars (encoding: {enc})") | |
| return text | |
| except (UnicodeDecodeError, UnicodeError): | |
| continue | |
| text = raw.decode("utf-8", errors="replace") | |
| logger.warning("TXT decoded with replacement characters") | |
| return text | |
| except Exception as e: | |
| raise ValueError(f"TXT parsing failed: {e}") | |
| # -- Public API ------------------------------------------------------- | |
| def validate_file_size(file_bytes: bytes, filename: str) -> None: | |
| """Raise ValueError if file exceeds MAX_FILE_SIZE_MB.""" | |
| size_mb = len(file_bytes) / (1024 * 1024) | |
| if size_mb > MAX_FILE_SIZE_MB: | |
| raise ValueError( | |
| f"File '{filename}' is {size_mb:.1f} MB — maximum is {MAX_FILE_SIZE_MB} MB." | |
| ) | |
| def parse_resume(file: Union[bytes, io.BytesIO], filename: str) -> str: | |
| """ | |
| Parse a resume file and return extracted text. | |
| Supports PDF, DOCX, TXT. | |
| """ | |
| if isinstance(file, bytes): | |
| validate_file_size(file, filename) | |
| suffix = Path(filename).suffix.lower() | |
| logger.info(f"Parsing resume: {filename} ({suffix})") | |
| if suffix == ".pdf": | |
| return extract_text_from_pdf(file) | |
| elif suffix == ".docx": | |
| return extract_text_from_docx(file) | |
| elif suffix == ".txt": | |
| return extract_text_from_txt(file) | |
| else: | |
| raise ValueError(f"Unsupported format: '{suffix}'. Supported: PDF, DOCX, TXT.") | |
| def parse_job_description( | |
| text_or_file: Union[str, bytes, io.BytesIO], | |
| filename: Optional[str] = None, | |
| ) -> str: | |
| """ | |
| Parse a job description from pasted text or uploaded file. | |
| """ | |
| if isinstance(text_or_file, str): | |
| if not text_or_file.strip(): | |
| raise ValueError("Job description text is empty.") | |
| logger.info(f"JD received as text ({len(text_or_file)} chars)") | |
| return text_or_file | |
| if filename is None: | |
| raise ValueError("filename is required when passing a file.") | |
| return parse_resume(text_or_file, filename) | |