Spaces:
Sleeping
Sleeping
| """ | |
| 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("&", "&").replace("<", "<").replace(">", ">") | |
| # 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 "" | |