File size: 10,204 Bytes
b15fd58
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ab9af33
 
 
 
 
 
 
b15fd58
 
ab9af33
 
 
b15fd58
 
 
 
 
ab9af33
 
 
b15fd58
 
 
 
 
 
 
 
 
 
ab9af33
 
b15fd58
 
 
 
ab9af33
 
 
 
b15fd58
 
 
 
 
 
 
 
 
 
 
ab9af33
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b15fd58
 
ab9af33
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b15fd58
 
 
 
 
 
 
 
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
"""
DOCX β†’ PDF conversion.

Strategy:
1. Windows + MS Word installed β†’ docx2pdf (perfect fidelity)
2. Anywhere else (HF Spaces, Linux) β†’ reportlab re-render from docx text
"""
import os
import re
import logging

log = logging.getLogger("pdf_writer")

_WORD_AVAILABLE = None  # cached after first check


def _word_available() -> bool:
    global _WORD_AVAILABLE
    if _WORD_AVAILABLE is not None:
        return _WORD_AVAILABLE
    if os.name != "nt":
        _WORD_AVAILABLE = False
        return False
    try:
        import win32com.client  # noqa: F401  (docx2pdf dependency)
        import pythoncom
        pythoncom.CoInitialize()
        try:
            import win32com.client as wc
            w = wc.Dispatch("Word.Application")
            w.Quit()
            _WORD_AVAILABLE = True
        finally:
            pythoncom.CoUninitialize()
    except Exception:
        _WORD_AVAILABLE = False
    return _WORD_AVAILABLE


def convert_folder(folder: str) -> dict:
    """
    Convert every .docx in folder to .pdf.
    Returns {abs_docx_path: pdf_path_or_empty}.
    Uses ONE Word session for the whole folder when Word is available
    (much faster + stable than per-file), else reportlab per file.
    """
    from pathlib import Path
    docx_files = sorted(Path(folder).glob("*.docx"))
    result = {}
    if not docx_files:
        return result

    # Only convert files that don't already have a PDF
    to_convert = [f for f in docx_files if not f.with_suffix(".pdf").exists()]

    if to_convert and _word_available():
        try:
            import pythoncom
            pythoncom.CoInitialize()
            try:
                from docx2pdf import convert
                if len(to_convert) == len(docx_files):
                    convert(folder)  # whole folder in one Word session
                else:
                    for f in to_convert:
                        convert(str(f), str(f.with_suffix(".pdf")))
            finally:
                pythoncom.CoUninitialize()
        except Exception as e:
            log.warning(f"Batch docx2pdf failed: {e} β€” falling back to reportlab per file")

    for f in docx_files:
        pdf = str(f.with_suffix(".pdf"))
        if not os.path.exists(pdf):
            pdf = _reportlab_render(str(f), pdf)
        result[os.path.abspath(str(f))] = pdf if pdf and os.path.exists(pdf) else ""
    return result


def docx_to_pdf(docx_path: str) -> str:
    """
    Convert a DOCX resume to PDF next to it. Returns the PDF path ('' on failure).
    Thread-safe: each call initializes its own COM context on Windows.
    """
    if not docx_path or not os.path.exists(docx_path):
        return ""
    pdf_path = os.path.splitext(docx_path)[0] + ".pdf"

    if _word_available():
        try:
            import pythoncom
            pythoncom.CoInitialize()
            try:
                from docx2pdf import convert
                convert(docx_path, pdf_path)
            finally:
                pythoncom.CoUninitialize()
            if os.path.exists(pdf_path):
                return pdf_path
        except Exception as e:
            log.warning(f"docx2pdf failed for {os.path.basename(docx_path)}: {e} β€” falling back to reportlab")

    return _reportlab_render(docx_path, pdf_path)


