File size: 7,636 Bytes
c425de3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43c3b77
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c425de3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43c3b77
c425de3
 
43c3b77
 
 
 
c425de3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Post-generation PDF parsing validation.

Success is never declared on the visual PDF alone. After a résumé PDF is
produced we re-extract its text with a real parser and verify the content is
present, ordered, and parseable — the same way an ATS would read it — and that
no hidden/injected keyword layer was smuggled in.
"""
from __future__ import annotations

import re
from typing import Dict, List, Optional


def _extract_pdf_text(pdf_path: str) -> Optional[str]:
    """Best-effort text extraction. Tries pdfplumber, then pymupdf. None on failure."""
    try:
        import pdfplumber
        parts = []
        with pdfplumber.open(pdf_path) as pdf:
            for page in pdf.pages:
                parts.append(page.extract_text() or "")
        text = "\n".join(parts).strip()
        if text:
            return text
    except Exception:
        pass
    try:
        import fitz  # pymupdf
        doc = fitz.open(pdf_path)
        text = "\n".join(p.get_text() for p in doc).strip()
        doc.close()
        return text or None
    except Exception:
        return None


def _pdftotext_extract(pdf_path: str) -> Optional[str]:
    """Independent parser #2: the poppler `pdftotext` CLI. None if unavailable."""
    import shutil
    import subprocess
    exe = shutil.which("pdftotext")
    if not exe:
        return None
    try:
        out = subprocess.run([exe, "-layout", pdf_path, "-"],
                             capture_output=True, timeout=30)
        txt = out.stdout.decode("utf-8", errors="replace")
        return txt if txt.strip() else None
    except Exception:
        return None


def verify_keywords_two_parsers(pdf_path: str, keywords: List[str]) -> Dict:
    """Confirm each accepted keyword survives BOTH independent parsers (a Python
    lib + poppler's pdftotext). Flags any keyword split/corrupted/missing in
    either. Falls back to a second Python parser (pymupdf) when pdftotext is
    absent, so there are always two independent extractions."""
    import re as _re
    py = _extract_pdf_text(pdf_path) or ""
    cli = _pdftotext_extract(pdf_path)
    parser2_name = "pdftotext"
    if cli is None:
        # fall back to a genuinely different Python engine
        try:
            import fitz
            doc = fitz.open(pdf_path)
            cli = "\n".join(p.get_text() for p in doc)
            doc.close()
            parser2_name = "pymupdf"
        except Exception:
            cli = ""
    def _present(text, kw):
        p = _re.sub(r"\s+", " ", (kw or "").lower()).strip()
        toks = [_re.escape(t) for t in p.split()]
        if not toks:
            return False
        pat = r"(?<![a-z0-9])" + r"[\s\W]{0,3}".join(toks) + r"(?![a-z0-9])"
        return _re.search(pat, text.lower()) is not None
    results = []
    for kw in keywords:
        in_py, in_cli = _present(py, kw), _present(cli, kw)
        results.append({"keyword": kw, "parser1_pdfplumber": in_py,
                        f"parser2_{parser2_name}": in_cli,
                        "in_both": in_py and in_cli})
    return {
        "parser1": "pdfplumber/pymupdf", "parser2": parser2_name,
        "parser1_chars": len(py), "parser2_chars": len(cli),
        "keywords_checked": len(keywords),
        "present_in_both": sum(1 for r in results if r["in_both"]),
        "missing_or_split": [r["keyword"] for r in results if not r["in_both"]],
        "per_keyword": results,
    }


def validate_pdf(
    pdf_path: str,
    *,
    expected_sections: Optional[List[str]] = None,
    expected_contact: Optional[List[str]] = None,
    forbidden_markers: Optional[List[str]] = None,
) -> Dict:
    """Parse the generated PDF and verify readability/integrity.

    Returns a diagnostics dict (stored alongside the output). `ok` is True only
    when the parser recovered text, expected sections appear in order, contact
    details are present, and no injected/hidden marker leaked into the text.
    """
    result: Dict = {
        "ok": False, "parser_recovered_text": False, "char_count": 0,
        "sections_found": [], "sections_missing": [], "sections_in_order": None,
        "contact_present": None, "forbidden_markers_found": [], "warnings": [],
    }

    text = _extract_pdf_text(pdf_path)
    if not text:
        result["warnings"].append("parser recovered no text (image-only or corrupt)")
        return result
    result["parser_recovered_text"] = True
    result["char_count"] = len(text)
    low = text.lower()

    # Section presence + order. Match a HEADING line (the section word dominating
    # its own line), not any prose substring — "communication skills" in a bullet
    # must not count as the SKILLS heading.
    expected_sections = expected_sections or ["experience", "education", "skills"]

    def _heading_pos(sec: str) -> int:
        s = sec.lower()
        for m in re.finditer(r"(?im)^[^\S\n]*([A-Za-z &/]{3,40})[^\S\n]*$", text):
            line = m.group(1).strip().lower()
            # near-exact heading line only (résumé headings stand alone) — never a
            # prose line that merely starts with the section word.
            if line == s or line.rstrip("s") == s.rstrip("s") \
                    or (line.startswith(s) and len(line) <= len(s) + 3):
                return m.start()
        return -1

    positions = []
    for sec in expected_sections:
        idx = _heading_pos(sec)
        if idx >= 0:
            result["sections_found"].append(sec)
            positions.append((sec, idx))
        else:
            result["sections_missing"].append(sec)
    ordered_positions = [p for _, p in positions]
    result["sections_in_order"] = (ordered_positions == sorted(ordered_positions)
                                    if len(ordered_positions) > 1 else True)

    # Contact readability (any expected token present).
    if expected_contact:
        result["contact_present"] = any(
            re.sub(r"\s+", "", c.lower()) in re.sub(r"\s+", "", low)
            for c in expected_contact if c
        )

    # No injected/hidden marker text (our own ATS comment tags or a hidden layer).
    markers = forbidden_markers or ["% ats-item", "% ats-skills-other",
                                    "ats-inject", "core focus areas include"]
    for m in markers:
        if m.lower() in low:
            result["forbidden_markers_found"].append(m)

    result["ok"] = (
        result["parser_recovered_text"]
        and not result["sections_missing"]
        and result["sections_in_order"] is True
        and not result["forbidden_markers_found"]
        and (result["contact_present"] in (None, True))
    )
    if result["sections_missing"]:
        result["warnings"].append(
            f"sections not parseable: {result['sections_missing']}")
    if result["forbidden_markers_found"]:
        result["warnings"].append(
            f"injected/hidden markers leaked into text: "
            f"{result['forbidden_markers_found']}")
    return result


if __name__ == "__main__":  # ponytail: runnable self-check (needs a sample PDF)
    import os, sys
    sample = sys.argv[1] if len(sys.argv) > 1 else os.path.join(
        os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
        "tests", "v2_output.pdf")
    if os.path.exists(sample):
        r = validate_pdf(sample, expected_sections=["experience", "education", "skills"])
        print("pdf_validate self-check:", {k: r[k] for k in
              ("ok", "parser_recovered_text", "sections_found",
               "sections_missing", "forbidden_markers_found")})
    else:
        print(f"pdf_validate self-check SKIPPED — no sample PDF at {sample}")