cv-buddy-backend / app /services /resume_generator.py
Momal's picture
Deploy cv-buddy backend
366c43e
from __future__ import annotations
import io
from pathlib import Path
from typing import TYPE_CHECKING
from jinja2 import Template
from docx import Document
from docx.shared import Pt
from docx.enum.text import WD_ALIGN_PARAGRAPH
if TYPE_CHECKING:
from app.models.resume import ResumeData
class ResumeGenerator:
def __init__(self):
self.templates_dir = Path(__file__).parent.parent.parent / "templates"
def to_html(self, resume: "ResumeData") -> str:
template_path = self.templates_dir / "resume.html"
# Use default template if not exists
if not template_path.exists():
template_str = self._default_template()
else:
template_str = template_path.read_text()
template = Template(template_str)
return template.render(resume=resume)
def _sanitize_text(self, text: str) -> str:
"""Remove or replace characters not supported by Helvetica."""
replacements = {
'β˜…': '*',
'β˜†': '*',
'β€’': '-',
'β†’': '->',
'←': '<-',
'βœ“': '[x]',
'βœ—': '[ ]',
'…': '...',
'"': '"',
'"': '"',
''': "'",
''': "'",
'–': '-',
'β€”': '-',
}
for char, replacement in replacements.items():
text = text.replace(char, replacement)
# Remove any remaining non-latin1 characters
return text.encode('latin-1', errors='replace').decode('latin-1')
def to_pdf(self, resume: "ResumeData") -> bytes:
from fpdf import FPDF
pdf = FPDF()
pdf.add_page()
pdf.set_margins(15, 15, 15)
pdf.set_auto_page_break(auto=True, margin=15)
sanitize = self._sanitize_text
# Contact header
pdf.set_font("Helvetica", "B", 14)
pdf.cell(0, 8, sanitize(resume.contact.name or "Name"), ln=True, align="C")
pdf.set_font("Helvetica", "", 9)
contact_parts = [p for p in [resume.contact.email, resume.contact.phone, resume.contact.location] if p]
if contact_parts:
pdf.cell(0, 5, sanitize(" | ".join(contact_parts)), ln=True, align="C")
pdf.ln(4)
page_width = pdf.w - pdf.l_margin - pdf.r_margin
# Summary
if resume.summary:
pdf.set_font("Helvetica", "B", 11)
pdf.cell(0, 7, "SUMMARY", ln=True)
pdf.set_draw_color(100, 100, 100)
pdf.line(pdf.l_margin, pdf.get_y(), pdf.l_margin + page_width, pdf.get_y())
pdf.ln(2)
pdf.set_font("Helvetica", "", 9)
pdf.multi_cell(page_width, 4, sanitize(resume.summary))
pdf.ln(3)
# Experience
if resume.experience:
pdf.set_font("Helvetica", "B", 11)
pdf.cell(0, 7, "EXPERIENCE", ln=True)
pdf.line(pdf.l_margin, pdf.get_y(), pdf.l_margin + page_width, pdf.get_y())
pdf.ln(2)
for exp in resume.experience:
pdf.set_font("Helvetica", "B", 10)
title_company = f"{exp.title} - {exp.company}"
pdf.cell(0, 5, sanitize(title_company[:80]), ln=True)
if exp.dates:
pdf.set_font("Helvetica", "I", 8)
pdf.cell(0, 4, sanitize(exp.dates), ln=True)
pdf.set_font("Helvetica", "", 9)
for bullet in exp.bullets:
bullet_text = f"* {bullet}"
pdf.multi_cell(page_width, 4, sanitize(bullet_text))
pdf.ln(2)
# Education
if resume.education:
pdf.set_font("Helvetica", "B", 11)
pdf.cell(0, 7, "EDUCATION", ln=True)
pdf.line(pdf.l_margin, pdf.get_y(), pdf.l_margin + page_width, pdf.get_y())
pdf.ln(2)
for edu in resume.education:
pdf.set_font("Helvetica", "B", 10)
pdf.cell(0, 5, sanitize(f"{edu.degree} - {edu.school}"), ln=True)
if edu.dates:
pdf.set_font("Helvetica", "I", 8)
pdf.cell(0, 4, sanitize(edu.dates), ln=True)
pdf.ln(2)
# Skills
if resume.skills:
pdf.set_font("Helvetica", "B", 11)
pdf.cell(0, 7, "SKILLS", ln=True)
pdf.line(pdf.l_margin, pdf.get_y(), pdf.l_margin + page_width, pdf.get_y())
pdf.ln(2)
pdf.set_font("Helvetica", "", 9)
skills_text = ", ".join(resume.skills)
pdf.multi_cell(page_width, 4, sanitize(skills_text))
return bytes(pdf.output())
def to_docx(self, resume: "ResumeData") -> bytes:
doc = Document()
# Contact info
name_para = doc.add_paragraph()
name_run = name_para.add_run(resume.contact.name)
name_run.bold = True
name_run.font.size = Pt(16)
name_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
contact_para = doc.add_paragraph()
contact_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
contact_parts = []
if resume.contact.email:
contact_parts.append(resume.contact.email)
if resume.contact.phone:
contact_parts.append(resume.contact.phone)
if resume.contact.location:
contact_parts.append(resume.contact.location)
contact_para.add_run(" | ".join(contact_parts))
# Summary
if resume.summary:
doc.add_heading("Summary", level=1)
doc.add_paragraph(resume.summary)
# Experience
if resume.experience:
doc.add_heading("Experience", level=1)
for exp in resume.experience:
exp_para = doc.add_paragraph()
exp_para.add_run(f"{exp.title}").bold = True
exp_para.add_run(f" | {exp.company}")
exp_para.add_run(f" | {exp.dates}").italic = True
for bullet in exp.bullets:
doc.add_paragraph(bullet, style="List Bullet")
# Education
if resume.education:
doc.add_heading("Education", level=1)
for edu in resume.education:
edu_para = doc.add_paragraph()
edu_para.add_run(f"{edu.degree}").bold = True
edu_para.add_run(f" | {edu.school}")
edu_para.add_run(f" | {edu.dates}").italic = True
# Skills
if resume.skills:
doc.add_heading("Skills", level=1)
doc.add_paragraph(", ".join(resume.skills))
buffer = io.BytesIO()
doc.save(buffer)
return buffer.getvalue()
def _default_template(self) -> str:
return """
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body { font-family: Arial, sans-serif; margin: 40px; font-size: 11pt; }
h1 { font-size: 18pt; margin-bottom: 5px; }
h2 { font-size: 13pt; border-bottom: 1px solid #333; margin-top: 15px; }
.contact { text-align: center; margin-bottom: 15px; }
.contact h1 { margin: 0; }
.contact p { margin: 5px 0; color: #555; }
.experience-item { margin-bottom: 12px; }
.experience-header { font-weight: bold; }
.experience-meta { color: #555; font-style: italic; }
ul { margin: 5px 0; padding-left: 20px; }
li { margin: 3px 0; }
.skills { margin-top: 10px; }
</style>
</head>
<body>
<div class="contact">
<h1>{{ resume.contact.name }}</h1>
<p>{{ resume.contact.email }} | {{ resume.contact.phone }} | {{ resume.contact.location }}</p>
</div>
{% if resume.summary %}
<h2>Summary</h2>
<p>{{ resume.summary }}</p>
{% endif %}
{% if resume.experience %}
<h2>Experience</h2>
{% for exp in resume.experience %}
<div class="experience-item">
<div class="experience-header">{{ exp.title }} | {{ exp.company }}</div>
<div class="experience-meta">{{ exp.dates }}</div>
<ul>
{% for bullet in exp.bullets %}
<li>{{ bullet }}</li>
{% endfor %}
</ul>
</div>
{% endfor %}
{% endif %}
{% if resume.education %}
<h2>Education</h2>
{% for edu in resume.education %}
<p><strong>{{ edu.degree }}</strong> | {{ edu.school }} | {{ edu.dates }}</p>
{% endfor %}
{% endif %}
{% if resume.skills %}
<h2>Skills</h2>
<p class="skills">{{ resume.skills | join(', ') }}</p>
{% endif %}
</body>
</html>
"""