diff --git a/.gitignore b/.gitignore index 21656f5..20ff241 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,6 @@ .DS_Store -public/ \ No newline at end of file +public/ + +# Resume PDF generation artifacts +typst/data/resume-data.json +typst/main.typ \ No newline at end of file diff --git a/RESUME_PDF.md b/RESUME_PDF.md new file mode 100644 index 0000000..b615690 --- /dev/null +++ b/RESUME_PDF.md @@ -0,0 +1,129 @@ +# Resume PDF Generation System + +This system automatically generates professional PDF resumes from the markdown content in `content/resume/_index.md` using Typst. + +## Features + +- **Automatic extraction** of resume data from markdown +- **Multiple layout styles** (default, modern, minimal, classic) +- **Modular configuration** for easy customization +- **Server-side generation** integrated with the build process + +## Quick Start + +### Generate PDF Only +```bash +# Default style +./scripts/build_resume.sh + +# Specific style +./scripts/build_resume.sh modern +./scripts/build_resume.sh minimal +./scripts/build_resume.sh classic +``` + +### Build Site + PDF +```bash +# Builds both the Zola site and generates the PDF +./scripts/build_site.sh [style] +``` + +## Directory Structure + +``` +typst/ +├── data/ # Generated JSON data from markdown +├── styles/ # Style configurations +│ └── styles.typ # All style definitions +├── templates/ # Typst templates +│ └── resume.typ # Main resume template +├── config.env # Configuration file +└── main.typ # Generated document entry point + +scripts/ +├── extract_resume_data.py # Markdown to JSON parser +├── build_resume.sh # PDF generation script +└── build_site.sh # Complete build script +``` + +## Available Styles + +### Default +- Blue accent color (#365590) +- Section lines enabled +- Traditional layout + +### Modern +- Blue accent color (#2563eb) +- No section lines +- Clean, minimal design +- Larger name font + +### Minimal +- Black accent color +- No section lines +- Compact spacing +- Simple typography + +### Classic +- Burgundy accent color (#800020) +- Traditional serif fonts +- Justified text +- Formal layout + +## Customization + +### 1. Edit Configuration +Modify `typst/config.env` to change basic settings. + +### 2. Create New Style +Add a new configuration in `typst/styles/styles.typ`: + +```typst +#let my-config = { + let config = default-config + config.colors.accent = rgb("#your-color") + // ... customize other properties + config +} +``` + +### 3. Modify Template +Edit `typst/templates/resume.typ` to change the layout structure. + +## Integration with CI/CD + +The build scripts can be integrated into deployment workflows: + +```yaml +# Example GitHub Actions step +- name: Build Resume PDF + run: | + ./scripts/build_site.sh modern + +- name: Deploy + run: | + # Deploy public/ directory + # PDF is available at static/resume.pdf +``` + +## Dependencies + +- **Typst** - Install via cargo: `cargo install typst-cli` +- **Python 3** - For markdown parsing +- **Zola** - For site generation (optional) + +## Troubleshooting + +### Font Warnings +If you see font warnings, the system will fall back to system fonts. This is normal and doesn't affect functionality. + +### Build Errors +- Ensure Typst is in your PATH: `export PATH="$HOME/.cargo/bin:$PATH"` +- Check that all required files exist in the typst/ directory +- Verify the JSON data is being generated correctly + +### Style Issues +- Check that your style name matches the config names in styles.typ +- Ensure color values are valid Typst rgb() values +- Verify spacing values include units (pt, em, in, etc.) \ No newline at end of file diff --git a/scripts/build_resume.sh b/scripts/build_resume.sh new file mode 100755 index 0000000..9e6148f --- /dev/null +++ b/scripts/build_resume.sh @@ -0,0 +1,65 @@ +#!/bin/bash +# Build resume PDF using Typst + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" +TYPST_DIR="$PROJECT_ROOT/typst" +CONTENT_DIR="$PROJECT_ROOT/content/resume" +STATIC_DIR="$PROJECT_ROOT/static" + +# Configuration +STYLE=${1:-"default"} # default, modern, minimal, classic +OUTPUT_NAME=${2:-"resume.pdf"} + +echo "Building resume PDF with style: $STYLE" + +# Extract resume data from markdown +echo "Extracting resume data..." +python3 "$SCRIPT_DIR/extract_resume_data.py" \ + "$CONTENT_DIR/_index.md" \ + "$TYPST_DIR/data/resume-data.json" + +# Create main.typ with selected style +echo "Generating Typst document..." +cat > "$TYPST_DIR/main.typ" << EOF +// Main resume document +// Imports resume data and generates PDF + +#import "templates/resume.typ": resume +#import "styles/styles.typ": default-config, modern-config, minimal-config, classic-config + +// Load resume data from JSON +#let resume-data = json("data/resume-data.json") + +// Choose configuration +#let config = ${STYLE}-config + +// Generate resume +#resume( + name: resume-data.name, + contact: resume-data.contact, + sections: resume-data.sections, + config: config +) +EOF + +# Check if typst is available +if ! command -v typst &> /dev/null; then + echo "Error: typst command not found. Please install Typst." + echo "Trying to use cargo installed version..." + export PATH="$HOME/.cargo/bin:$PATH" + if ! command -v typst &> /dev/null; then + echo "Error: typst still not found even after adding cargo bin to PATH" + exit 1 + fi +fi + +# Compile to PDF +echo "Compiling PDF..." +cd "$TYPST_DIR" +typst compile main.typ "$STATIC_DIR/$OUTPUT_NAME" + +echo "Resume PDF generated: $STATIC_DIR/$OUTPUT_NAME" +echo "Style used: $STYLE" \ No newline at end of file diff --git a/scripts/build_site.sh b/scripts/build_site.sh new file mode 100755 index 0000000..5b3f722 --- /dev/null +++ b/scripts/build_site.sh @@ -0,0 +1,34 @@ +#!/bin/bash +# Enhanced build script that builds both the Zola site and the PDF resume + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" + +# Configuration +RESUME_STYLE=${1:-"default"} # default, modern, minimal, classic +OUTPUT_NAME="resume.pdf" + +echo "Building Zola site with updated resume..." + +# Build the resume PDF +echo "1. Generating resume PDF..." +cd "$PROJECT_ROOT" +export PATH="$HOME/.cargo/bin:$PATH" +./scripts/build_resume.sh "$RESUME_STYLE" "$OUTPUT_NAME" + +# Check if zola is available +if ! command -v zola &> /dev/null; then + echo "Warning: zola command not found. Skipping site build." + echo "PDF generation complete. Resume available at: static/$OUTPUT_NAME" + exit 0 +fi + +# Build the Zola site +echo "2. Building Zola site..." +zola build + +echo "Build complete!" +echo "- Resume PDF: static/$OUTPUT_NAME (style: $RESUME_STYLE)" +echo "- Site: public/" \ No newline at end of file diff --git a/scripts/example_custom_style.sh b/scripts/example_custom_style.sh new file mode 100755 index 0000000..4c21fe2 --- /dev/null +++ b/scripts/example_custom_style.sh @@ -0,0 +1,32 @@ +#!/bin/bash +# Example: Create a custom style and generate PDF + +# This script demonstrates how to create a custom style configuration + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" + +# Add a custom style to the styles.typ file +cat >> "$PROJECT_ROOT/typst/styles/styles.typ" << 'EOF' + +// Custom teal configuration +#let teal-config = { + let config = default-config + config.colors.accent = rgb("#14b8a6") + config.colors.section = rgb("#14b8a6") + config.section-line = true + config.sizes.name = 26pt + config.spacing.after-header = 18pt + config +} +EOF + +# Temporarily update the build script to support the new style +sed -i 's/classic-config/teal-config/' "$PROJECT_ROOT/typst/main.typ" + +# Build with the custom style +cd "$PROJECT_ROOT" +export PATH="$HOME/.cargo/bin:$PATH" +./scripts/build_resume.sh teal "resume-teal.pdf" + +echo "Custom teal style PDF generated: static/resume-teal.pdf" \ No newline at end of file diff --git a/scripts/extract_resume_data.py b/scripts/extract_resume_data.py new file mode 100755 index 0000000..e918428 --- /dev/null +++ b/scripts/extract_resume_data.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +""" +Extract resume data from markdown and convert to JSON for Typst processing. +""" + +import re +import json +import sys +from pathlib import Path +from typing import Dict, List, Any + + +def parse_markdown_resume(content: str) -> Dict[str, Any]: + """Parse the markdown resume content into structured data.""" + + # Remove TOML frontmatter + content = re.sub(r'^\+\+\+.*?\+\+\+\s*', '', content, flags=re.DOTALL) + + # Split by sections (## headers) + sections = re.split(r'^## (.+)$', content, flags=re.MULTILINE) + + resume_data = { + "sections": {}, + "name": "Max Farrell", # Could be extracted from frontmatter + "contact": { + "location": "Austin, TX", + "linkedin": "linkedin.com/in/maxffarrell", + "github": "github.com/maxffarrell" + } + } + + # Parse sections + current_section = None + for i, part in enumerate(sections): + if i == 0: + continue # Skip content before first section + + if i % 2 == 1: # Section header + current_section = part.strip() + resume_data["sections"][current_section] = {"content": "", "items": []} + else: # Section content + if current_section: + resume_data["sections"][current_section]["content"] = part.strip() + + # Extract location from About Me section + if current_section == "About Me": + location_match = re.search(r'\*\*Location:\*\*\s*(.+)', part) + if location_match: + resume_data["contact"]["location"] = location_match.group(1).strip() + + # Parse experience/project items + if current_section in ["Experience", "Projects", "Education"]: + items = parse_section_items(part.strip()) + resume_data["sections"][current_section]["items"] = items + + return resume_data + + +def parse_section_items(content: str) -> List[Dict[str, Any]]: + """Parse experience/project items from section content.""" + items = [] + lines = content.split('\n') + + current_item = None + + for line in lines: + line = line.strip() + + # Check for job header pattern: **Company** • Role (Dates) + job_match = re.match(r'\*\*(.+?)\*\*\s*•\s*(.+)', line) + if job_match: + # Save previous item + if current_item: + items.append(current_item) + + company = job_match.group(1).strip() + role_and_dates = job_match.group(2).strip() + + # Extract dates from role + date_match = re.search(r'\(([^)]+)\)$', role_and_dates) + if date_match: + dates = date_match.group(1) + role = role_and_dates[:date_match.start()].strip() + else: + role = role_and_dates + dates = "" + + current_item = { + "company": company, + "role": role, + "dates": dates, + "description": [] + } + + # Check for simple company name without role + elif re.match(r'\*\*(.+?)\*\*$', line): + # Save previous item + if current_item: + items.append(current_item) + + company = re.match(r'\*\*(.+?)\*\*$', line).group(1).strip() + current_item = { + "company": company, + "role": "", + "dates": "", + "description": [] + } + + # Check for bullet points + elif line.startswith('- ') and current_item: + bullet = line[2:].strip() + current_item["description"].append(bullet) + + # Add the last item + if current_item: + items.append(current_item) + + return items + + +def main(): + """Main function to process resume markdown.""" + if len(sys.argv) != 3: + print("Usage: extract_resume_data.py ") + sys.exit(1) + + input_file = Path(sys.argv[1]) + output_file = Path(sys.argv[2]) + + if not input_file.exists(): + print(f"Error: Input file {input_file} does not exist") + sys.exit(1) + + # Read and parse the markdown + content = input_file.read_text(encoding='utf-8') + resume_data = parse_markdown_resume(content) + + # Write JSON output + with open(output_file, 'w', encoding='utf-8') as f: + json.dump(resume_data, f, indent=2, ensure_ascii=False) + + print(f"Resume data extracted to {output_file}") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/static/resume.pdf b/static/resume.pdf index d20ff23..9bba85e 100644 Binary files a/static/resume.pdf and b/static/resume.pdf differ diff --git a/typst/config.env b/typst/config.env new file mode 100644 index 0000000..9c99914 --- /dev/null +++ b/typst/config.env @@ -0,0 +1,30 @@ +# Resume PDF Configuration +# This file allows easy customization of the PDF layout + +# Available styles: default, modern, minimal, classic +RESUME_STYLE=default + +# Output filename +OUTPUT_NAME=resume.pdf + +# Color scheme (for custom configuration) +ACCENT_COLOR=#365590 +SECTION_COLOR=#365590 +TEXT_COLOR=#000000 + +# Typography settings +FONT_FAMILY=Liberation Sans +NAME_SIZE=24pt +SECTION_SIZE=14pt +BODY_SIZE=11pt + +# Layout options +ENABLE_SECTION_LINE=true +JUSTIFY_TEXT=false +PAGE_MARGINS=0.75in + +# Spacing (in points) +AFTER_NAME=4pt +AFTER_HEADER=16pt +BETWEEN_SECTIONS=12pt +BETWEEN_ITEMS=10pt \ No newline at end of file diff --git a/typst/styles/styles.typ b/typst/styles/styles.typ new file mode 100644 index 0000000..0fc36ca --- /dev/null +++ b/typst/styles/styles.typ @@ -0,0 +1,96 @@ +// Resume Styling Configuration +// Modular and customizable design system + +// Default configuration +#let default-config = ( + // Page settings + paper: "us-letter", + margins: (x: 0.75in, y: 0.75in), + + // Typography + fonts: ( + heading: ("Liberation Sans",), + body: ("Liberation Sans",) + ), + + // Font sizes + sizes: ( + name: 24pt, + section: 14pt, + contact: 11pt, + body: 11pt, + item-title: 11pt, + item-details: 10pt + ), + + // Colors + colors: ( + background: white, + text: black, + accent: rgb("#365590"), + section: rgb("#365590"), + item-title: black, + item-details: rgb("#555555") + ), + + // Spacing + spacing: ( + after-name: 4pt, + after-header: 16pt, + after-section-heading: 4pt, + after-section-line: 8pt, + after-section-content: 12pt, + after-item-title: 3pt, + between-items: 10pt + ), + + // Layout options + justify: false, + section-line: true, + list-tight: true +) + +// Alternative modern configuration +#let modern-config = { + let config = default-config + config.colors.accent = rgb("#2563eb") + config.colors.section = rgb("#2563eb") + config.section-line = false + config.fonts.heading = ("Liberation Sans",) + config.sizes.name = 28pt + config.spacing.after-header = 20pt + config +} + +// Minimal configuration +#let minimal-config = { + let config = default-config + config.colors.accent = rgb("#000000") + config.colors.section = rgb("#000000") + config.section-line = false + config.sizes.name = 20pt + config.spacing = ( + after-name: 3pt, + after-header: 12pt, + after-section-heading: 3pt, + after-section-line: 6pt, + after-section-content: 10pt, + after-item-title: 2pt, + between-items: 8pt + ) + config +} + +// Classic configuration +#let classic-config = { + let config = default-config + config.colors.accent = rgb("#800020") + config.colors.section = rgb("#800020") + config.fonts = ( + heading: ("Liberation Serif",), + body: ("Liberation Serif",) + ) + config.sizes.name = 22pt + config.justify = true + config +} \ No newline at end of file diff --git a/typst/templates/resume.typ b/typst/templates/resume.typ new file mode 100644 index 0000000..21d76b4 --- /dev/null +++ b/typst/templates/resume.typ @@ -0,0 +1,121 @@ +// Modern Resume Template for Typst +// Configurable layout and styling + +#import "../styles/styles.typ": * + +#let format_item(item, config) = { + // Company and role + if "company" in item and item.company != "" [ + #grid( + columns: (1fr, auto), + [ + #text( + weight: "bold", + size: config.sizes.item-title, + fill: config.colors.item-title + )[#item.company] + + #if "role" in item and item.role != "" [ + #text( + size: config.sizes.item-details, + fill: config.colors.item-details + )[ • #item.role] + ] + ], + [ + #if "dates" in item and item.dates != "" [ + #text( + size: config.sizes.item-details, + fill: config.colors.item-details, + style: "italic" + )[#item.dates] + ] + ] + ) + + #v(config.spacing.after-item-title) + ] + + // Description bullets + if "description" in item and item.description.len() > 0 [ + #for bullet in item.description [ + #list.item[#bullet] + ] + ] +} + +#let resume( + name: "Name", + contact: (:), + sections: (:), + config: default-config +) = { + + // Page setup + set page( + paper: config.paper, + margin: config.margins, + fill: config.colors.background + ) + + set text( + font: config.fonts.body, + size: config.sizes.body, + fill: config.colors.text + ) + + set par(justify: config.justify) + + // Header with name and contact + align(center)[ + #text( + font: config.fonts.heading, + size: config.sizes.name, + weight: "bold", + fill: config.colors.accent + )[#name] + + #if contact.keys().len() > 0 [ + #v(config.spacing.after-name) + #text(size: config.sizes.contact)[ + #contact.values().join(" • ") + ] + ] + ] + + v(config.spacing.after-header) + + // Process sections + for (section-name, section-data) in sections { + if section-name != "" { + // Section heading + text( + font: config.fonts.heading, + size: config.sizes.section, + weight: "bold", + fill: config.colors.section + )[#section-name] + + v(config.spacing.after-section-heading) + + // Section line (if enabled) + if config.section-line { + line(length: 100%, stroke: config.colors.accent + 0.5pt) + v(config.spacing.after-section-line) + } + + // Section content + if "items" in section-data and section-data.items.len() > 0 { + // Structured items (Experience, Projects, etc.) + for item in section-data.items { + format_item(item, config) + v(config.spacing.between-items) + } + } else { + // Plain content + [#section-data.content] + v(config.spacing.after-section-content) + } + } + } +} \ No newline at end of file