File size: 12,814 Bytes
a4db4ad
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389

import os
import re
import sys
import traceback

import pdfplumber
from docx import Document


# =============================================================================
# INTERNAL HELPER β€” Text Cleaning
# =============================================================================

def clean_text(text: str) -> str:
    """
    Clean raw extracted text for downstream processing.

    What this does:
    - Removes non-printable / control characters (common in PDFs)
    - Collapses multiple blank lines into a single blank line
    - Strips leading/trailing whitespace from each line
    - Preserves paragraph structure (single blank line between sections)
    - Does NOT remove punctuation or change any words β€” extractor.py needs raw content

    Args:
        text: Raw string from pdfplumber or python-docx

    Returns:
        Cleaned string, ready for extractor.py
    """
    if not text:
        return ""

    # Step 1: Remove non-printable characters (null bytes, form feeds etc)
    # Keep: normal chars, newlines, tabs
    text = re.sub(r'[^\x09\x0A\x0D\x20-\x7E\u00A0-\uFFFF]', ' ', text)

    # Step 2: Normalise line endings (Windows \r\n β†’ \n)
    text = text.replace('\r\n', '\n').replace('\r', '\n')

    # Step 3: Strip trailing whitespace from each line
    lines = [line.rstrip() for line in text.split('\n')]

    # Step 4: Collapse 3+ consecutive blank lines into 2
    # This preserves section gaps while removing excessive whitespace
    cleaned_lines = []
    blank_count = 0
    for line in lines:
        if line.strip() == '':
            blank_count += 1
            if blank_count <= 2:
                cleaned_lines.append('')
        else:
            blank_count = 0
            cleaned_lines.append(line)

    # Step 5: Remove leading/trailing blank lines from the whole document
    result = '\n'.join(cleaned_lines).strip()

    return result


# =============================================================================
# PDF EXTRACTION
# =============================================================================

def extract_text_from_pdf(file_path: str) -> str:
    """
    Extract all text from a PDF resume using pdfplumber.

    Handles:
    - Multi-page PDFs (reads all pages)
    - Single and multi-column layouts
    - Tables inside PDFs (extracts cell text)
    - PDFs with mixed text + table sections

    Does NOT handle:
    - Scanned/image PDFs (these have no embedded text β€” returns empty string)
    - Password-protected PDFs (raises an error)

    Args:
        file_path: Absolute or relative path to the PDF file

    Returns:
        Cleaned text string. Empty string if no text found (likely scanned PDF).

    Raises:
        FileNotFoundError: If file does not exist
        Exception: If pdfplumber cannot open the file
    """
    if not os.path.exists(file_path):
        raise FileNotFoundError(f"PDF not found: {file_path}")

    all_text = []

    with pdfplumber.open(file_path) as pdf:
        for page_num, page in enumerate(pdf.pages, start=1):
            page_text_parts = []

            # --- Extract regular text ---
            text = page.extract_text()
            if text:
                page_text_parts.append(text)

            # --- Extract text from tables ---
            # Some resumes put skills or experience in HTML-style tables
            # pdfplumber reads these separately from regular text
            tables = page.extract_tables()
            for table in tables:
                for row in table:
                    # Each row is a list of cell values (some may be None)
                    row_text = '  '.join(
                        cell.strip() for cell in row
                        if cell and cell.strip()
                    )
                    if row_text:
                        page_text_parts.append(row_text)

            # Combine text from this page
            if page_text_parts:
                page_combined = '\n'.join(page_text_parts)
                all_text.append(f"[Page {page_num}]\n{page_combined}")

    raw_text = '\n\n'.join(all_text)
    return clean_text(raw_text)


# =============================================================================
# DOCX EXTRACTION
# =============================================================================

def extract_text_from_docx(file_path: str) -> str:
    """
    Extract all text from a DOCX resume using python-docx.

    Handles:
    - All paragraph text (normal, headings, bullets)
    - Tables inside DOCX (common in formatted resumes)
    - Bold/italic text (text content preserved, formatting ignored)
    - Multi-section documents

    Args:
        file_path: Absolute or relative path to the DOCX file

    Returns:
        Cleaned text string.

    Raises:
        FileNotFoundError: If file does not exist
    """
    if not os.path.exists(file_path):
        raise FileNotFoundError(f"DOCX not found: {file_path}")

    doc = Document(file_path)
    all_text = []

    # --- Extract paragraph text ---
    for para in doc.paragraphs:
        text = para.text.strip()
        if text:
            all_text.append(text)

    # --- Extract table text ---
    # Many DOCX resumes use tables for layout (two-column skill sections etc)
    for table in doc.tables:
        for row in table.rows:
            row_parts = []
            for cell in row.cells:
                cell_text = cell.text.strip()
                if cell_text:
                    row_parts.append(cell_text)
            if row_parts:
                all_text.append('  |  '.join(row_parts))

    raw_text = '\n'.join(all_text)
    return clean_text(raw_text)


# =============================================================================
# MAIN ROUTER β€” Use This Function in All Other Files
# =============================================================================

