diff --git a/.gitignore b/.gitignore index 18262e2..ffe7d41 100644 --- a/.gitignore +++ b/.gitignore @@ -3,5 +3,8 @@ .claude .idea .venv/ +__pycache__/ +*.pyc test/exports/ -test/out/ \ No newline at end of file +test/out/ +*_raw diff --git a/README.md b/README.md index 956c92f..e8f4862 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # WebResume 🌐 πŸ’» πŸ“„ -A modern, interactive web-based resume β€” dynamic content loading, dark mode, live editing, and print-ready layout. +A modern, interactive web-based resume - dynamic content loading, dark mode, live editing, and print-ready layout. [![Live Site](https://img.shields.io/badge/πŸ”—_Live_Site-hellosaumil.github.io/WebResume-blue)](https://hellosaumil.github.io/WebResume) [![Latest Version](https://img.shields.io/badge/πŸ“Œ_Latest-/v4-purple)](https://hellosaumil.github.io/WebResume/v4/) @@ -17,48 +17,50 @@ A modern, interactive web-based resume β€” dynamic content loading, dark mode, l ### The Why? On the onset of 2026, I wanted to revamp my resume since I last updated it in 2020 after my post-graduation. -My resume lived in an `Apple Pages` template I hand-crafted in 2017 β€” custom fonts, pixel-perfect spacing, the best I could do at the time. Five years of "just one more tweak" later, spending 20 mins fixing a single bullet point or adding a new project URL was taking more time than it should. +My resume lived in an `Apple Pages` template I hand-crafted in 2017 - custom fonts, pixel-perfect spacing, the best I could do at the time. Five years of "just one more tweak" later, spending 20 mins fixing a single bullet point or adding a new project URL was taking more time than it should. **I wanted something local, easy to edit, maintain, and version control. πŸ’­** `Google Docs`? Been there, done that, moving text-boxes is not fun. `LaTeX`? Great until you want `_italic-bold-link_` on the same line without selling your soul. `MS Word`? ...let's not. ### The When? -Over the holidays of Christmas 2025, I got my hands on Google's Project Antigravity and vibe-coded a Solar System Android XR app from scratch for my all-new Galaxy XR headset β€” in a weekend. +Over the holidays of Christmas 2025, I got my hands on Google's Project Antigravity and vibe-coded a Solar System Android XR app from scratch for my all-new Galaxy XR headset - in a weekend. As a backend engineer, I've been using AI pair-programming at work to build delightful websites. I thought: why can't I just make a `WebResume` to complement my limited front-end skills? ### The How? -A resume I actually *enjoy* updating β€” write content in `Markdown`, watch it `render live`, toggle `dark mode` for ✨vibes✨, and hit `Ctrl+P` when a recruiter inevitably asks for a `PDF`. +A resume I actually *enjoy* updating - write content in `Markdown`, watch it `render live`, toggle `dark mode` for ✨vibes✨, and hit `Ctrl+P` when a recruiter inevitably asks for a `PDF`. **No frameworks, no build step, just `HTML + CSS + JS` and a little bit of audacity.** ### Some Design Choices -- **Markdown-first content** β€” I've been living in `.md` files thanks to LLMs; it's the easiest way to add highlights, links, and formatting without drowning in HTML tags. So I made it the data layer. -- **Versioned deployments** β€” Because past-me and future-me deserve their own URLs. GitHub Pages + Actions auto-deploy `main`, `demo`, `PR previews`, and `tagged version snapshots` β€” so every iteration is a living, linkable artifact. +- **Markdown-first content** - I've been living in `.md` files thanks to LLMs; it's the easiest way to add highlights, links, and formatting without drowning in HTML tags. So I made it the data layer. +- **Versioned deployments** - Because past-me and future-me deserve their own URLs. GitHub Pages + Actions auto-deploy `main`, `demo`, `PR previews`, and `tagged version snapshots` - so every iteration is a living, linkable artifact. ### Closing Notes I hope this saves time for people who are looking for a simple, local, and easy-to-edit resume template. I hope you enjoy using this resume template as much as I enjoyed building it! ## ✨ Features -- **CSS Inspector** β€” Hover to inspect font properties +- **CSS Inspector** - Hover to inspect font properties

CSS Inspector 1 CSS Inspector 2

