JAA-ATS-Tool / src /pdf_writer.py
saitejatirunagari's picture
perf+feat: parallel resume generation, PDF output, full JD fetching, API timeouts
b15fd58
Raw
History Blame
6.45 kB
"""
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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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 ""