def extract_text(file_path: str) -> str:
    """
    Auto-detect file type and extract text from a resume.

    This is the only function you need to call from outside this file.
    It detects .pdf vs .docx and routes to the correct extractor.

    Args:
        file_path: Path to a .pdf or .docx resume file

    Returns:
        Cleaned text string ready for extractor.py

    Raises:
        ValueError:  If file extension is not .pdf or .docx
        FileNotFoundError: If file does not exist
    """
    # Check extension first β€” unsupported format is a config error, not a missing file
    ext = os.path.splitext(file_path)[1].lower()
    if ext not in ('.pdf', '.docx'):
        raise ValueError(
            f"Unsupported file format: '{ext}'\n"
            f"Supported formats: .pdf, .docx\n"
            f"File: {file_path}"
        )

    # Then check existence
    if not os.path.exists(file_path):
        raise FileNotFoundError(
            f"File not found: {file_path}\n"
            f"Make sure the file is saved in data/resumes/"
        )

    if ext == '.pdf':
        return extract_text_from_pdf(file_path)
    else:
        return extract_text_from_docx(file_path)


# =============================================================================
# BATCH LOADER β€” Processes an Entire Folder
# =============================================================================

def load_resumes_from_folder(folder_path: str,
                              verbose: bool = True) -> list:
    """
    Load and parse all PDF and DOCX resumes from a folder.

    Processes every supported file in the folder.
    Skips unsupported files silently.
    Logs a warning for files that fail to parse (does not crash).

    Args:
        folder_path: Path to folder containing resume files
        verbose:     If True, prints progress for each file

    Returns:
        List of dicts, one per successfully parsed resume:
        [
            {
                "file_name" : "resume_1.pdf",
                "file_path" : "/full/path/to/resume_1.pdf",
                "raw_text"  : "Priya Sharma\nData Analyst...",
                "char_count": 3241,
                "word_count": 512,
                "status"    : "ok"
            },
            ...
        ]
        Failed files are included with status "failed" and empty raw_text,
        so you can see what went wrong without crashing the pipeline.
    """
    if not os.path.exists(folder_path):
        raise FileNotFoundError(
            f"Resume folder not found: {folder_path}\n"
            f"Create it and add PDF/DOCX files, then run again."
        )

    # Find all supported files in the folder
    all_files = os.listdir(folder_path)
    supported = [
        f for f in sorted(all_files)
        if os.path.splitext(f)[1].lower() in ('.pdf', '.docx')
    ]
    skipped = [
        f for f in all_files
        if f not in supported and not f.startswith('.')
    ]

    if verbose:
        print(f"\n[Parser] Found {len(supported)} supported file(s) "
              f"in '{os.path.basename(folder_path)}'")
        if skipped:
            print(f"[Parser] Skipping {len(skipped)} unsupported file(s): "
                  f"{', '.join(skipped[:5])}")

    results = []

    for i, fname in enumerate(supported, start=1):
        fpath = os.path.join(folder_path, fname)

        if verbose:
            print(f"[Parser] ({i}/{len(supported)}) Parsing: {fname} ...", end=' ')

        try:
            text = extract_text(fpath)

            # Quality check β€” warn if text is suspiciously short
            word_count = len(text.split())
            char_count = len(text)

            if word_count < 30:
                status = "warning_short"
                if verbose:
                    print(f"⚠ Only {word_count} words extracted β€” "
                          f"may be a scanned or image-based PDF")
            else:
                status = "ok"
                if verbose:
                    print(f"βœ“  {char_count:,} chars | {word_count:,} words")

            results.append({
                "file_name"  : fname,
                "file_path"  : os.path.abspath(fpath),
                "raw_text"   : text,
                "char_count" : char_count,
                "word_count" : word_count,
                "status"     : status,
            })

        except Exception as e:
            if verbose:
                print(f"βœ— FAILED β€” {type(e).__name__}: {e}")
            results.append({
                "file_name"  : fname,
                "file_path"  : os.path.abspath(fpath),
                "raw_text"   : "",
                "char_count" : 0,
                "word_count" : 0,
                "status"     : f"failed: {type(e).__name__}: {e}",
            })

    # Summary
    ok_count   = sum(1 for r in results if r["status"] == "ok")
    fail_count = sum(1 for r in results if r["status"].startswith("failed"))
    warn_count = sum(1 for r in results if r["status"] == "warning_short")

    if verbose:
        print(f"\n[Parser] Done β€” "
              f"{ok_count} parsed OK | "
              f"{warn_count} warnings | "
              f"{fail_count} failed\n")

    return results


# =============================================================================
# QUICK SELF-TEST
# Run this file directly to test parser on your resumes:
#   python engine/parser.py
# =============================================================================

if __name__ == "__main__":

    # Add project root to path so config.py can be imported
    sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

    try:
        import config
        resume_folder = config.RESUME_DIR
    except ImportError:
        # Fallback if run outside project structure
        resume_folder = os.path.join(
            os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
            "data", "../data/resumes"
        )

    print("\n" + "="*60)
    print("  PARSER SELF-TEST")
    print("="*60)
    print(f"  Resume folder: {resume_folder}")

    if not os.path.exists(resume_folder):
        print(f"\n  Folder does not exist yet.")
        print(f"  Create it and add PDF/DOCX files:\n  {resume_folder}\n")
        sys.exit(0)

    resumes = load_resumes_from_folder(resume_folder, verbose=True)

    if not resumes:
        print("\n  No resumes found. Add PDF/DOCX files to the folder above.\n")
        sys.exit(0)

    # Show a preview of the first successfully parsed resume
    ok_resumes = [r for r in resumes if r["status"] == "ok"]
    if ok_resumes:
        first = ok_resumes[0]
        print("="*60)
        print(f"  PREVIEW β€” {first['file_name']}")
        print("="*60)
        preview_lines = first["raw_text"].split('\n')[:20]
        for line in preview_lines:
            if line.strip():
                print(f"  {line}")
        print(f"\n  ... ({first['word_count']} total words)\n")