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)