Spaces:
Running
Running
import logging | |
import os | |
import re | |
from datetime import datetime | |
from dateutil.parser import parse as date_parse | |
from docx import Document | |
from docx.enum.text import WD_PARAGRAPH_ALIGNMENT, WD_TAB_ALIGNMENT | |
from docx.shared import Inches, Pt | |
logger = logging.getLogger(__name__) | |
def fmt_range(raw: str) -> str: | |
"""Formats a date range string nicely.""" | |
if not raw: | |
return "" | |
parts = [p.strip() for p in re.split(r"\s*[β-]\s*", raw)] | |
formatted_parts = [] | |
for part in parts: | |
if part.lower() == "present": | |
formatted_parts.append("Present") | |
else: | |
try: | |
date_obj = date_parse(part, fuzzy=True, default=datetime(1900, 1, 1)) | |
if date_obj.year == 1900: | |
formatted_parts.append(part) | |
else: | |
formatted_parts.append(date_obj.strftime("%B %Y")) | |
except (ValueError, TypeError): | |
formatted_parts.append(part) | |
return " β ".join(formatted_parts) | |
def add_section_heading(doc, text): | |
"""Adds a centered section heading.""" | |
p = doc.add_paragraph() | |
run = p.add_run(text.upper()) | |
run.bold = True | |
font = run.font | |
font.size = Pt(12) | |
font.name = 'Arial' | |
p.alignment = WD_PARAGRAPH_ALIGNMENT.CENTER | |
p.paragraph_format.space_after = Pt(6) | |
def build_resume_from_data(tmpl: str, sections: dict, remove_blank_pages_enabled: bool = True) -> Document: | |
""" | |
Builds a formatted resume from structured data, inserting header/footer images and logging the process. | |
""" | |
logger.info("BUILDER: Starting image-based resume build process.") | |
try: | |
# 1. Create a new blank document, ignoring the template file | |
doc = Document() | |
logger.info("BUILDER: Successfully created a new blank document.") | |
# Get section and enable different first page header/footer | |
section = doc.sections[0] | |
section.different_first_page = True | |
# Move header and footer to the very edge of the page | |
section.header_distance = Pt(0) | |
section.footer_distance = Pt(0) | |
logger.info("BUILDER: Set header/footer distance to 0 to remove whitespace.") | |
# 2. Define image paths relative to the project root | |
script_dir = os.path.dirname(os.path.abspath(__file__)) | |
project_root = os.path.dirname(script_dir) | |
header_path = os.path.join(project_root, 'header.png') | |
footer_path = os.path.join(project_root, 'footer.png') | |
logger.info(f"BUILDER: Attempting to use header image from: {header_path}") | |
logger.info(f"BUILDER: Attempting to use footer image from: {footer_path}") | |
if not os.path.exists(header_path): | |
logger.error(f"BUILDER FATAL: Header image not found at '{header_path}'. Cannot proceed.") | |
return doc # Return empty doc | |
if not os.path.exists(footer_path): | |
logger.error(f"BUILDER FATAL: Footer image not found at '{footer_path}'. Cannot proceed.") | |
return doc # Return empty doc | |
# 3. Setup Headers | |
candidate_name = sections.get("Name", "Candidate Name Not Found") | |
experiences = sections.get("StructuredExperiences", []) | |
job_title = experiences[0].get("title", "") if experiences else "" | |
# -- First Page Header (Image + Name + Title) -- | |
first_page_header = section.first_page_header | |
first_page_header.is_linked_to_previous = False | |
# Safely get or create a paragraph for the image | |
p_header_img_first = first_page_header.paragraphs[0] if first_page_header.paragraphs else first_page_header.add_paragraph() | |
p_header_img_first.clear() | |
p_header_img_first.paragraph_format.space_before = Pt(0) | |
p_header_img_first.paragraph_format.space_after = Pt(0) | |
p_header_img_first.paragraph_format.left_indent = -section.left_margin | |
p_header_img_first.add_run().add_picture(header_path, width=section.page_width) | |
logger.info("BUILDER: Inserted header.png into FIRST PAGE header.") | |
# Add Name | |
p_name = first_page_header.add_paragraph() | |
run_name = p_name.add_run(candidate_name.upper()) | |
run_name.font.name = 'Arial' | |
run_name.font.size = Pt(14) | |
run_name.bold = True | |
p_name.alignment = WD_PARAGRAPH_ALIGNMENT.CENTER | |
p_name.paragraph_format.space_before = Pt(6) | |
p_name.paragraph_format.space_after = Pt(0) | |
logger.info(f"BUILDER: Added candidate name '{candidate_name}' to FIRST PAGE header.") | |
# Add Job Title | |
if job_title: | |
p_title = first_page_header.add_paragraph() | |
run_title = p_title.add_run(job_title) | |
run_title.font.name = 'Arial' | |
run_title.font.size = Pt(11) | |
p_title.alignment = WD_PARAGRAPH_ALIGNMENT.CENTER | |
p_title.paragraph_format.space_before = Pt(0) | |
logger.info(f"BUILDER: Added job title '{job_title}' to FIRST PAGE header.") | |
# -- Primary Header for subsequent pages (Image Only) -- | |
primary_header = section.header | |
primary_header.is_linked_to_previous = False | |
# Safely get or create a paragraph for the image | |
p_header_img_primary = primary_header.paragraphs[0] if primary_header.paragraphs else primary_header.add_paragraph() | |
p_header_img_primary.clear() | |
p_header_img_primary.paragraph_format.space_before = Pt(0) | |
p_header_img_primary.paragraph_format.space_after = Pt(0) | |
p_header_img_primary.paragraph_format.left_indent = -section.left_margin | |
p_header_img_primary.add_run().add_picture(header_path, width=section.page_width) | |
logger.info("BUILDER: Inserted header.png into PRIMARY header for subsequent pages.") | |
# 4. Insert Footer Image (same for all pages) | |
footer = section.footer | |
footer.is_linked_to_previous = False | |
# Safely get or create a paragraph for the image | |
p_footer_img = footer.paragraphs[0] if footer.paragraphs else footer.add_paragraph() | |
p_footer_img.clear() | |
p_footer_img.paragraph_format.space_before = Pt(0) | |
p_footer_img.paragraph_format.space_after = Pt(0) | |
p_footer_img.paragraph_format.left_indent = -section.left_margin | |
p_footer_img.add_run().add_picture(footer_path, width=section.page_width) | |
# Link the first page footer to the primary footer so we only define it once. | |
section.first_page_footer.is_linked_to_previous = True | |
logger.info("BUILDER: Inserted footer.png and configured for all pages.") | |
# 5. Build Resume Body | |
logger.info("BUILDER: Proceeding to add structured resume content to document body.") | |
# --- Professional Summary --- | |
if sections.get("Summary"): | |
add_section_heading(doc, "Professional Summary") | |
doc.add_paragraph(sections["Summary"]).paragraph_format.space_after = Pt(12) | |
# --- Skills --- | |
if sections.get("Skills"): | |
add_section_heading(doc, "Skills") | |
skills_text = ", ".join(sections["Skills"]) | |
p = doc.add_paragraph(skills_text) | |
p.alignment = WD_PARAGRAPH_ALIGNMENT.CENTER | |
p.paragraph_format.space_after = Pt(12) | |
# --- Professional Experience --- | |
if experiences: | |
add_section_heading(doc, "Professional Experience") | |
for exp in experiences: | |
if not isinstance(exp, dict): | |
continue | |
p = doc.add_paragraph() | |
p.add_run(exp.get("title", "N/A")).bold = True | |
p.add_run(" | ").bold = True | |
p.add_run(exp.get("company", "N/A")).italic = True | |
p.add_run(f'\t{fmt_range(exp.get("date_range", ""))}') | |
tab_stops = p.paragraph_format.tab_stops | |
tab_stops.add_tab_stop(Inches(6.5), WD_TAB_ALIGNMENT.RIGHT) | |
responsibilities = exp.get("responsibilities", []) | |
if responsibilities and isinstance(responsibilities, list): | |
for resp in responsibilities: | |
if resp.strip(): | |
try: | |
p_resp = doc.add_paragraph(resp, style='List Bullet') | |
except KeyError: | |
p_resp = doc.add_paragraph(f"β’ {resp}") | |
p_resp.paragraph_format.left_indent = Inches(0.25) | |
p_resp.paragraph_format.space_before = Pt(0) | |
p_resp.paragraph_format.space_after = Pt(3) | |
doc.add_paragraph().paragraph_format.space_after = Pt(6) | |
# --- Education --- | |
if sections.get("Education"): | |
add_section_heading(doc, "Education") | |
for edu in sections.get("Education", []): | |
if edu.strip(): | |
try: | |
p_edu = doc.add_paragraph(edu, style='List Bullet') | |
except KeyError: | |
p_edu = doc.add_paragraph(f"β’ {edu}") | |
p_edu.paragraph_format.left_indent = Inches(0.25) | |
logger.info("BUILDER: Resume build process completed successfully.") | |
return doc | |
except Exception: | |
logger.error("BUILDER: An unexpected error occurred during resume generation.", exc_info=True) | |
return Document() | |