JAA-ATS-Tool / src /resume_renderer.py
saitejatirunagari's picture
feat(phase4): canonical Resume model + single locked visual format
0e70529
Raw
History Blame
10.2 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)
NO Skills/Competencies section anywhere. NO sub-sections.
"""
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_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
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()