-- **Dark Mode** β€” Auto-detects device theme with manual toggle +- **Dark Mode** - Auto-detects device theme with manual toggle Dark Mode -- **Dynamic Content Loading** β€” Resume data from easy-to-edit Markdown files -- **Live Edit Mode** β€” All content editable in-browser -- **Section Reordering** β€” Drag & drop section titles -- **Page Preview** β€” US Letter page constraints & guides -- **Print Ready** β€” Optimized for PDF export +- **Dynamic Content Loading** - Resume data from easy-to-edit Markdown files +- **Dynamic LaTeX & PDF Export** - Instant 1-page classic LaTeX (Jake's Resume / Computer Modern) generation directly from markdown with one-click Overleaf launch and native `pdfTeX` compilation +- **Live Edit Mode** - All content editable in-browser +- **Section Reordering** - Drag & drop section titles +- **Page Preview** - US Letter page constraints & guides +- **Print Ready** - Optimized for PDF export > See [Controls & Features](docs/controls.md) for detailed usage. ## πŸš€ Quick Start ```bash -npx serve . # start local server β†’ open http://127.0.0.1:3000 +npx serve . # start local server β†’ open http://127.0.0.1:3000 +python3 scripts/generate_latex.py # generate dynamic LaTeX & 1-page PDF ``` Edit any `.md` file in `data/`, refresh to see changes. See [Data Format](docs/data-format.md) for schemas. @@ -67,10 +69,12 @@ Edit any `.md` file in `data/`, refresh to see changes. See [Data Format](docs/d ``` WebResume/ -β”œβ”€β”€ index.html # Main HTML structure +β”œβ”€β”€ index.html # Main HTML structure with LaTeX export modal β”œβ”€β”€ styles.css # Styling with dark mode support -β”œβ”€β”€ script.js # Dynamic loading & interactive features +β”œβ”€β”€ script.js # Dynamic loading, interactive features & TeX generator β”œβ”€β”€ DESIGN_SYSTEM.md # Typography & spacing style guide +β”œβ”€β”€ scripts/ +β”‚ └── generate_latex.py # Dynamic LaTeX & 1-page PDF generator (Jake's Resume template) β”œβ”€β”€ data/ # Resume content in Markdown β”‚ β”œβ”€β”€ header.md β”‚ β”œβ”€β”€ summary.md @@ -152,4 +156,4 @@ Thanks to [![Google Antigravity](https://img.shields.io/badge/Google_Antigravity ## πŸ“„ License -MIT License β€” feel free to use this template for your own resume! +MIT License - feel free to use this template for your own resume! diff --git a/data/experience.md b/data/experience.md index b02fb34..adf6045 100644 --- a/data/experience.md +++ b/data/experience.md @@ -1,22 +1,32 @@ ## Senior Software Engineer, Graphics Software Infrastructure +### [Qualcomm](https://www.qualcomm.com/snapdragon/overview) - San Diego, CA Β· (prev: Boulder, CO; Toronto, ON) +#### 2021 - Present -### [Qualcomm](https://www.qualcomm.com/snapdragon/overview) β€” San Diego, CA +##### Distributed Pre-Silicon GPU Driver Validation Platform -#### 2021 - Present +- `Designed and led` end-to-end `pre-silicon` GPU validation platform from concept to production, catching regressions and protecting pass rates on proprietary GPU driver code across `5 generations` of `Snapdragon 8 Elite Extreme` (mobile) and `X Elite Extreme` (PC) series chipsets. +- Scaled validation to `300K+ containers/week` on Windows/Linux across an on-prem `HPC` farm through a fault-tolerant runner harness, covering `Vulkan`/`OpenCL`/`OpenGLES` - `CTS`, `VKPerf`, `GFXReconstruct`, and device-capture replay with `billions of tests` in sandboxed, reproducible runtimes. +- Parallelized `1100+ days` of serial `CPU-based Adreno emulation` into a `24-hour` nightly cycle (`1100x`) with `Docker`/`Singularity` on `IBM Spectrum LSF`. +- Built `FastAPI` observability backend on `RedisJSON`/`RediSearch`, powering custom `web dashboards` with `sub-second` live queries over `2M+` records. +- Architected async `RabbitMQ` pipelines into `Postgres` and a `DuckDB` data lake, offloading raw driver logs to `Artifactory` to keep databases lean. +- Standardized `Pydantic` result schemas across `10+` Khronos suites that the `on-device benchmark` team adopted directly and extended for KPIs. + +##### Automated Regression Triage System + +- `Engineered` a cron-scheduled `CI/CD` pipeline that diffs non-passing rows across consecutive nightly logs, builds `Gerrit` commit DAGs, and dispatches multi-stage `Jenkins` bisection workflows, eliminating operational toil from nightly builds with overlapping test failures. +- Implemented stateful `multi-bisection engine` on a crash-recoverable `JSONL` state loop inspired by [`Karpathy's autoresearch`](https://github.com/karpathy/autoresearch), sharing midpoint builds across failing tests and partitioning commit ranges, isolating C culprits in `O(C log N)` runs instead of `O(K log N)`, cutting compute by `80%`. +- `Left-shifted` driver validation by compressing the failure-to-fix loop, from detection and `root-cause commit` identification to author notification, enabling `rapid fix iteration` and a sustained `99%` pass rate `week over week`. + +##### Agentic Developer Infrastructure -- `Architected and own` an in-house pre-silicon `GPU validation` `end-to-end automation framework` dispatching `500K+ jobs/week` on `LSF` on-prem, catching driver regressions on flagship `Snapdragon Adreno GPUs`. -- Parallelized `Docker`/`Singularity` containerized workloads, cutting nightly driver validation cycles from `240 days to 1 day` to support higher throughput. -- Delivered a `FastAPI` + `RedisJSON`/`RediSearch` backend with `TTL`-based expiry, providing `sub-second` access to live test metrics with `2M+ rows/month`. -- Designed `web dashboards`, on-demand `Excel` reports, and automated regression `email alerts` to `meet each stakeholder group's reporting needs`. -- Eliminated reporting bottlenecks via async `RabbitMQ` routing to `Postgres` and `Artifactory`, enabling independent scaling of each layer. -- Standardized `Pydantic` canonical schemas across `10+` Khronos test suites; adopted by the benchmarking team, `saving months of engineering effort`. -- Introduced `MCP servers` wrapping live `API` backends, making `LLM-queryable operational infra`; drove org-wide adoption of `agentic coding` (`Cline`). +- `Shipped` `MCP` server with `OAuth2` over live infra APIs plus a `LangGraph`/`ChromaDB` RAG over internal docs, making operational data LLM-queryable. +- Drove org-wide adoption of agentic coding with `Cline`, `Codex CLI`, delivering talks on MCP-based developer workflows and writing custom Skills. --- ## Data Science Developer, Applied ML -### [KORE Wireless](https://www.korewireless.com/) β€” Blue Ash, OH +### [KORE Wireless](https://www.korewireless.com/) - Blue Ash, OH #### 2020 - 2021 @@ -27,7 +37,7 @@ ## Assistant Researcher, Deep Learning -### [Marine Bio-acoustics Research Collaborative (MBARC)](https://acoustics.ucsd.edu), [SDSU](https://roch.sdsu.edu/index.php/research-overview) β€” San Diego, CA +### [Marine Bio-acoustics Research Collaborative (MBARC)](https://acoustics.ucsd.edu), [SDSU](https://roch.sdsu.edu/index.php/research-overview) - San Diego, CA #### 2019 - 2020 diff --git a/data/projects.md b/data/projects.md index 538fb9b..03eb971 100644 --- a/data/projects.md +++ b/data/projects.md @@ -1,19 +1,6 @@ -### [JobPostHighlightsExtension β€” Chrome Extension for Job-Resume Matching](https://github.com/hellosaumil/JobPostHighlightsExtension) - -##### Chrome MV3, 2-Stage Pipeline, Hybrid Refinement, Prompt API, Summarizer API, Gemini Nano, Gemini Cloud, Ollama - -- Developed `Chrome MV3` extension with `2-stage LLM pipeline` (extract β†’ score) + `hybrid refinement` pass to detect and fill missing fields. -- Engineered `multi-provider AI` architecture supporting `Gemini Cloud`, `Ollama`, and `on-device Gemini Nano` via Chrome's `Prompt API`. -- Optimized inference latency with `pre-warmed model sessions` at startup; subsequent evaluations use `clone()` for `sub-second` response times. -- Designed dual-mode UI (`sidepanel` + `pop-out window`) with dark/light themes and `Cmd+J`/`Ctrl+J` shortcut via `Chrome Commands API`. - ---- - -### [Satellite Terrain Classification β€” Distributed ML on AWS EMR](https://github.com/hellosaumil/deepsat-aws-emr-pyspark) +### [Satellite Terrain Classification - Distributed ML on AWS EMR](https://github.com/hellosaumil/deepsat-aws-emr-pyspark) ##### Distributed ML, Image Classification, PCA, Random Forests, PySpark, AWS EMR, S3 - Deployed distributed `PySpark ML` pipeline on `AWS EMR` to classify `405K satellite images` across `6 terrain categories`; achieved `92% accuracy`. -- Reduced training time by `~85%` via `PCA dimensionality reduction` while preserving classification performance on high-dimensional image features. -- Architected scalable `S3` + `Spark ML` pipelines processing `5.6 GB` at `<$0.20/run` on `m5.xlarge` clusters for cost-efficient batch inference. -- Structured `dual-mode framework` enabling seamless `local-to-cloud transition`; same codebase runs on local Spark for dev and EMR for production. +- Architected `S3` + Spark ML pipelines processing `5.6 GB` at `<$0.20/run`, cutting training time `~85%` via PCA dimensionality reduction. diff --git a/data/skills.md b/data/skills.md index 4bd27d9..55a7cbc 100644 --- a/data/skills.md +++ b/data/skills.md @@ -1,2 +1,2 @@ -- **Infrastructure:** Python, Bash, FastAPI, Pydantic, Redis, RabbitMQ, Docker, Singularity, Kubernetes (Rancher, GKE), LSF (HPC), Postgres, Jenkins -- **ML & Data:** PyTorch, TensorFlow, PySpark, Pandas, LangGraph, FastMCP, ChromaDB +- **Infrastructure:** Python, Bash/Shell, Docker, Kubernetes (Rancher, GKE - Google Cloud), IBM LSF (HPC), Git/Gerrit, HTML/CSS/JS, Jenkins CI/CD +- **Data:** FastAPI, RabbitMQ, Pydantic, Pandas, Redis, DuckDB, Parquet, PostgreSQL - **ML:** PyTorch, TensorFlow, PySpark, LangGraph, FastMCP diff --git a/data/summary.md b/data/summary.md index a2f0185..5f0bf72 100644 --- a/data/summary.md +++ b/data/summary.md @@ -1 +1,3 @@ -Senior Software Engineer specializing in `distributed infrastructure` and `applied ML systems`, with `5+ years` building test `automation frameworks`, high-performance backends, and developer tooling. At Qualcomm, `architect and own` an end-to-end GPU validation platform orchestrating containerized execution of `500K+ jobs/week` across HPC clusters with real-time reporting handling `~2M rows/month`. Background in `ML research` (GANs, anomaly detection) and `agentic infrastructure` (`MCP servers`, `LLM`-queryable APIs). +Built the large-scale infrastructure that validates Snapdragon Adreno GPU drivers before the silicon exists, cutting nightly cycles `1100x`. +Previously, deep learning research in computer vision and speech processing. +Interested in building tools for the next frontier of compute and agentic infrastructure. \ No newline at end of file diff --git a/docs/controls.md b/docs/controls.md index 3815d0e..316c295 100644 --- a/docs/controls.md +++ b/docs/controls.md @@ -10,7 +10,7 @@ The floating control panel (bottom-right) includes: | ✏️ Edit Mode | Toggle in-browser editing of all resume content | | πŸ“„ Page Preview | Toggle US Letter page constraints and guides (default: ON) | | βœ–οΈ Reset | Reload all content from Markdown files and clear edits | -| 🧊 CSS Inspector | Toggle CSS property inspection on hover (default: ON) β€” auto-disables when Edit Mode is active | +| 🧊 CSS Inspector | Toggle CSS property inspection on hover (default: ON) - auto-disables when Edit Mode is active | ## CSS Inspector diff --git a/docs/data-format.md b/docs/data-format.md index 4b2c2cf..13dceb1 100644 --- a/docs/data-format.md +++ b/docs/data-format.md @@ -20,9 +20,10 @@ One or two sentence professional summary paragraph. ## Experience & Projects (`experience.md`, `projects.md`) ```markdown ## Job Title -### [Company Name](https://example.com) β€” Location +### [Company Name](https://example.com) - Location #### Date Range ##### *Tech Stack: Python, JavaScript, AWS* +##### Subsection Title (or **Subsection Title**) - Bullet point with `code formatting` - Another bullet with **bold text** @@ -45,11 +46,11 @@ Degree: Bachelor of Technology... ## Skills (`skills.md`) ```markdown - **Programming:** Python, JavaScript, TypeScript -- **Tools & Frameworks:** React, Node.js, Docker +- **Data:** PyTorch, Pandas, PostgreSQL - **ML:** TensorFlow, LangGraph, ChromaDB ``` ## Lists (`publications.md`, `leadership.md`, `certificates.md`) ```markdown -- `[Publication Title](https://doi.org/example)` β€” Conference Name -- **Role** β€” Organization/Event Details +- `[Publication Title](https://doi.org/example)` - Conference Name +- **Role** - Organization/Event Details ``` diff --git a/index.html b/index.html index 4b9b0b4..dc2bac1 100644 --- a/index.html +++ b/index.html @@ -6,7 +6,18 @@ Saumil Shah - Resume - + + +
- - + diff --git a/script.js b/script.js index 60387d7..e2e7b40 100644 --- a/script.js +++ b/script.js @@ -31,10 +31,14 @@ document.addEventListener('DOMContentLoaded', function () { } // Helper to fetch markdown + const rawMarkdownCache = {}; + async function fetchMarkdown(filename) { const response = await fetch(`data/${filename}?cache=${new Date().getTime()}`); if (!response.ok) throw new Error(`Failed to load ${filename}`); - return await response.text(); + const text = await response.text(); + rawMarkdownCache[filename] = text; + return text; } // Helper: Parse Key-Value pairs (e.g., "Name: John") @@ -165,7 +169,7 @@ document.addEventListener('DOMContentLoaded', function () { ${edu.degree || ''}
${edu.school || ''} - ${edu.gpa ? ` β€” ${edu.gpa}` : ''} + ${edu.gpa ? ` - ${edu.gpa}` : ''} ${edu.date ? `${edu.date}` : ''}
@@ -190,6 +194,25 @@ document.addEventListener('DOMContentLoaded', function () { } items.forEach(item => { + // Check if line contains multiple skills (e.g. "**Data:** ... - **ML:** ...") + const parts = item.split(/\s+(?:[-—–]|--|---)\s+(?=\*\*[^*]+?:\*\*)/); + if (parts.length > 1) { + const subItems = parts.map(p => { + const m = p.match(/^\*\*(.*?):\*\*\s*(.*)$/); + return m ? { label: m[1], val: m[2] } : null; + }).filter(Boolean); + + if (subItems.length > 1) { + const div = document.createElement('div'); + div.className = 'skills-item'; + div.innerHTML = subItems.map((sub, idx) => + `${idx > 0 ? '  ' : ''}${sub.label}: ${parseMarkdownLinks(sub.val)}` + ).join(''); + container.appendChild(div); + return; + } + } + // Expecting format "**Label:** Value" const match = item.match(/^\*\*(.*?):\*\*\s*(.*)$/); if (match) { @@ -229,7 +252,7 @@ document.addEventListener('DOMContentLoaded', function () { const text = await fetchMarkdown('experience.md'); const sections = text.split('---').map(section => { const lines = section.trim().split('\n'); - const data = { content: [], companies: [] }; + const data = { content: [], items: [], companies: [] }; lines.forEach(line => { const trimmed = line.trim(); @@ -240,8 +263,25 @@ document.addEventListener('DOMContentLoaded', function () { else data.companies.push(val); } else if (trimmed.startsWith('#### ')) data.date = trimmed.replace('#### ', '').trim(); - else if (trimmed.startsWith('##### ')) data.tech = trimmed.replace('##### ', '').replace(/^\*(.*)\*$/, '$1').trim(); // Strip outer italics if present - else if (trimmed.startsWith('- ')) data.content.push(trimmed); + else if (trimmed.startsWith('##### ')) { + const val = trimmed.replace('##### ', '').trim(); + if (val.startsWith('*') && val.endsWith('*')) { + data.tech = val.replace(/^\*(.*)\*$/, '$1').trim(); + } else { + data.items.push({ type: 'subheading', text: val }); + } + } + else if (trimmed.startsWith('- ')) { + data.content.push(trimmed); + data.items.push({ type: 'bullet', text: trimmed.replace(/^-\s*/, '') }); + } + else if (trimmed.startsWith('###### ')) { + const subText = trimmed.replace(/^######\s*/, '').trim(); + data.items.push({ type: 'subheading', text: subText }); + } + else if (trimmed.startsWith('**') && (trimmed.endsWith('**') || trimmed.endsWith('**:'))) { + data.items.push({ type: 'subheading', text: trimmed }); + } }); return data; }); @@ -258,16 +298,43 @@ document.addEventListener('DOMContentLoaded', function () { const div = document.createElement('div'); div.className = 'experience-item'; - // Generate bullets with link parsing - const bullets = exp.content - .map(line => `
  • ${parseMarkdownLinks(line.replace(/^-\s*/, ''))}
  • `) - .join(''); - // Generate company rows const companyRows = exp.companies .map(co => `
    ${parseMarkdownLinks(co)}
    `) .join(''); + // Generate items (subheadings and bullet lists) + let itemsHtml = ''; + let inList = false; + + exp.items.forEach(item => { + if (item.type === 'subheading') { + if (inList) { + itemsHtml += ''; + inList = false; + } + itemsHtml += `
    ${parseMarkdownLinks(item.text)}
    `; + } else if (item.type === 'bullet') { + if (!inList) { + itemsHtml += ''; + } + + // Fallback if only content was populated without items + if (!itemsHtml && exp.content.length > 0) { + const bullets = exp.content + .map(line => `
  • ${parseMarkdownLinks(line.replace(/^-\s*/, ''))}
  • `) + .join(''); + itemsHtml = ``; + } + div.innerHTML = `
    ${parseMarkdownLinks(exp.title || '')} @@ -276,9 +343,7 @@ document.addEventListener('DOMContentLoaded', function () { ${companyRows} ${exp.thesis ? `
    ${parseMarkdownLinks(exp.thesis)}
    ` : ''}
    ${exp.tech || ''}
    - + ${itemsHtml} `; container.appendChild(div); }); @@ -385,6 +450,7 @@ document.addEventListener('DOMContentLoaded', function () { const themeToggleBtn = document.getElementById('themeToggle'); function updateThemeUI(isDark) { + document.documentElement.classList.toggle('dark-mode', isDark); document.body.classList.toggle('dark-mode', isDark); if (themeToggleBtn) { themeToggleBtn.classList.toggle('active', isDark); diff --git a/scripts/generate_latex.py b/scripts/generate_latex.py new file mode 100755 index 0000000..137b693 --- /dev/null +++ b/scripts/generate_latex.py @@ -0,0 +1,650 @@ +#!/usr/bin/env python3 +"""Dynamic LaTeX Resume Generator. + +Generates a modern, ATS-friendly LaTeX equivalent (resume.tex) of the +WebResume directly from markdown data in the data/ directory. +Adheres strictly to DESIGN_SYSTEM.md typography hierarchy and 1-page constraints. +""" + +import argparse +import os +import re +import shutil +import subprocess +import sys +import time +from pathlib import Path + + +def escape_latex(text: str) -> str: + """Escape LaTeX special characters while preserving intentional syntax.""" + chars = { + "\\": r"\textbackslash{}", + "&": r"\&", + "%": r"\%", + "$": r"\$", + "#": r"\#", + "_": r"\_", + "{": r"\{", + "}": r"\}", + "~": r"$\sim$", + "^": r"\textasciicircum{}", + "<": r"\textless{}", + ">": r"\textgreater{}", + } + # Escape backslash first to avoid double-escaping + pattern = re.compile(r"([\\&%$#_{}~^<>])") + return pattern.sub(lambda m: chars[m.group(1)], text) + + +def md_to_latex(text: str, underline_links: bool = True) -> str: + """Convert inline Markdown syntax (links, backticks, bold, italics) to LaTeX.""" + if not text: + return "" + + # Pre-normalize unicode dashes (safe before character escaping) + text = text.replace("β€”", "---").replace("–", "--") + + # Token storage to protect already-converted LaTeX macros from character escaping + tokens = [] + + def save_token(val: str) -> str: + idx = len(tokens) + tokens.append(val) + return f"@@TOK{idx}@@" + + # 1. Backticked links: `[text](url)` + def replace_backtick_link(match): + label, url = match.group(1), match.group(2) + escaped_label = escape_latex(label) + inner = rf"\underline{{\tech{{{escaped_label}}}}}" if underline_links else rf"\tech{{{escaped_label}}}" + return save_token(rf"\href{{{url}}}{{{inner}}}") + + text = re.sub(r"`\[([^\]]+)\]\(([^)]+)\)`", replace_backtick_link, text) + + # 2. Markdown links: [text](url) + def replace_link(match): + label, url = match.group(1), match.group(2) + escaped_label = escape_latex(label) + inner = rf"\underline{{{escaped_label}}}" if underline_links else escaped_label + return save_token(rf"\href{{{url}}}{{{inner}}}") + + text = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", replace_link, text) + + # 3. Inline code / highlights: `code` + def replace_code(match): + code_str = escape_latex(match.group(1)) + return save_token(r"\tech{" + code_str + r"}") + + text = re.sub(r"`([^`]+)`", replace_code, text) + + # 4. Bold text: **text** + def replace_bold(match): + bold_str = escape_latex(match.group(1)) + return save_token(r"\textbf{" + bold_str + r"}") + + text = re.sub(r"\*\*([^*]+)\*\*", replace_bold, text) + + # 5. Italic text: *text* + def replace_italic(match): + italic_str = escape_latex(match.group(1)) + return save_token(r"\textit{" + italic_str + r"}") + + text = re.sub(r"(? dict: + """Parse Key: Value formatted text into a dictionary.""" + data = {} + for line in content.splitlines(): + match = re.match(r"^([^:]+):\s*(.*)$", line) + if match: + data[match.group(1).strip().lower()] = match.group(2).strip() + return data + + +def parse_sections(content: str) -> list: + """Parse text blocks separated by '---' into individual dictionaries.""" + sections = [] + for raw in content.split("---"): + section_data = {} + for line in raw.strip().splitlines(): + match = re.match(r"^([^:]+):\s*(.*)$", line) + if match: + section_data[match.group(1).strip().lower()] = match.group(2).strip() + if section_data: + sections.append(section_data) + return sections + + +def parse_list(content: str) -> list: + """Parse bulleted list into lines.""" + return [ + line.strip()[2:].strip() + for line in content.splitlines() + if line.strip().startswith("- ") + ] + + +def parse_experience(content: str) -> list: + """Parse experience.md into structured entries.""" + entries = [] + raw_sections = content.split("---") + for sec in raw_sections: + lines = sec.strip().splitlines() + entry = {"companies": [], "content": [], "items": []} + for line in lines: + t = line.strip() + if t.startswith("## "): + entry["title"] = t[3:].strip() + elif t.startswith("### "): + val = t[4:].strip() + if val.startswith("Master's Thesis:"): + entry["thesis"] = val + else: + entry["companies"].append(val) + elif t.startswith("#### "): + entry["date"] = t[5:].strip() + elif t.startswith("##### "): + val = t[6:].strip() + if val.startswith("*") and val.endswith("*"): + entry["tech"] = val.strip("*") + else: + entry["items"].append({"type": "subheading", "text": val}) + elif t.startswith("- "): + entry["content"].append(t[2:].strip()) + entry["items"].append({"type": "bullet", "text": t[2:].strip()}) + elif t.startswith("###### "): + entry["items"].append({"type": "subheading", "text": t[7:].strip()}) + elif t.startswith("**") and (t.endswith("**") or t.endswith("**:")): + entry["items"].append({"type": "subheading", "text": t}) + + if entry.get("title"): + entries.append(entry) + return entries + + +def parse_projects(content: str) -> list: + """Parse projects.md into structured entries.""" + entries = [] + raw_sections = content.split("---") + for sec in raw_sections: + lines = sec.strip().splitlines() + entry = {"content": []} + for line in lines: + t = line.strip() + if t.startswith("### "): + entry["title"] = t[4:].strip() + elif t.startswith("##### "): + entry["tech"] = t[6:].strip().strip("*") + elif t.startswith("- "): + entry["content"].append(t[2:].strip()) + + if entry.get("title"): + entries.append(entry) + return entries + + +def read_file(filepath: Path) -> str: + """Safely read text from path if it exists.""" + if filepath.is_file(): + return filepath.read_text(encoding="utf-8").strip() + return "" + + +def generate_latex( + data_dir: Path, + accent_hex: str = "0066CC", + paper: str = "letter", + ats_mode: bool = False, +) -> str: + """Assemble complete LaTeX resume matching classic Jake's Resume LaTeX template.""" + clean_hex = accent_hex.lstrip("#").upper() + if ats_mode: + clean_hex = "000000" + + header_data = parse_key_value(read_file(data_dir / "header.md")) + summary_text = read_file(data_dir / "summary.md") + skills_lines = parse_list(read_file(data_dir / "skills.md")) + experience_entries = parse_experience(read_file(data_dir / "experience.md")) + project_entries = parse_projects(read_file(data_dir / "projects.md")) + education_entries = parse_sections(read_file(data_dir / "education.md")) + publications_lines = parse_list(read_file(data_dir / "publications.md")) + leadership_lines = parse_list(read_file(data_dir / "leadership.md")) + certificates_lines = parse_list(read_file(data_dir / "certificates.md")) + + firstname = header_data.get("firstname", "") + lastname = header_data.get("lastname", "") + fullname = f"{firstname} {lastname}".strip().upper() + phone = header_data.get("phone", "") + clean_phone = re.sub(r"[^\d+]", "", phone) + email = header_data.get("email", "") + location = header_data.get("location", "") + linkedin = header_data.get("linkedin", "") + linkedin_url = linkedin if linkedin.startswith("http") else f"https://{linkedin}" + website = header_data.get("website", "") + website_url = website if website.startswith("http") else f"https://{website}" + + paper_spec = "letterpaper" if paper == "letter" else "a4paper" + + lines = [] + lines.append(r"% !TEX root = resume.tex") + lines.append(r"% ============================================================================") + lines.append(r"% DYNAMICALLY GENERATED FROM data/*.md - DO NOT EDIT THIS FILE MANUALLY!") + lines.append(r"% Single Source of Truth: data/ directory") + lines.append(r"% Template: Classic Academic / Jake's Resume (Computer Modern Default LaTeX)") + lines.append(rf"% Generated at: {time.strftime('%Y-%m-%d %H:%M:%S')}") + lines.append(r"% ============================================================================") + lines.append(rf"\documentclass[{paper_spec},10pt]{{article}}") + lines.append(r"") + lines.append(r"\usepackage{latexsym}") + lines.append(r"\usepackage{titlesec}") + lines.append(r"\usepackage{xcolor}") + lines.append(r"\usepackage{enumitem}") + lines.append(r"\usepackage{hyperref}") + lines.append(r"\usepackage{fancyhdr}") + lines.append(r"\usepackage{tabularx}") + lines.append(r"\usepackage{textcomp}") + lines.append(r"\usepackage{geometry}") + lines.append(r"") + lines.append(r"% --- Geometry (Precise 1-page fit with Jake's Resume 10pt/9pt font sizes) ---") + lines.append(r"\geometry{") + lines.append(rf" {paper_spec},") + lines.append(r" top=0.22in,") + lines.append(r" bottom=0.22in,") + lines.append(r" left=0.30in,") + lines.append(r" right=0.30in") + lines.append(r"}") + lines.append(r"\pagestyle{empty}") + lines.append(r"\urlstyle{same}") + lines.append(r"\raggedbottom") + lines.append(r"\raggedright") + lines.append(r"\setlength{\tabcolsep}{0in}") + lines.append(r"\renewcommand{\arraystretch}{0.92}") + lines.append(r"") + lines.append(r"% --- Color Setup ---") + lines.append(r"\definecolor{textdark}{RGB}{0,0,0}") + lines.append(rf"\definecolor{{accentcolor}}{{HTML}}{{{clean_hex}}}") + lines.append(r"") + lines.append(r"% --- Section Formatting (True Small Caps 12pt \large matching Jake's Resume) ---") + lines.append(r"\titleformat{\section}{") + lines.append(r" \scshape\raggedright\large") + lines.append(r"}{}{0em}{}[\color{black}\titlerule]") + lines.append(r"\titlespacing*{\section}{0pt}{3pt}{1.5pt}") + lines.append(r"") + lines.append(r"% --- Custom Commands (Jake's Resume Macros) ---") + lines.append(r"\newcommand{\tech}[1]{\textbf{#1}}") + lines.append(r"\newcommand{\resumeItem}[1]{") + lines.append(r" \item\fontsize{8.5pt}{9.4pt}\selectfont{#1}") + lines.append(r"}") + lines.append(r"") + lines.append(r"\newcommand{\resumeSubheading}[4]{") + lines.append(r" \item") + lines.append(r" \begin{tabular*}{\linewidth}{@{\extracolsep{\fill}}lr}") + lines.append(r" \textbf{\textcolor{accentcolor}{#1}} & #2 \\") + lines.append(r" \textbf{\textit{#3}} & \textit{#4} \\") + lines.append(r" \end{tabular*}\vspace{-1.5pt}") + lines.append(r"}") + lines.append(r"") + lines.append(r"\newcommand{\resumeProjectHeading}[2]{") + lines.append(r" \item") + lines.append(r" \begin{tabular*}{\linewidth}{@{\extracolsep{\fill}}lr}") + lines.append(r" \textbf{#1} & \\") + lines.append(r" \textit{\small #2} & \\") + lines.append(r" \end{tabular*}\vspace{-1.5pt}") + lines.append(r"}") + lines.append(r"") + lines.append( + r"\newcommand{\resumeSubHeadingListStart}{" + r"\begin{itemize}[leftmargin=0in, label={}, " + r"topsep=1pt, itemsep=1.5pt, parsep=0pt, partopsep=0pt]}" + ) + lines.append(r"\newcommand{\resumeSubHeadingListEnd}{\end{itemize}}") + lines.append( + r"\newcommand{\resumeItemListStart}{" + r"\begin{itemize}[leftmargin=10pt, label=\small$\bullet$, " + r"topsep=1pt, itemsep=-0.8pt, parsep=0pt, partopsep=0pt]}" + ) + lines.append(r"\newcommand{\resumeItemListEnd}{\end{itemize}}") + lines.append( + r"\newcommand{\resumeSubheadingTitle}[1]{" + r"\vspace{2pt}\par\noindent\fontsize{8.5pt}{9.4pt}\selectfont{#1}\vspace{1pt}}" + ) + lines.append(r"") + lines.append(r"\begin{document}") + lines.append(r"") + + # Header + lines.append(r"% --- HEADER ---") + lines.append(r"\begin{center}") + lines.append(rf" \textbf{{\Huge {fullname}}} \\[2pt]") + header_items = [] + if location: + header_items.append(escape_latex(location)) + if phone: + header_items.append(rf"\href{{tel:{clean_phone}}}{{{escape_latex(phone)}}}") + if email: + header_items.append(rf"\href{{mailto:{email}}}{{\underline{{{escape_latex(email)}}}}}") + if linkedin: + header_items.append(rf"\href{{{linkedin_url}}}{{\underline{{{escape_latex(linkedin)}}}}}") + if website: + header_items.append(rf"\href{{{website_url}}}{{\underline{{{escape_latex(website)}}}}}") + lines.append(r" \small " + r" \textopenbullet\ ".join(header_items)) + lines.append(r"\end{center}") + lines.append(r"\hypersetup{pdfborder={0 0 0}}") + lines.append(r"\vspace{-11pt}") + lines.append(r"") + + # Summary + if summary_text: + lines.append(r"% --- SUMMARY ---") + lines.append(r"\section{Summary}") + lines.append( + r"\begin{itemize}[leftmargin=0in, label={}, " + r"topsep=1pt, itemsep=0pt, parsep=0pt, partopsep=0pt]" + ) + lines.append(rf" \item\fontsize{{8.5pt}}{{9.4pt}}\selectfont{{{md_to_latex(summary_text)}}}") + lines.append(r"\end{itemize}") + lines.append(r"") + + # Technical Skills + if skills_lines: + lines.append(r"% --- TECHNICAL SKILLS ---") + lines.append(r"\section{Technical Skills}") + lines.append(r"\resumeSubHeadingListStart") + for s_line in skills_lines: + parts = re.split(r"\s+(?:[-—–]|--|---)\s+(?=\*\*[^*]+?:\*\*)", s_line) + skill_tex = r"\quad ".join(md_to_latex(p) for p in parts) + lines.append(rf" \item\fontsize{{8.5pt}}{{9.4pt}}\selectfont{{{skill_tex}}}") + lines.append(r"\resumeSubHeadingListEnd") + lines.append(r"") + + # Experience + if experience_entries: + lines.append(r"% --- PROFESSIONAL EXPERIENCE ---") + lines.append(r"\section{Professional Experience}") + lines.append(r"\resumeSubHeadingListStart") + for exp in experience_entries: + title_tex = md_to_latex(exp.get("title", ""), underline_links=False) + date_tex = md_to_latex(exp.get("date", ""), underline_links=False) + company_raw = " --- ".join(exp.get("companies", [])) + + # Split company and location + if " β€” " in company_raw or " - " in company_raw: + parts = re.split(r" [β€”-] ", company_raw, maxsplit=1) + co_tex = md_to_latex(parts[0].strip(), underline_links=False) + loc_tex = md_to_latex(parts[1].strip(), underline_links=False) + elif "---" in company_raw: + parts = company_raw.split("---", 1) + co_tex = md_to_latex(parts[0].strip(), underline_links=False) + loc_tex = md_to_latex(parts[1].strip(), underline_links=False) + else: + co_tex = md_to_latex(company_raw, underline_links=False) + loc_tex = "" + + lines.append( + rf" \resumeSubheading{{{co_tex}}}{{{loc_tex}}}" + rf"{{{title_tex}}}{{{date_tex}}}" + ) + if exp.get("thesis"): + thesis_tex = md_to_latex(exp["thesis"]) + lines.append( + rf" \vspace{{-5pt}}\par\noindent\fontsize{{8.5pt}}{{9.4pt}}\selectfont" + rf"{{\textit{{{thesis_tex}}}}}\vspace{{-1.5pt}}" + ) + in_list = False + if exp.get("tech"): + lines.append(r" \resumeItemListStart") + lines.append(rf" \resumeItem{{\textit{{{md_to_latex(exp['tech'])}}}}}") + in_list = True + + items = exp.get("items", []) + if not items and exp.get("content"): + items = [{"type": "bullet", "text": b} for b in exp.get("content", [])] + + for item in items: + if item["type"] == "subheading": + if in_list: + lines.append(r" \resumeItemListEnd") + in_list = False + lines.append(rf" \resumeSubheadingTitle{{{md_to_latex(item['text'])}}}") + elif item["type"] == "bullet": + if not in_list: + lines.append(r" \resumeItemListStart") + in_list = True + lines.append(rf" \resumeItem{{{md_to_latex(item['text'])}}}") + + if in_list: + lines.append(r" \resumeItemListEnd") + lines.append(r"\resumeSubHeadingListEnd") + lines.append(r"") + + # Projects + if project_entries: + lines.append(r"% --- RESEARCH AND PROJECTS ---") + lines.append(r"\section{Research and Projects}") + lines.append(r"\resumeSubHeadingListStart") + for proj in project_entries: + title_tex = md_to_latex(proj.get("title", "")) + tech_tex = md_to_latex(proj.get("tech", "")) + + lines.append(rf" \resumeProjectHeading{{{title_tex}}}{{{tech_tex}}}") + if proj.get("content"): + lines.append(r" \resumeItemListStart") + for bullet in proj["content"]: + lines.append(rf" \resumeItem{{{md_to_latex(bullet)}}}") + lines.append(r" \resumeItemListEnd") + lines.append(r"\resumeSubHeadingListEnd") + lines.append(r"") + + # Education + if education_entries: + lines.append(r"% --- EDUCATION ---") + lines.append(r"\section{Education}") + lines.append(r"\resumeSubHeadingListStart") + for edu in education_entries: + deg = md_to_latex(edu.get("degree", "")) + sch = md_to_latex(edu.get("school", "")) + lines.append(r" \item") + lines.append( + rf" \begin{{tabular*}}{{\linewidth}}{{@{{\extracolsep{{\fill}}}}lr}}" + rf"\textbf{{{sch}}} & \textit{{{deg}}} \\" + rf"\end{{tabular*}}" + ) + lines.append(r"\resumeSubHeadingListEnd") + lines.append(r"") + + # Publications & Awards + if publications_lines: + lines.append(r"% --- PUBLICATIONS & AWARDS ---") + lines.append(r"\section{Publications \& Awards}") + lines.append(r"\resumeItemListStart") + for pub in publications_lines: + lines.append(rf" \resumeItem{{{md_to_latex(pub)}}}") + lines.append(r"\resumeItemListEnd") + lines.append(r"") + + # Leadership & Certificates + if leadership_lines: + lines.append(r"% --- LEADERSHIP ---") + lines.append(r"\section{Leadership}") + lines.append(r"\resumeSubHeadingListStart") + for lead in leadership_lines: + lines.append(rf" \item\fontsize{{9pt}}{{9.8pt}}\selectfont{{{md_to_latex(lead)}}}") + lines.append(r"\resumeSubHeadingListEnd") + lines.append(r"") + + if certificates_lines: + lines.append(r"% --- CERTIFICATES ---") + lines.append(r"\section{Certificates}") + lines.append(r"\resumeSubHeadingListStart") + for cert in certificates_lines: + lines.append(rf" \item\fontsize{{9pt}}{{9.8pt}}\selectfont{{{md_to_latex(cert)}}}") + lines.append(r"\resumeSubHeadingListEnd") + lines.append(r"") + + lines.append(r"\end{document}") + lines.append(r"") + + return "\n".join(lines) + + +def compile_latex(tex_path: Path) -> bool: + """Attempt compilation with local pdflatex, tectonic, xelatex, or lualatex.""" + local_pdflatex = Path.home() / ".local" / "bin" / "pdflatex" + local_tectonic = Path.home() / ".local" / "bin" / "tectonic" + engine = None + if local_pdflatex.is_file() and os.access(str(local_pdflatex), os.X_OK): + engine = str(local_pdflatex) + elif shutil.which("pdflatex"): + engine = shutil.which("pdflatex") + elif local_tectonic.is_file() and os.access(str(local_tectonic), os.X_OK): + engine = str(local_tectonic) + else: + engine = ( + shutil.which("tectonic") + or shutil.which("xelatex") + or shutil.which("lualatex") + ) + + if not engine: + print("Note: No local TeX engine (tectonic, pdflatex, xelatex) found in PATH.") + print(f"You can compile '{tex_path.name}' instantly on Overleaf or with Docker.") + return False + + cmd = [engine, "-interaction=nonstopmode", tex_path.name] + if "tectonic" in Path(engine).name: + cmd = [engine, tex_path.name] + + print(f"Compiling with {Path(engine).name}...") + res = subprocess.run(cmd, cwd=tex_path.parent, capture_output=True, text=True) + if res.returncode == 0: + pdf_path = tex_path.with_suffix(".pdf") + print(f"βœ“ Compilation successful: {pdf_path}") + return True + else: + print(f"Compilation error:\n{res.stdout[-500:]}\n{res.stderr[-500:]}") + return False + + +def watch_data_dir(data_dir: Path, output_path: Path, compile_pdf: bool = True, **kwargs): + """Watch data/ directory and regenerate resume.tex and PDF on any file modification.""" + print(f"Watching {data_dir} for changes... (Press Ctrl+C to stop)") + last_mtimes = {} + + def get_mtimes(): + mtimes = {} + for p in data_dir.glob("*.md"): + mtimes[p] = p.stat().st_mtime + return mtimes + + last_mtimes = get_mtimes() + try: + while True: + time.sleep(1.0) + current_mtimes = get_mtimes() + if current_mtimes != last_mtimes: + print(f"[{time.strftime('%H:%M:%S')}] Detected change in data/. Regenerating...") + tex_code = generate_latex(data_dir, **kwargs) + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(tex_code, encoding="utf-8") + print(f"Regenerated {output_path}") + if compile_pdf: + compile_latex(output_path) + last_mtimes = current_mtimes + except KeyboardInterrupt: + print("\nStopped watching.") + + +def main(): + parser = argparse.ArgumentParser( + description="Dynamic LaTeX Resume Generator from Markdown", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + parser.add_argument( + "--data-dir", + type=Path, + default=Path("data"), + help="Path to resume data markdown directory", + ) + parser.add_argument( + "-o", + "--output", + type=Path, + default=Path("test/exports/resume.tex"), + help="Output .tex file path (defaults to gitignored test/exports/resume.tex)", + ) + parser.add_argument( + "--accent", + type=str, + default="0066CC", + help="Hex color code for company links and highlights (e.g. 0066CC)", + ) + parser.add_argument( + "--paper", + choices=["letter", "a4"], + default="letter", + help="Target paper size", + ) + parser.add_argument( + "--ats", + action="store_true", + help="Generate pure monochrome ATS-compliant LaTeX (no colors)", + ) + parser.add_argument( + "--no-pdf", + action="store_true", + help="Skip automatic compilation to PDF", + ) + parser.add_argument( + "-w", + "--watch", + action="store_true", + help="Watch data/ directory and regenerate automatically on changes", + ) + + args = parser.parse_args() + + if not args.data_dir.exists(): + print(f"Error: Data directory '{args.data_dir}' does not exist.", file=sys.stderr) + sys.exit(1) + + tex_code = generate_latex( + data_dir=args.data_dir, + accent_hex=args.accent, + paper=args.paper, + ats_mode=args.ats, + ) + + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(tex_code, encoding="utf-8") + print(f"Generated dynamic LaTeX resume: {args.output} ({len(tex_code)} bytes)") + + compile_pdf = not args.no_pdf + if compile_pdf: + compile_latex(args.output) + + if args.watch: + watch_data_dir( + data_dir=args.data_dir, + output_path=args.output, + compile_pdf=compile_pdf, + accent_hex=args.accent, + paper=args.paper, + ats_mode=args.ats, + ) + + +if __name__ == "__main__": + main() diff --git a/styles.css b/styles.css index ef25311..0ed2127 100644 --- a/styles.css +++ b/styles.css @@ -4,6 +4,7 @@ ======================================== */ :root { + color-scheme: light; --primary-color: #1a1a1a; --accent-color: #0066cc; --text-color: #333333; @@ -31,7 +32,9 @@ --tooltip-text: #ffffff; } +html.dark-mode, body.dark-mode { + color-scheme: dark; --primary-color: #ffffff; /* Pure white for headers in dark mode */ --accent-color: #60a5fa; @@ -66,6 +69,7 @@ body.dark-mode { } body { + font-optical-sizing: none; font-family: 'Google Sans Flex', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 8pt; font-weight: 300; @@ -594,6 +598,7 @@ body.dark-mode.show-page-preview .resume-page:hover { margin-bottom: 0; } + .skill-label { color: var(--primary-color); font-weight: 500; @@ -707,6 +712,23 @@ code { margin-bottom: 2px; } +.exp-subheading { + font-weight: 500; + margin-top: 4px; + margin-bottom: 1px; + color: var(--text-color); + hyphens: none; + word-break: keep-all; +} + +.exp-subheading strong { + font-weight: 500; +} + +.exp-subheading:first-of-type { + margin-top: 3px; +} + .tech-stack-row { display: flex; justify-content: space-between; @@ -1151,14 +1173,33 @@ body.dark-mode .attribution code { display: none !important; } - /* Fix thin font rendering in PDF export β€” + /* Fix thin font rendering in PDF export - print/PDF engines lack sub-pixel antialiasing, - causing light weights (100–300) to appear thinner than on screen. */ + causing light weights (100–300) to appear thinner than on screen. + Use darker ink tokens and stepped-up print weights instead of + -webkit-text-stroke (which emits duplicate PDF text layers). */ + :root { + --text-color: #111111; + --text-light: #2b2b2b; + } + * { -webkit-print-color-adjust: exact; print-color-adjust: exact; } + #field-lastname { + font-weight: 200 !important; + } + + .contact-link, + .contact-link:link, + .contact-link:visited, + .section-title-link a, + #field-website a { + font-weight: 400 !important; + } + /* Consistent margins in print whether preview mode was toggled or not */ .resume-page { padding: var(--margin-top) var(--margin-right) var(--margin-bottom) var(--margin-left) !important; @@ -1522,6 +1563,7 @@ body.dark-mode .dev-inspector-tooltip .tooltip-row { gap: 4px; } + /* Experience and Projects */ .experience-item, .project-item { diff --git a/test/test_generate_latex.py b/test/test_generate_latex.py new file mode 100644 index 0000000..cb8077d --- /dev/null +++ b/test/test_generate_latex.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""Tests for Dynamic LaTeX Resume Generator.""" + +import unittest +from pathlib import Path +from scripts.generate_latex import ( + escape_latex, + generate_latex, + md_to_latex, + parse_experience, + parse_key_value, + parse_list, + parse_projects, + parse_sections, +) + + +class TestLatexGenerator(unittest.TestCase): + def setUp(self): + self.data_dir = Path("data") + + def test_escape_latex(self): + raw = r"100% test & $50 #1 _foo_ {bar} ^top ~home \path <$0.20 >90%" + escaped = escape_latex(raw) + self.assertIn(r"\%", escaped) + self.assertIn(r"\&", escaped) + self.assertIn(r"\$", escaped) + self.assertIn(r"\#", escaped) + self.assertIn(r"\_", escaped) + self.assertIn(r"\{", escaped) + self.assertIn(r"\}", escaped) + self.assertIn(r"\textasciicircum{}", escaped) + self.assertIn(r"$\sim$", escaped) + self.assertIn(r"\textless{}", escaped) + self.assertIn(r"\textgreater{}", escaped) + + def test_md_to_latex_formatting(self): + text = "Built `API` with **Python** and *FastAPI* for [Qualcomm](https://example.com) β€” 99% uptime." + result = md_to_latex(text) + self.assertIn(r"\tech{API}", result) + self.assertIn(r"\textbf{Python}", result) + self.assertIn(r"\textit{FastAPI}", result) + self.assertIn(r"\href{https://example.com}{\underline{Qualcomm}}", result) + self.assertIn(r"---", result) + self.assertIn(r"99\%", result) + + def test_md_to_latex_backtick_link(self): + text = "Master's Thesis: `[Learning to Detect Odontocete Whistles](https://example.com)`, Advisor: Dr. Roch" + result = md_to_latex(text) + self.assertIn( + r"\href{https://example.com}{\underline{\tech{Learning to Detect Odontocete Whistles}}}", + result, + ) + + def test_md_to_latex_arrow_and_dot(self): + text = "Pipeline: extract β†’ score Β· evaluation" + result = md_to_latex(text) + self.assertIn(r"$\to$", result) + self.assertIn(r"$\cdot$", result) + + def test_parse_key_value(self): + raw = "FirstName: Saumil\nLastName: Shah\nPhone: (858)-324-4496" + data = parse_key_value(raw) + self.assertEqual(data["firstname"], "Saumil") + self.assertEqual(data["lastname"], "Shah") + self.assertEqual(data["phone"], "(858)-324-4496") + + def test_parse_sections(self): + raw = "Degree: M.S.\nSchool: SDSU\n---\nDegree: B.Tech.\nSchool: AU" + secs = parse_sections(raw) + self.assertEqual(len(secs), 2) + self.assertEqual(secs[0]["degree"], "M.S.") + self.assertEqual(secs[1]["school"], "AU") + + def test_parse_list(self): + raw = "- Item 1\n- Item 2\n" + items = parse_list(raw) + self.assertEqual(items, ["Item 1", "Item 2"]) + + def test_parse_experience(self): + raw = ( + "## Senior Engineer\n" + "### [Company](http://co.com) β€” City, ST\n" + "#### 2021 - Present\n" + "**Core Platform**\n" + "- Did amazing things\n" + ) + entries = parse_experience(raw) + self.assertEqual(len(entries), 1) + self.assertEqual(entries[0]["title"], "Senior Engineer") + self.assertEqual(entries[0]["date"], "2021 - Present") + self.assertEqual(entries[0]["companies"], ["[Company](http://co.com) β€” City, ST"]) + self.assertEqual(entries[0]["content"], ["Did amazing things"]) + self.assertEqual(entries[0]["items"], [ + {"type": "subheading", "text": "**Core Platform**"}, + {"type": "bullet", "text": "Did amazing things"}, + ]) + + def test_parse_projects(self): + raw = ( + "### [Project 1](http://proj.com)\n" + "##### Python, Docker\n" + "- Shipped v1\n" + ) + entries = parse_projects(raw) + self.assertEqual(len(entries), 1) + self.assertEqual(entries[0]["title"], "[Project 1](http://proj.com)") + self.assertEqual(entries[0]["tech"], "Python, Docker") + self.assertEqual(entries[0]["content"], ["Shipped v1"]) + + def test_generate_latex_default(self): + tex = generate_latex(self.data_dir) + self.assertIn(r"\documentclass[letterpaper,10pt]{article}", tex) + self.assertIn("SAUMIL SHAH", tex) + self.assertIn("Qualcomm", tex) + self.assertIn("San Diego State University", tex) + self.assertIn("Satellite Terrain Classification", tex) + self.assertIn(r"\definecolor{accentcolor}{HTML}{0066CC}", tex) + self.assertIn(r"\end{document}", tex) + + def test_generate_latex_ats_mode(self): + tex = generate_latex(self.data_dir, ats_mode=True) + self.assertIn(r"\definecolor{accentcolor}{HTML}{000000}", tex) + + def test_generate_latex_a4_paper(self): + tex = generate_latex(self.data_dir, paper="a4") + self.assertIn(r"\documentclass[a4paper,10pt]{article}", tex) + + def test_braces_balance(self): + tex = generate_latex(self.data_dir) + # Verify opening and closing braces balance in non-escaped context + cleaned = tex.replace(r"\{", "").replace(r"\}", "") + open_count = cleaned.count("{") + close_count = cleaned.count("}") + self.assertEqual(open_count, close_count, "LaTeX braces are not balanced!") + + +if __name__ == "__main__": + unittest.main()