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()