JAA-ATS-Tool / src /resume_renderer.py
saitejatirunagari's picture
feat: LaTeX resume input + recruiter-grade keyword placement + resilient Run (Phase 7)
7759bfb
Raw
History Blame
16 kB
"""
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()