Spaces:
Sleeping
Sleeping
| """ | |
| 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() | |