Spaces:
Sleeping
Sleeping
File size: 8,782 Bytes
91e794e | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 | """
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)
|