""" 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 clean styled PDF using reportlab.""" try: from docx import Document 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 doc = Document(docx_path) paragraphs = [(p.text, p.style.name if p.style else "") for p in doc.paragraphs] 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), "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), } 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("&", "&").replace("<", "<").replace(">", ">") for text, style_name in paragraphs: text = text.strip() if not text: continue if set(text) <= {"─", "-", "—", "_"}: continue # horizontal rules if not first_text_seen: flow.append(Paragraph(esc(text), styles["name"])) first_text_seen = True elif "|" in text and ("@" in text or re.search(r"\+?\d{6,}", text)): flow.append(Paragraph(esc(text), styles["contact"])) elif re.match(r"^[A-Z][A-Z\s&/]+$", text) and len(text) > 3: flow.append(Paragraph(esc(text), styles["header"])) elif style_name == "List Bullet" or text.startswith(("•", "-", "–", "▪")): clean = text.lstrip("•-–▪* ").strip() flow.append(Paragraph(f"• {esc(clean)}", styles["bullet"])) else: flow.append(Paragraph(esc(text), styles["body"])) 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 ""