def _reportlab_render(docx_path: str, pdf_path: str) -> str:
    """
    Re-render the DOCX content as a styled PDF that mirrors the DOCX layout.

    Walks the document body in XML order so paragraphs and tables (e.g. the
    Core Competencies 3-column table) appear where they actually are β€” not
    paragraphs first and tables dumped at the end.
    """
    try:
        from docx import Document
        from docx.oxml.ns import qn
        from docx.text.paragraph import Paragraph as DocxParagraph
        from docx.table import Table as DocxTable
        from reportlab.lib.pagesizes import A4
        from reportlab.lib.units import inch
        from reportlab.lib.colors import HexColor
        from reportlab.lib.styles import ParagraphStyle
        from reportlab.lib.enums import TA_CENTER
        from reportlab.platypus import (
            SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle,
        )

        doc = Document(docx_path)

        styles = {
            "name": ParagraphStyle("name", fontName="Helvetica-Bold", fontSize=18,
                                   textColor=HexColor("#1A1A2E"), alignment=TA_CENTER, spaceAfter=4),
            "contact": ParagraphStyle("contact", fontName="Helvetica", fontSize=9,
                                      textColor=HexColor("#444444"), alignment=TA_CENTER, spaceAfter=6),
            "header": ParagraphStyle("header", fontName="Helvetica-Bold", fontSize=11,
                                     textColor=HexColor("#16489E"), spaceBefore=10, spaceAfter=4),
            "sub_header": ParagraphStyle("sub_header", fontName="Helvetica-Bold", fontSize=10,
                                         textColor=HexColor("#1A1A2E"), spaceBefore=4, spaceAfter=2),
            "bullet": ParagraphStyle("bullet", fontName="Helvetica", fontSize=10,
                                     leftIndent=14, bulletIndent=4, spaceAfter=2, leading=13),
            "body": ParagraphStyle("body", fontName="Helvetica", fontSize=10,
                                   spaceAfter=3, leading=13),
            "meta": ParagraphStyle("meta", fontName="Helvetica-Oblique", fontSize=9,
                                   textColor=HexColor("#555555"), spaceAfter=3, leading=12),
            "skill_cell": ParagraphStyle("skill_cell", fontName="Helvetica", fontSize=10,
                                          leading=12),
        }

        pdf = SimpleDocTemplate(pdf_path, pagesize=A4,
                                topMargin=0.6 * inch, bottomMargin=0.6 * inch,
                                leftMargin=0.7 * inch, rightMargin=0.7 * inch)
        flow = []
        first_text_seen = False

        def esc(t):
            return t.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")

        # Walk body children in document order so tables appear under their header
        body = doc.element.body
        for child in body.iterchildren():
            tag = child.tag

            if tag == qn("w:p"):
                p = DocxParagraph(child, doc)
                text = (p.text or "").strip()
                if not text:
                    continue
                if set(text) <= {"─", "-", "β€”", "_"}:
                    continue

                if not first_text_seen:
                    flow.append(Paragraph(esc(text), styles["name"]))
                    first_text_seen = True
                    continue

                # Contact line
                if "|" in text and ("@" in text or re.search(r"\+?\d{6,}", text)):
                    flow.append(Paragraph(esc(text), styles["contact"]))
                    continue

                # Section header (ALL CAPS)
                if re.match(r"^[A-Z][A-Z\s&/]{2,}$", text) and len(text) <= 60:
                    flow.append(Paragraph(esc(text), styles["header"]))
                    continue

                # Style-driven detection
                style_name = p.style.name if p.style else ""
                if style_name == "List Bullet" or text.startswith(("β€’", "-", "–", "β–ͺ", "●")):
                    clean = text.lstrip("β€’-–—β–ͺ●* ").strip()
                    flow.append(Paragraph(f"β€’ {esc(clean)}", styles["bullet"]))
                    continue

                # Bold role headers and sub-section headers: use first run's bold attribute
                is_bold = any(r.bold for r in p.runs) if p.runs else False
                if is_bold and len(text) < 120:
                    flow.append(Paragraph(esc(text), styles["sub_header"]))
                    continue

                # Italic small meta lines (scope, etc.)
                is_italic = any(r.italic for r in p.runs) if p.runs else False
                if is_italic and len(text) < 200:
                    flow.append(Paragraph(esc(text), styles["meta"]))
                    continue

                flow.append(Paragraph(esc(text), styles["body"]))

            elif tag == qn("w:tbl"):
                # Render as a real PDF table to mirror DOCX layout
                docx_tbl = DocxTable(child, doc)
                rows_data: list[list] = []
                cols = 0
                for row in docx_tbl.rows:
                    cells_paras = []
                    for c in row.cells:
                        cell_text = (c.text or "").strip()
                        cells_paras.append(Paragraph(esc(cell_text), styles["skill_cell"]))
                    rows_data.append(cells_paras)
                    cols = max(cols, len(cells_paras))

                if rows_data and cols:
                    # Pad short rows
                    for r in rows_data:
                        while len(r) < cols:
                            r.append(Paragraph("", styles["skill_cell"]))

                    page_w = A4[0] - 1.4 * inch
                    col_w = page_w / cols
                    tbl = Table(rows_data, colWidths=[col_w] * cols, repeatRows=0)
                    tbl.setStyle(TableStyle([
                        ("BACKGROUND", (0, 0), (-1, -1), HexColor("#EFF6FF")),
                        ("BOX",        (0, 0), (-1, -1), 0.25, HexColor("#CBD5E1")),
                        ("INNERGRID",  (0, 0), (-1, -1), 0.25, HexColor("#E2E8F0")),
                        ("LEFTPADDING",   (0, 0), (-1, -1), 6),
                        ("RIGHTPADDING",  (0, 0), (-1, -1), 6),
                        ("TOPPADDING",    (0, 0), (-1, -1), 4),
                        ("BOTTOMPADDING", (0, 0), (-1, -1), 4),
                        ("VALIGN",        (0, 0), (-1, -1), "TOP"),
                    ]))
                    flow.append(tbl)
                    flow.append(Spacer(1, 4))

        if not flow:
            return ""
        flow.append(Spacer(1, 6))
        pdf.build(flow)
        return pdf_path if os.path.exists(pdf_path) else ""
    except Exception as e:
        log.error(f"reportlab PDF render failed for {os.path.basename(docx_path)}: {e}")
        return ""