import re import hashlib from pathlib import Path from fpdf import FPDF from docx import Document from docx.shared import Pt, Inches, RGBColor from docx.enum.text import WD_ALIGN_PARAGRAPH from docx.oxml.ns import qn CV_PROFILES = [ { "font": "Calibri", "size": 11, "name_size": 20, "section_font": "Calibri", "section_size": 11.5, "color": RGBColor(0x1a, 0x1a, 0x1a), "accent": RGBColor(0x1a, 0x3a, 0x5c), "margins": (0.75, 0.75, 0.75, 0.75), "line_spacing": 1.2, "contact_sep": " | ", "name": "modern-blue", }, { "font": "Arial", "size": 11, "name_size": 19, "section_font": "Arial", "section_size": 11.5, "color": RGBColor(0x1a, 0x1a, 0x1a), "accent": RGBColor(0x2d, 0x50, 0x16), "margins": (0.7, 0.7, 0.7, 0.7), "line_spacing": 1.15, "contact_sep": " \u2022 ", "name": "classic-green", }, { "font": "Georgia", "size": 11, "name_size": 21, "section_font": "Georgia", "section_size": 11.5, "color": RGBColor(0x1a, 0x1a, 0x1a), "accent": RGBColor(0x5c, 0x1a, 0x1a), "margins": (0.8, 0.8, 0.8, 0.8), "line_spacing": 1.2, "contact_sep": " | ", "name": "serif-maroon", }, { "font": "Tahoma", "size": 11, "name_size": 18, "section_font": "Tahoma", "section_size": 11.5, "color": RGBColor(0x1a, 0x1a, 0x1a), "accent": RGBColor(0x1a, 0x1a, 0x5c), "margins": (0.65, 0.65, 0.65, 0.65), "line_spacing": 1.15, "contact_sep": " \u2014 ", "name": "compact-navy", }, { "font": "Garamond", "size": 11.5, "name_size": 22, "section_font": "Garamond", "section_size": 12, "color": RGBColor(0x1a, 0x1a, 0x1a), "accent": RGBColor(0x5c, 0x3a, 0x1a), "margins": (0.8, 0.8, 0.8, 0.8), "line_spacing": 1.25, "contact_sep": " | ", "name": "classic-warm", }, { "font": "Verdana", "size": 11, "name_size": 20, "section_font": "Verdana", "section_size": 11.5, "color": RGBColor(0x1a, 0x1a, 0x1a), "accent": RGBColor(0x3a, 0x1a, 0x5c), "margins": (0.7, 0.7, 0.7, 0.7), "line_spacing": 1.2, "contact_sep": " \u2022 ", "name": "wide-purple", }, ] def get_cv_profile(seed_str: str = "", counter: int = 0) -> dict: if not seed_str: return CV_PROFILES[0] full_seed = f"{seed_str}_gen{counter}_{__import__('random').randint(0, 9999)}" idx = int(hashlib.md5(full_seed.encode()).hexdigest(), 16) % len(CV_PROFILES) return CV_PROFILES[idx] def _safe(text: str) -> str: if not text: return text replacements = { "\u2014": "--", "\u2013": "-", "\u2018": "'", "\u2019": "'", "\u201c": '"', "\u201d": '"', "\u2022": "-", "\u2026": "...", "\u00a0": " ", "\u2032": "'", "\u2033": '"', "\u25b6": ">", } for k, v in replacements.items(): text = text.replace(k, v) return text.encode("latin-1", errors="replace").decode("latin-1") SECTIONS_ORDER = ["summary", "skills", "experience", "education", "certifications", "projects", "languages"] def _shuffled_sections(seed_str: str = "") -> list: """Return sections in a seeded random order for visual diversity.""" order = list(SECTIONS_ORDER) if not seed_str: return order import hashlib rnd = int(hashlib.md5(seed_str.encode()).hexdigest(), 16) # Use Fisher-Yates with deterministic randomness for i in range(len(order) - 1, 0, -1): rnd, ri = divmod(rnd, i + 1) order[i], order[ri] = order[ri], order[i] return order def _dedup_company(title: str, company: str) -> str: """If title already contains the company name, don't append it.""" if company and company.lower() in title.lower(): return title return f"{title}, {company}" if title and company else (title or company or "") def _add_right_tab_stop(paragraph, position=Inches(7.0)): """Add a right-aligned tab stop for dates.""" tab_stops = paragraph.paragraph_format.tab_stops tab_stops.add_tab_stop(position, alignment=2) # 2 = RIGHT def _is_grouped_skills(skills: list) -> bool: return bool(skills) and isinstance(skills[0], dict) and "domain" in skills[0] def _render_skills_docx(doc, skills: list, max_groups: int = 6, profile: dict = None): """Render skills in DOCX — handles both flat strings and grouped dicts.""" if not skills: return _add_section_title_docx(doc, "Core Competencies", profile) sz = (profile.get("size", 11) - 1) if profile else 10 if _is_grouped_skills(skills): for group in skills[:max_groups]: domain = group.get("domain", group.get("name", "")) items = group.get("items", []) desc = group.get("description", "") line = f"{domain}: {', '.join(items)}" if items else domain if desc: line += f" — {desc}" bp = doc.add_paragraph(line, style="List Bullet") bp.paragraph_format.space_after = Pt(0) bp.paragraph_format.space_before = Pt(0) bp.paragraph_format.left_indent = Inches(0.25) for run in bp.runs: run.font.size = Pt(sz) else: for s in skills[:8]: bp = doc.add_paragraph(s, style="List Bullet") bp.paragraph_format.space_after = Pt(0) bp.paragraph_format.space_before = Pt(0) bp.paragraph_format.left_indent = Inches(0.25) for run in bp.runs: run.font.size = Pt(sz) def _render_skills_pdf(pdf, skills: list, max_groups: int = 6, profile: dict = None): """Render skills in PDF — handles both flat strings and grouped dicts.""" if not skills: return pdf._section_title("Core Competencies") if _is_grouped_skills(skills): for group in skills[:max_groups]: domain = group.get("domain", group.get("name", "")) items = group.get("items", []) desc = group.get("description", "") line = f"{domain}: {', '.join(items)}" if items else domain if desc: line += f" — {desc}" pdf._bullet(line) else: for s in skills[:8]: pdf._bullet(s) pdf.ln(1) def _render_skills_text(skills: list, max_groups: int = 6) -> list: """Render skills as text lines for preview — handles both formats.""" lines = [] if not skills: return lines lines.append("SKILLS") if _is_grouped_skills(skills): for group in skills[:max_groups]: domain = group.get("domain", group.get("name", "")) items = group.get("items", []) desc = group.get("description", "") line = f"{domain}: {', '.join(items)}" if items else domain if desc: line += f" — {desc}" lines.append(" * " + line) else: for s in skills[:8]: lines.append(f" * {s}") lines.append("") return lines def _add_section_title_docx(doc, title: str, profile: dict = None): p = doc.add_paragraph() p.paragraph_format.space_before = Pt(8) p.paragraph_format.space_after = Pt(4) run = p.add_run(title.upper()) run.bold = True run.font.size = Pt((profile.get("section_size", 11) + 0.5)) if profile else Pt(11.5) col = profile.get("accent", RGBColor(0x1a, 0x1a, 0x1a)) if profile else RGBColor(0x1a, 0x1a, 0x1a) run.font.color.rgb = col pPr = p._p.get_or_add_pPr() pBdr = pPr.makeelement(qn("w:pBdr"), {}) bottom = pBdr.makeelement(qn("w:bottom"), { qn("w:val"): "single", qn("w:sz"): "4", qn("w:space"): "1", qn("w:color"): "{:02x}{:02x}{:02x}".format(col[0], col[1], col[2]), }) pBdr.append(bottom) pPr.append(pBdr) def _add_referees_docx(doc, referees: list = None, profile: dict = None): _add_section_title_docx(doc, "Referees", profile) if referees and any(r.get("name", "") for r in referees): for r in referees[:3]: parts = [r.get("name", ""), r.get("title", ""), r.get("company", ""), r.get("email", ""), r.get("phone", "")] line = " | ".join(p for p in parts if p) if line: p = doc.add_paragraph(line) p.paragraph_format.space_after = Pt(1) else: p = doc.add_paragraph("Available upon request.") p.paragraph_format.space_after = Pt(3) def generate_cv_docx(tailored_cv: dict, output_path: str, target_type: str = "local", profile: dict = None, cv_seed: str = "") -> str: if profile is None: profile = CV_PROFILES[0] doc = Document() m = profile.get("margins", (0.75, 0.75, 0.75, 0.75)) for section in doc.sections: section.top_margin = Inches(m[0]) section.bottom_margin = Inches(m[1]) section.left_margin = Inches(m[2]) section.right_margin = Inches(m[3]) style = doc.styles["Normal"] font = style.font font.name = profile["font"] font.size = Pt(profile["size"]) sp = style.paragraph_format sp.space_after = Pt(2) sp.space_before = Pt(0) sp.line_spacing = profile["line_spacing"] personal = tailored_cv.get("personal_info", {}) name = personal.get("name", "") email = personal.get("email", "") phone = personal.get("phone", "") location = personal.get("location", "") linkedin = personal.get("linkedin", "") website = personal.get("website", "") # NAME (largest element — F-pattern anchor) header = doc.add_paragraph() header.alignment = WD_ALIGN_PARAGRAPH.LEFT run = header.add_run(name) run.bold = True run.font.size = Pt(profile["name_size"]) run.font.color.rgb = profile.get("accent", profile["color"]) # Contact line contact_parts = [p for p in [phone, email, location] if p] linkedin_parts = [p for p in [linkedin, website] if p] if contact_parts: c = doc.add_paragraph() c.paragraph_format.space_after = Pt(4) run = c.add_run(profile["contact_sep"].join(contact_parts)) run.font.size = Pt(profile["size"] - 1.5) run.font.color.rgb = RGBColor(0x55, 0x55, 0x55) if linkedin_parts: run2 = c.add_run(" | " + profile["contact_sep"].join(linkedin_parts)) run2.font.size = Pt(profile["size"] - 1.5) run2.font.color.rgb = RGBColor(0x55, 0x55, 0x55) # Render sections in shuffled order for diversity sz = profile["size"] summary = tailored_cv.get("professional_summary", "") experience = tailored_cv.get("experience", []) education = tailored_cv.get("education", []) certifications = tailored_cv.get("certifications", []) projects = tailored_cv.get("projects", []) volunteer = tailored_cv.get("volunteer_experience", tailored_cv.get("volunteer", [])) skills_data = tailored_cv.get("skills", []) def _render_section(section_name: str): if section_name == "summary" and summary: _add_section_title_docx(doc, "Professional Summary", profile) p = doc.add_paragraph(summary) p.paragraph_format.space_after = Pt(4) elif section_name == "skills" and skills_data: _render_skills_docx(doc, skills_data, profile=profile) elif section_name == "experience" and experience: _add_section_title_docx(doc, "Professional Experience", profile) for exp in experience[:3]: end = "Present" if exp.get("current") else exp.get("end_date", "") date_str = f"{exp.get('start_date', '')} - {end}" if exp.get("start_date") else "" p = doc.add_paragraph() p.paragraph_format.space_before = Pt(4) p.paragraph_format.space_after = Pt(1) title_company = _dedup_company(exp.get("title", ""), exp.get("company", "")) run = p.add_run(title_company) run.bold = True run.font.size = Pt(sz) run.font.name = profile["font"] if date_str: _add_right_tab_stop(p, Inches(7.0)) run3 = p.add_run(f"\t{date_str}") run3.font.size = Pt(9) run3.font.color.rgb = RGBColor(0x55, 0x55, 0x55) for ach in exp.get("achievements", [])[:7]: bp = doc.add_paragraph(ach, style="List Bullet") bp.paragraph_format.space_after = Pt(0) bp.paragraph_format.space_before = Pt(0) bp.paragraph_format.left_indent = Inches(0.25) for run_b in bp.runs: run_b.font.size = Pt(sz - 0.5) elif section_name == "education" and education: _add_section_title_docx(doc, "Education", profile) for edu in education[:3]: p = doc.add_paragraph() p.paragraph_format.space_after = Pt(1) p.paragraph_format.space_before = Pt(1) degree_inst = f"{edu.get('degree', '')} \u2014 {edu.get('institution', '')}" run = p.add_run(degree_inst) run.font.size = Pt(sz) run.bold = True if edu.get("start_date"): yr = f"{edu['start_date']} - {edu.get('end_date', '')}" _add_right_tab_stop(p, Inches(7.0)) run2 = p.add_run(f"\t{yr}") run2.font.size = Pt(sz - 1) run2.font.color.rgb = RGBColor(0x55, 0x55, 0x55) elif section_name == "certifications" and certifications: _add_section_title_docx(doc, "Certifications", profile) certs = [c if isinstance(c, str) else c.get("name", "") for c in certifications[:5]] for c in certs: bp = doc.add_paragraph(c, style="List Bullet") bp.paragraph_format.space_after = Pt(0) bp.paragraph_format.space_before = Pt(0) bp.paragraph_format.left_indent = Inches(0.25) for run_c in bp.runs: run_c.font.size = Pt(9.5) elif section_name == "projects" and projects: _add_section_title_docx(doc, "Projects", profile) for proj in projects[:4]: tech = f" [{', '.join(proj.get('technologies', []))}]" if proj.get("technologies") else "" link = f" ({proj['url']})" if proj.get("url") else "" p = doc.add_paragraph() p.paragraph_format.space_after = Pt(2) run_pname = p.add_run(f"{proj.get('name', '')}: ") run_pname.bold = True p.add_run(f"{proj.get('description', '')[:250]}{tech}{link}") elif section_name == "languages": langs = tailored_cv.get("languages", []) if langs: _add_section_title_docx(doc, "Languages", profile) lang_str = ", ".join(l if isinstance(l, str) else l.get("name", l.get("language", "")) for l in langs[:4]) if lang_str: p = doc.add_paragraph(lang_str) p.paragraph_format.space_after = Pt(4) order = _shuffled_sections(cv_seed or profile.get("name", "cv")) for s in order: _render_section(s) # Volunteer and referees always at end (positional, non-negotiable) if volunteer: _add_section_title_docx(doc, "Volunteer Experience", profile) for v in volunteer[:3]: if isinstance(v, str): bp = doc.add_paragraph(v, style="List Bullet") bp.paragraph_format.space_after = Pt(0) bp.paragraph_format.left_indent = Inches(0.25) else: title_v = v.get("title", "") org = v.get("company", v.get("organization", "")) dates = "" if v.get("start_date"): dates = f" ({v['start_date']} - {v.get('end_date', '')})" bp = doc.add_paragraph(f"{title_v} \u2014 {org}{dates}", style="List Bullet") bp.paragraph_format.space_after = Pt(0) bp.paragraph_format.left_indent = Inches(0.25) for ach in v.get("achievements", [])[:3]: bp2 = doc.add_paragraph(ach, style="List Bullet") bp2.paragraph_format.space_after = Pt(0) bp2.paragraph_format.left_indent = Inches(0.35) if target_type == "local": _add_referees_docx(doc, tailored_cv.get("referees"), profile) doc.save(output_path) return str(output_path) def generate_cover_docx(letter_text: str, output_path: str, personal_info: dict = None, company: str = "", profile: dict = None) -> str: if profile is None: profile = CV_PROFILES[0] doc = Document() m = profile.get("margins", (1.0, 1.0, 1.0, 1.0)) for section in doc.sections: section.top_margin = Inches(m[0]) section.bottom_margin = Inches(m[1]) section.left_margin = Inches(m[2]) section.right_margin = Inches(m[3]) style = doc.styles["Normal"] font = style.font font.name = profile["font"] font.size = Pt(profile["size"] + 1) style.paragraph_format.space_after = Pt(6) style.paragraph_format.line_spacing = 1.15 # 1. Header (Sender's details) if personal_info: name = personal_info.get("name", "") email = personal_info.get("email", "") phone = personal_info.get("phone", "") location = personal_info.get("location", "") linkedin = personal_info.get("linkedin", "") website = personal_info.get("website", "") # Bold name p_name = doc.add_paragraph() p_name.paragraph_format.space_after = Pt(2) run_name = p_name.add_run(name) run_name.bold = True run_name.font.size = Pt(profile["name_size"]) run_name.font.color.rgb = profile.get("accent", profile["color"]) # Contact line contact_parts = [p for p in [phone, email, location] if p] linkedin_parts = [p for p in [linkedin, website] if p] p_contact = doc.add_paragraph() p_contact.paragraph_format.space_after = Pt(8) run_contact = p_contact.add_run(profile["contact_sep"].join(contact_parts)) run_contact.font.size = Pt(profile["size"] - 1) run_contact.font.color.rgb = RGBColor(0x55, 0x55, 0x55) if linkedin_parts: run_link = p_contact.add_run(" | " + profile["contact_sep"].join(linkedin_parts)) run_link.font.size = Pt(profile["size"] - 1) run_link.font.color.rgb = RGBColor(0x55, 0x55, 0x55) # Horizontal Rule p_hr = doc.add_paragraph() pPr = p_hr._p.get_or_add_pPr() pBdr = pPr.makeelement(qn("w:pBdr"), {}) bottom = pBdr.makeelement(qn("w:bottom"), { qn("w:val"): "single", qn("w:sz"): "6", qn("w:space"): "1", qn("w:color"): "aaaaaa", }) pBdr.append(bottom) pPr.append(pBdr) p_hr.paragraph_format.space_after = Pt(12) # 2. Date from datetime import datetime date_str = datetime.now().strftime("%B %d, %Y") p_date = doc.add_paragraph() p_date.paragraph_format.space_after = Pt(12) run_date = p_date.add_run(date_str) run_date.font.size = Pt(11) # 3. Recipient if company: p_rec = doc.add_paragraph() p_rec.paragraph_format.space_after = Pt(12) run_rec = p_rec.add_run(f"Hiring Committee\n{company}") run_rec.font.size = Pt(11) # 4. Salutation p_sal = doc.add_paragraph() p_sal.paragraph_format.space_after = Pt(12) salutation = f"Dear Hiring Team at {company}," if company else "Dear Hiring Manager," run_sal = p_sal.add_run(salutation) run_sal.font.size = Pt(11) # 5. Body paragraphs clean_lines = [] for para in letter_text.split("\n\n"): para = para.strip() if not para: continue if para.lower().startswith(("dear", "to whom", "hello", "hi ")): continue if para.lower().startswith(("sincerely", "regards", "best regards", "thank you", "respectfully")): continue clean_lines.append(para) for para_text in clean_lines: p = doc.add_paragraph() p.paragraph_format.space_after = Pt(12) run = p.add_run(para_text) run.font.size = Pt(11) # 6. Sign-off p_close = doc.add_paragraph() p_close.paragraph_format.space_after = Pt(2) p_close.paragraph_format.space_before = Pt(12) run_close = p_close.add_run("Sincerely,") run_close.font.size = Pt(11) if personal_info: p_sender = doc.add_paragraph() p_sender.paragraph_format.space_after = Pt(0) run_sender = p_sender.add_run(personal_info.get("name", "")) run_sender.bold = True run_sender.font.size = Pt(11) doc.save(output_path) return str(output_path) def generate_cv_preview_text(tailored_cv: dict) -> str: lines = [] personal = tailored_cv.get("personal_info", {}) lines.append(personal.get("name", "")) contact = " | ".join(p for p in [personal.get("phone"), personal.get("email"), personal.get("location")] if p) if contact: lines.append(contact) lines.append("") summary = tailored_cv.get("professional_summary", "") if summary: lines.append("PROFESSIONAL SUMMARY") lines.append(summary) lines.append("") skills = tailored_cv.get("skills", []) if skills: lines.extend(_render_skills_text(skills)) experience = tailored_cv.get("experience", []) if experience: lines.append("EXPERIENCE") for exp in experience[:3]: end = "Present" if exp.get("current") else exp.get("end_date", "") title_company = _dedup_company(exp.get('title', ''), exp.get('company', '')) parts = [title_company] if exp.get("start_date") or end: parts.append(f"({exp.get('start_date', '')} - {end})") lines.append(" ".join(p for p in parts if p)) for ach in exp.get("achievements", [])[:6]: lines.append(f" - {ach}") lines.append("") education = tailored_cv.get("education", []) if education: lines.append("EDUCATION") for edu in education[:3]: line = f"{edu.get('degree', '')} — {edu.get('institution', '')}" if edu.get("start_date"): line += f" ({edu['start_date']} - {edu.get('end_date', '')})" lines.append(line) lines.append("") certifications = tailored_cv.get("certifications", []) if certifications: lines.append("CERTIFICATIONS") certs = [c if isinstance(c, str) else c.get("name", "") for c in certifications[:5]] lines.append(", ".join(certs)) lines.append("") projects = tailored_cv.get("projects", []) if projects: lines.append("PROJECTS") for proj in projects[:4]: tech = f" [{', '.join(proj.get('technologies', [])[:3])}]" if proj.get("technologies") else "" link = f" ({proj['url']})" if proj.get("url") else "" lines.append(f" * {proj.get('name', '')}: {proj.get('description', '')[:200]}{tech}{link}") volunteer = tailored_cv.get("volunteer_experience", tailored_cv.get("volunteer", [])) if volunteer: lines.append("") lines.append("VOLUNTEER EXPERIENCE") for v in volunteer[:3]: if isinstance(v, str): lines.append(f" * {v}") else: lines.append(f" * {v.get('title', '')} — {v.get('company', v.get('organization', ''))}") memberships = tailored_cv.get("professional_memberships", []) if memberships: lines.append("") lines.append("PROFESSIONAL MEMBERSHIPS") lines.append(", ".join(m if isinstance(m, str) else m.get("name", "") for m in memberships[:5])) return "\n".join(lines).strip() class _CvPdf(FPDF): def __init__(self, profile: dict = None): super().__init__() self.profile = profile or CV_PROFILES[0] self.set_auto_page_break(auto=True, margin=14) self.set_margins(14, 14, 14) accent = self.profile.get("accent", RGBColor(0x1a, 0x1a, 0x1a)) self._accent_rgb = (accent[0], accent[1], accent[2]) self._font_name = self.profile.get("font", "Helvetica") self._font_pdf = self._pdf_font_name() def _pdf_font_name(self): name = self._font_name if name in ("Calibri", "Verdana", "Tahoma", "Garamond", "Georgia"): return "Helvetica" return name def _section_title(self, title: str): self.set_x(self.l_margin) self.set_font(self._font_pdf, "B", 10) r, g, b = self._accent_rgb self.set_text_color(r, g, b) self.cell(0, 5.5, _safe(title.upper()), new_x="LMARGIN", new_y="NEXT") y = self.get_y() self.set_draw_color(r, g, b) self.line(self.l_margin, y, self.w - self.r_margin, y) self.ln(2) def _body(self, text: str, size=10): self.set_x(self.l_margin) self.set_font("Helvetica", "", size) self.set_text_color(30, 30, 30) self.multi_cell(w=0, h=4.5, text=_safe(text), new_x="LMARGIN", new_y="NEXT") def _bold_line(self, text: str): self.set_x(self.l_margin) self.set_font("Helvetica", "B", 10) self.set_text_color(26, 26, 26) self.multi_cell(w=0, h=5, text=_safe(text), new_x="LMARGIN", new_y="NEXT") def _bullet(self, text: str): self.set_font("Helvetica", "", 9.5) self.set_text_color(30, 30, 30) indent = 4 self.cell(indent, 4.5, "- ") x = self.get_x() self.multi_cell(w=0, h=4.5, text=_safe(text), new_x="LMARGIN", new_y="NEXT") def _add_referees_pdf(pdf, referees: list = None): pdf._section_title("Referees") if referees and any(r.get("name", "") for r in referees): for r in referees[:3]: parts = [r.get("name", ""), r.get("title", ""), r.get("company", ""), r.get("email", ""), r.get("phone", "")] line = " | ".join(p for p in parts if p) if line: pdf._body(line) else: pdf._body("Available upon request.") def generate_cv_pdf(tailored_cv: dict, output_path: str, target_type: str = "local", profile: dict = None, cv_seed: str = "") -> str: pdf = _CvPdf(profile) pdf.add_page() personal = tailored_cv.get("personal_info", {}) name = personal.get("name", "") email = personal.get("email", "") phone = personal.get("phone", "") location = personal.get("location", "") # Name pdf.set_font("Helvetica", "B", 18) pdf.set_text_color(26, 26, 26) pdf.cell(0, 8, name, new_x="LMARGIN", new_y="NEXT") # Contact contact_parts = [p for p in [phone, email, location] if p] if contact_parts: pdf.set_font("Helvetica", "", 9) pdf.set_text_color(85, 85, 85) pdf.cell(0, 5, " | ".join(contact_parts), new_x="LMARGIN", new_y="NEXT") pdf.ln(3) summary = tailored_cv.get("professional_summary", "") experience = tailored_cv.get("experience", []) education = tailored_cv.get("education", []) certifications = tailored_cv.get("certifications", []) projects = tailored_cv.get("projects", []) skills_data = tailored_cv.get("skills", []) volunteer = tailored_cv.get("volunteer_experience", tailored_cv.get("volunteer", [])) def _render_pdf_section(section_name: str): if section_name == "summary" and summary: pdf._section_title("Professional Summary") pdf._body(summary) pdf.ln(1.5) elif section_name == "skills" and skills_data: _render_skills_pdf(pdf, skills_data, profile=pdf.profile) elif section_name == "experience" and experience: pdf._section_title("Professional Experience") for exp in experience[:3]: end = "Present" if exp.get("current") else exp.get("end_date", "") date_str = f"{exp.get('start_date', '')} - {end}" if exp.get("start_date") else "" title_line = _dedup_company(exp.get("title", ""), exp.get("company", "")) if date_str: title_line += f" ({date_str})" pdf._bold_line(title_line) for ach in exp.get("achievements", [])[:7]: pdf._bullet(ach) pdf.ln(1) elif section_name == "education" and education: pdf._section_title("Education") for edu in education[:3]: line = edu.get("degree", "") if edu.get("institution"): line += f" -- {edu['institution']}" if edu.get("start_date"): line += f" ({edu['start_date']} - {edu.get('end_date', '')})" pdf._bold_line(line) elif section_name == "certifications" and certifications and pdf.get_y() < 250: pdf._section_title("Certifications") for c in certifications[:5]: name_c = c if isinstance(c, str) else c.get("name", "") pdf._bullet(name_c) elif section_name == "projects" and projects and pdf.get_y() < 230: pdf._section_title("Projects") for proj in projects[:4]: tech = f" [{', '.join(proj.get('technologies', [])[:3])}]" if proj.get("technologies") else "" link = f" ({proj['url']})" if proj.get("url") else "" pdf._body(f"{proj.get('name', '')}: {proj.get('description', '')[:200]}{tech}{link}") elif section_name == "languages": langs = tailored_cv.get("languages", []) if langs and pdf.get_y() < 260: pdf._section_title("Languages") lang_str = ", ".join(l if isinstance(l, str) else l.get("name", l.get("language", "")) for l in langs[:4]) if lang_str: pdf._body(lang_str) order = _shuffled_sections(cv_seed or profile.get("name", "cv")) if cv_seed else SECTIONS_ORDER for s in order: _render_pdf_section(s) if volunteer and pdf.get_y() < 230: pdf._section_title("Volunteer Experience") for v in volunteer[:3]: if isinstance(v, str): pdf._bullet(v) else: title_v = v.get("title", "") org = v.get("company", v.get("organization", "")) dates = f" ({v['start_date']} - {v.get('end_date', '')})" if v.get("start_date") else "" pdf._bullet(f"{title_v} -- {org}{dates}") for ach in v.get("achievements", [])[:3]: pdf._bullet(ach) if target_type == "local" and pdf.get_y() < 260: _add_referees_pdf(pdf, tailored_cv.get("referees")) pdf.output(output_path) return str(output_path) def generate_cover_pdf(letter_text: str, output_path: str, personal_info: dict = None, company: str = "", profile: dict = None) -> str: pdf = _CvPdf(profile) pdf.add_page() # 1. Header (Sender's details) if personal_info: name = personal_info.get("name", "") email = personal_info.get("email", "") phone = personal_info.get("phone", "") location = personal_info.get("location", "") linkedin = personal_info.get("linkedin", "") website = personal_info.get("website", "") pdf.set_font("Helvetica", "B", 14) pdf.set_text_color(26, 26, 26) pdf.cell(0, 6, _safe(name), new_x="LMARGIN", new_y="NEXT") contact_parts = [p for p in [phone, email, location] if p] linkedin_parts = [p for p in [linkedin, website] if p] contact_line = " | ".join(contact_parts) if linkedin_parts: contact_line += " | " + " | ".join(linkedin_parts) pdf.set_font("Helvetica", "", 8.5) pdf.set_text_color(85, 85, 85) pdf.cell(0, 4, _safe(contact_line), new_x="LMARGIN", new_y="NEXT") pdf.ln(2) # Horizontal divider y = pdf.get_y() pdf.set_draw_color(170, 170, 170) pdf.line(pdf.l_margin, y, pdf.w - pdf.r_margin, y) pdf.ln(4) # 2. Date from datetime import datetime date_str = datetime.now().strftime("%B %d, %Y") pdf.set_font("Helvetica", "", 10.5) pdf.set_text_color(30, 30, 30) pdf.cell(0, 5, _safe(date_str), new_x="LMARGIN", new_y="NEXT") pdf.ln(3) # 3. Recipient if company: pdf.set_font("Helvetica", "", 10.5) pdf.multi_cell(0, 5, _safe(f"Hiring Committee\n{company}")) pdf.ln(3) # 4. Salutation salutation = f"Dear Hiring Team at {company}," if company else "Dear Hiring Manager," pdf.set_font("Helvetica", "", 10.5) pdf.cell(0, 5, _safe(salutation), new_x="LMARGIN", new_y="NEXT") pdf.ln(4) # 5. Body clean_lines = [] for para in letter_text.split("\n\n"): para = para.strip() if not para: continue if para.lower().startswith(("dear", "to whom", "hello", "hi ")): continue if para.lower().startswith(("sincerely", "regards", "best regards", "thank you", "respectfully")): continue clean_lines.append(para) for para_text in clean_lines: pdf.multi_cell(0, 5.5, _safe(para_text)) pdf.ln(4) # 6. Closing pdf.cell(0, 5.5, "Sincerely,", new_x="LMARGIN", new_y="NEXT") pdf.ln(2) if personal_info: pdf.set_font("Helvetica", "B", 10.5) pdf.cell(0, 5.5, _safe(personal_info.get("name", "")), new_x="LMARGIN", new_y="NEXT") pdf.output(output_path) return str(output_path)