""" Canonical renderer — takes a `Resume` model and produces a single canonical visual format DOCX (and via pdf_writer, the matching PDF). The visual is locked. Every tailored resume looks identical: - Header: name (20pt bold, centered) + contact (10pt, centered) - Section headers: 11pt bold indigo, ALL CAPS, thin underline rule - Role: title (11pt bold) + company·location (10pt italic gray) + dates (10pt italic gray) - Bullets: 10.5pt, hanging indent, single line spacing - Achievements: 3-5 quantified bullets - Education: degree (bold) + institution·dates (italic gray) - Skills: categorized SKILLS section (Tools, Methodologies, Domains, Core) placed after KEY ACHIEVEMENTS — the industry-standard ATS keyword vehicle. NO sub-sections within roles. """ from __future__ import annotations import os import re from docx import Document from docx.shared import Pt, RGBColor, Inches from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_LINE_SPACING from docx.oxml.ns import qn from docx.oxml import OxmlElement from .resume_model import Resume, Role, Education # Colors used throughout — single source of truth _NAME_COLOR = RGBColor(0x1A, 0x1A, 0x2E) # near-black navy _BODY_COLOR = RGBColor(0x1F, 0x29, 0x37) # body text _META_COLOR = RGBColor(0x55, 0x5B, 0x68) # italic gray _SECTION_COLOR = RGBColor(0x16, 0x48, 0x9E) # indigo blue _HR_LINE_COLOR = "16489E" def render_resume_docx(resume: Resume, filepath: str) -> str: """ Render the Resume model to a DOCX at filepath. Returns the filepath. Visual spec is fixed — every resume looks identical. """ doc = Document() _set_page_margins(doc) _set_default_paragraph_style(doc) _write_header(doc, resume) _write_summary(doc, resume) _write_experience(doc, resume) _write_achievements(doc, resume) _write_skills(doc, resume) _write_education(doc, resume) os.makedirs(os.path.dirname(filepath), exist_ok=True) doc.save(filepath) return filepath # ───────────────────────────────────────────────────────────────────────── # Document setup # ───────────────────────────────────────────────────────────────────────── def _set_page_margins(doc: Document) -> None: for section in doc.sections: section.top_margin = Inches(0.6) section.bottom_margin = Inches(0.6) section.left_margin = Inches(0.7) section.right_margin = Inches(0.7) def _set_default_paragraph_style(doc: Document) -> None: """Set the base style: Calibri 10.5pt, single-line spacing.""" style = doc.styles["Normal"] style.font.name = "Calibri" style.font.size = Pt(10.5) style.paragraph_format.space_after = Pt(0) style.paragraph_format.line_spacing = 1.15 # ───────────────────────────────────────────────────────────────────────── # Header (name + contact) # ───────────────────────────────────────────────────────────────────────── def _write_header(doc: Document, resume: Resume) -> None: name_para = doc.add_paragraph() name_para.alignment = WD_ALIGN_PARAGRAPH.CENTER name_para.paragraph_format.space_after = Pt(2) run = name_para.add_run(resume.name.upper()) run.bold = True run.font.name = "Calibri" run.font.size = Pt(20) run.font.color.rgb = _NAME_COLOR contact_line = resume.contact.render_line() if contact_line: cp = doc.add_paragraph(contact_line) cp.alignment = WD_ALIGN_PARAGRAPH.CENTER cp.paragraph_format.space_after = Pt(6) for r in cp.runs: r.font.name = "Calibri" r.font.size = Pt(10) r.font.color.rgb = _META_COLOR # Thin horizontal rule under the header rule = doc.add_paragraph() rule.paragraph_format.space_after = Pt(6) _add_bottom_border(rule, size="6", color=_HR_LINE_COLOR) # ───────────────────────────────────────────────────────────────────────── # Sections # ───────────────────────────────────────────────────────────────────────── def _write_summary(doc: Document, resume: Resume) -> None: if not resume.summary: return _add_section_header(doc, "PROFESSIONAL SUMMARY") p = doc.add_paragraph(resume.summary) p.paragraph_format.space_after = Pt(6) p.paragraph_format.line_spacing = 1.25 for r in p.runs: r.font.name = "Calibri" r.font.size = Pt(10.5) r.font.color.rgb = _BODY_COLOR # Canonical casing for acronyms/brands so the Skills section reads correctly. _SKILL_CASE = { "prd": "PRD", "prds": "PRDs", "saas": "SaaS", "api": "API", "apis": "APIs", "crm": "CRM", "ux": "UX", "ui": "UI", "kpi": "KPI", "kpis": "KPIs", "ga4": "GA4", "ai": "AI", "ml": "ML", "nlp": "NLP", "llm": "LLM", "llms": "LLMs", "ocr": "OCR", "qa": "QA", "sql": "SQL", "gtm": "GTM", "okr": "OKR", "okrs": "OKRs", "mvp": "MVP", "roi": "ROI", "cac": "CAC", "ltv": "LTV", "nps": "NPS", "arpu": "ARPU", "b2b": "B2B", "b2c": "B2C", "siem": "SIEM", "soar": "SOAR", "xdr": "XDR", "secops": "SecOps", "devops": "DevOps", "mlops": "MLOps", "plg": "PLG", "sso": "SSO", "fintech": "FinTech", "edtech": "EdTech", "martech": "MarTech", "healthtech": "HealthTech", "ecommerce": "eCommerce", "a/b testing": "A/B Testing", "power bi": "Power BI", "ga": "Google Analytics", "powerbi": "Power BI", } # Skill → category buckets (order = display order). A skill matches a bucket if # it appears in that bucket's term set; anything else falls into "Core". _TOOLS = { "jira", "confluence", "figma", "notion", "asana", "miro", "trello", "linear", "mixpanel", "amplitude", "ga4", "google analytics", "ga", "metabase", "tableau", "looker", "power bi", "powerbi", "sql", "excel", "spreadsheets", "segment", "hotjar", "salesforce", "hubspot", "webengage", "clevertap", "braze", "slack", "productboard", "airtable", "python", } _METHODS = { "agile", "scrum", "kanban", "lean", "sprint", "sprint planning", "okrs", "okr", "a/b testing", "experimentation", "experiments", "design thinking", "discovery", "roadmapping", "prioritization", "gtm", "go-to-market", "go-to-market strategy", "hypothesis testing", "user research", "agile methodologies", "backlog", "story mapping", "iteration", "performance tracking", "gap analysis", "competitive analysis", "competitor analysis", "market research", "benchmarking", } _DOMAINS = { "fintech", "edtech", "healthtech", "martech", "ecommerce", "saas", "b2b", "b2c", "lending", "credit", "insurance", "fraud", "banking", "payments", "logistics", "cybersecurity", "secops", "siem", "soar", "xdr", } # Recruiter-credible caps for the SKILLS section. A real skills block is a # concise, scannable list — never a keyword dump. We cap each category line # and the section total so the rendered DOCX reads like a human's resume. _PER_CATEGORY_CAP = 10 # max items shown on a single category line _SKILLS_TOTAL_CAP = 28 # max items across ALL categories combined def _cap_skill(s: str) -> str: k = s.strip().lower() if k in _SKILL_CASE: return _SKILL_CASE[k] if "/" in k: return "/".join(p.capitalize() for p in k.split("/")) return " ".join(w.capitalize() for w in k.split()) def _write_skills(doc: Document, resume: Resume) -> None: """Categorized SKILLS section — the industry-standard ATS keyword vehicle. Groups the flat skill list into Tools, Methodologies, Domains, and Core Competencies, each rendered as EXACTLY ONE 'Category: a, b, c' line. Each category is capped at `_PER_CATEGORY_CAP` items and the section total at `_SKILLS_TOTAL_CAP`, so the block stays a concise, recruiter-credible list (never the repeated-header keyword dump). Surplus includable keywords are redirected into the Summary/Experience upstream, not rendered here. Placed after KEY ACHIEVEMENTS in the resume outline. """ if not resume.skills: return # Dedup (case-insensitive), preserve order seen, flat = set(), [] for s in resume.skills: k = s.strip().lower() if k and k not in seen: seen.add(k) flat.append(s.strip()) buckets = {"Tools & Analytics": [], "Methodologies": [], "Domains": [], "Core Competencies": []} for s in flat: k = s.lower() if k in _TOOLS: buckets["Tools & Analytics"].append(s) elif k in _METHODS: buckets["Methodologies"].append(s) elif k in _DOMAINS: buckets["Domains"].append(s) else: buckets["Core Competencies"].append(s) # Cap BEFORE rendering: at most one line per category, and a recruiter- # credible section total. Each bucket is trimmed to _PER_CATEGORY_CAP, then # categories are filled in display order (Tools → Methods → Domains → Core) # until the running total hits _SKILLS_TOTAL_CAP — so an overlong "Core # Competencies" bucket is the first to lose its tail, keeping the section # reading like a real skills list. Overflow is intentionally DROPPED here; # important surplus terms are woven into Summary/Experience upstream. lines: list[tuple[str, list[str]]] = [] running_total = 0 for cat, items in buckets.items(): if not items or running_total >= _SKILLS_TOTAL_CAP: continue capped = items[:_PER_CATEGORY_CAP] remaining = _SKILLS_TOTAL_CAP - running_total capped = capped[:remaining] if not capped: continue lines.append((cat, capped)) running_total += len(capped) if not lines: return _add_section_header(doc, "SKILLS") for cat, items in lines: p = doc.add_paragraph() p.paragraph_format.space_after = Pt(2) label = p.add_run(f"{cat}: ") label.bold = True label.font.name = "Calibri" label.font.size = Pt(10.5) label.font.color.rgb = _NAME_COLOR body = p.add_run(", ".join(_cap_skill(x) for x in items)) body.font.name = "Calibri" body.font.size = Pt(10.5) body.font.color.rgb = _BODY_COLOR def _write_experience(doc: Document, resume: Resume) -> None: if not resume.roles: return _add_section_header(doc, "PROFESSIONAL EXPERIENCE") for role in resume.roles: _write_role(doc, role) def _write_role(doc: Document, role: Role) -> None: # Role title — bold, 11pt title_para = doc.add_paragraph() title_para.paragraph_format.space_before = Pt(8) title_para.paragraph_format.space_after = Pt(0) run = title_para.add_run(role.title) run.bold = True run.font.name = "Calibri" run.font.size = Pt(11) run.font.color.rgb = _NAME_COLOR # Company · Location · Dates — italic gray, 10pt meta_parts = [] if role.company: meta_parts.append(role.company) if role.location: meta_parts.append(role.location) if role.dates: meta_parts.append(role.dates) meta_text = " · ".join(meta_parts) if meta_text: meta_para = doc.add_paragraph() meta_para.paragraph_format.space_after = Pt(3) run = meta_para.add_run(meta_text) run.italic = True run.font.name = "Calibri" run.font.size = Pt(10) run.font.color.rgb = _META_COLOR # Bullets for bullet in role.bullets: p = doc.add_paragraph(style="List Bullet") p.paragraph_format.space_after = Pt(2) p.paragraph_format.left_indent = Inches(0.2) run = p.add_run(_clean_bullet_text(bullet)) run.font.name = "Calibri" run.font.size = Pt(10.5) run.font.color.rgb = _BODY_COLOR def _write_achievements(doc: Document, resume: Resume) -> None: if not resume.achievements: return _add_section_header(doc, "KEY ACHIEVEMENTS") for ach in resume.achievements: p = doc.add_paragraph(style="List Bullet") p.paragraph_format.space_after = Pt(2) p.paragraph_format.left_indent = Inches(0.2) run = p.add_run(_clean_bullet_text(ach)) run.font.name = "Calibri" run.font.size = Pt(10.5) run.font.color.rgb = _BODY_COLOR def _write_education(doc: Document, resume: Resume) -> None: if not resume.education: return _add_section_header(doc, "EDUCATION") for edu in resume.education: # Degree — bold 10.5pt deg_para = doc.add_paragraph() deg_para.paragraph_format.space_before = Pt(4) deg_para.paragraph_format.space_after = Pt(0) run = deg_para.add_run(edu.degree) run.bold = True run.font.name = "Calibri" run.font.size = Pt(10.5) run.font.color.rgb = _NAME_COLOR # Institution · Dates — italic gray meta_parts = [] if edu.institution: meta_parts.append(edu.institution) if edu.dates: meta_parts.append(edu.dates) meta_text = " · ".join(meta_parts) if meta_text: ip = doc.add_paragraph() ip.paragraph_format.space_after = Pt(2) run = ip.add_run(meta_text) run.italic = True run.font.name = "Calibri" run.font.size = Pt(10) run.font.color.rgb = _META_COLOR # ───────────────────────────────────────────────────────────────────────── # Section header helper # ───────────────────────────────────────────────────────────────────────── def _add_section_header(doc: Document, title: str) -> None: """ALL-CAPS section header with thin indigo underline.""" p = doc.add_paragraph() p.paragraph_format.space_before = Pt(10) p.paragraph_format.space_after = Pt(4) run = p.add_run(title.upper()) run.bold = True run.font.name = "Calibri" run.font.size = Pt(11) run.font.color.rgb = _SECTION_COLOR _add_bottom_border(p) def _add_bottom_border(paragraph, size: str = "6", color: str = _HR_LINE_COLOR) -> None: pPr = paragraph._p.get_or_add_pPr() pBdr = OxmlElement("w:pBdr") bottom = OxmlElement("w:bottom") bottom.set(qn("w:val"), "single") bottom.set(qn("w:sz"), size) bottom.set(qn("w:space"), "1") bottom.set(qn("w:color"), color) pBdr.append(bottom) pPr.append(pBdr) # ───────────────────────────────────────────────────────────────────────── # Helpers # ───────────────────────────────────────────────────────────────────────── def _clean_bullet_text(text: str) -> str: """Strip leading bullet chars and collapse whitespace.""" s = re.sub(r"^[•\-–—*▪●\s]+", "", text) return re.sub(r"\s+", " ", s).strip()