Spaces:
Running
Running
| 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> | |
| """ | |