From 562090ff8bca40b4b4b4dcf2994ea4cf98df57ac Mon Sep 17 00:00:00 2001 From: Saumil Shah <6513922+hellosaumil@users.noreply.github.com> Date: Sun, 25 Oct 2026 00:56:29 -0700 Subject: [PATCH 1/4] style: improve PDF export font legibility and prevent text duplication --- index.html | 4 ++-- styles.css | 22 +++++++++++++++++++++- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/index.html b/index.html index 4b9b0b4..155438a 100644 --- a/index.html +++ b/index.html @@ -6,7 +6,7 @@ Saumil Shah - Resume - + Publications & Awards - + diff --git a/styles.css b/styles.css index ef25311..112543f 100644 --- a/styles.css +++ b/styles.css @@ -66,6 +66,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; @@ -1153,12 +1154,31 @@ body.dark-mode .attribution code { /* 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; From 0a593b13a22a8f1aa98b1a30005a14222b99380e Mon Sep 17 00:00:00 2001 From: Saumil Shah <6513922+hellosaumil@users.noreply.github.com> Date: Sun, 25 Oct 2026 18:36:25 -0700 Subject: [PATCH 2/4] feat: add dynamic LaTeX and 1-page PDF export matching Jake's template --- .gitignore | 2 + README.md | 10 +- index.html | 48 ++- script.js | 355 ++++++++++++++++++++- scripts/generate_latex.py | 616 ++++++++++++++++++++++++++++++++++++ styles.css | 174 +++++++++- test/test_generate_latex.py | 134 ++++++++ 7 files changed, 1330 insertions(+), 9 deletions(-) create mode 100755 scripts/generate_latex.py create mode 100644 test/test_generate_latex.py diff --git a/.gitignore b/.gitignore index 18262e2..eb7ef31 100644 --- a/.gitignore +++ b/.gitignore @@ -3,5 +3,7 @@ .claude .idea .venv/ +__pycache__/ +*.pyc test/exports/ test/out/ \ No newline at end of file diff --git a/README.md b/README.md index 956c92f..563c901 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ I hope this saves time for people who are looking for a simple, local, and easy- Dark Mode - **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 @@ -58,7 +59,8 @@ I hope this saves time for people who are looking for a simple, local, and easy- ## 🚀 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 diff --git a/index.html b/index.html index 155438a..d16fbc1 100644 --- a/index.html +++ b/index.html @@ -6,7 +6,7 @@ Saumil Shah - Resume - + + + @@ -161,8 +165,48 @@

Publications & Awards

+ + + + - + diff --git a/script.js b/script.js index 60387d7..656557c 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") @@ -1010,4 +1014,353 @@ document.addEventListener('DOMContentLoaded', function () { // Load saved order after all content is loaded setTimeout(() => loadSectionOrder(), 100); + + // ======================================== + // Dynamic LaTeX Export & Overleaf Integration + // ======================================== + function initLatexExport() { + const latexBtn = document.getElementById('latexExportBtn'); + const modal = document.getElementById('latexModal'); + const closeBtn = document.getElementById('closeLatexModal'); + const copyBtn = document.getElementById('copyLatexBtn'); + const downloadBtn = document.getElementById('downloadLatexBtn'); + const overleafBtn = document.getElementById('openOverleafBtn'); + const display = document.getElementById('latexCodeDisplay'); + const overleafForm = document.getElementById('overleafForm'); + const overleafSnip = document.getElementById('overleafSnipInput'); + + if (!latexBtn || !modal) return; + + function escapeLatex(text) { + if (!text) return ''; + const chars = { + '\\': '\\textbackslash{}', + '&': '\\&', + '%': '\\%', + '$': '\\$', + '#': '\\#', + '_': '\\_', + '{': '\\{', + '}': '\\}', + '~': '$\\sim$', + '^': '\\textasciicircum{}', + '<': '\\textless{}', + '>': '\\textgreater{}' + }; + return text.replace(/([\\&%$#_{}~^<>])/g, m => chars[m] || m); + } + + function mdToLatex(text, underlineLinks = true) { + if (!text) return ''; + let str = text.replace(/—/g, '---').replace(/–/g, '--'); + const tokens = []; + const saveToken = (val) => { + const id = tokens.length; + tokens.push(val); + return `@@TOK${id}@@`; + }; + + str = str.replace(/`\[([^\]]+)\]\(([^)]+)\)`/g, (_, label, url) => { + const esc = escapeLatex(label); + const inner = underlineLinks ? `\\underline{\\tech{${esc}}}` : `\\tech{${esc}}`; + return saveToken(`\\href{${url}}{${inner}}`); + }); + str = str.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, url) => { + const esc = escapeLatex(label); + const inner = underlineLinks ? `\\underline{${esc}}` : esc; + return saveToken(`\\href{${url}}{${inner}}`); + }); + str = str.replace(/`([^`]+)`/g, (_, code) => saveToken(`\\tech{${escapeLatex(code)}}`)); + str = str.replace(/\*\*([^*]+)\*\*/g, (_, b) => saveToken(`\\textbf{${escapeLatex(b)}}`)); + str = str.replace(/(? saveToken(`\\textit{${escapeLatex(it)}}`)); + + str = escapeLatex(str); + str = str.replace(/→/g, '$\\to$').replace(/·/g, '$\\cdot$'); + + tokens.forEach((tok, i) => { + str = str.replace(new RegExp(`@@TOK${i}@@`, 'g'), tok); + }); + return str; + } + + function generateLatexCode() { + const headerData = parseKeyValue(rawMarkdownCache['header.md'] || ''); + const summaryText = (rawMarkdownCache['summary.md'] || '').trim(); + const skillsLines = parseList(rawMarkdownCache['skills.md'] || ''); + const educationEntries = parseSections(rawMarkdownCache['education.md'] || ''); + const publicationsLines = parseList(rawMarkdownCache['publications.md'] || ''); + + const fname = headerData['firstname'] || 'Saumil'; + const lname = headerData['lastname'] || 'Shah'; + const fullname = `${fname} ${lname}`.trim().toUpperCase(); + const phone = headerData['phone'] || ''; + const cleanPhone = phone.replace(/[^\d+]/g, ''); + const email = headerData['email'] || ''; + const loc = headerData['location'] || ''; + const linkedin = headerData['linkedin'] || ''; + const liUrl = linkedin.startsWith('http') ? linkedin : `https://${linkedin}`; + const website = headerData['website'] || ''; + const webUrl = website.startsWith('http') ? website : `https://${website}`; + + const headerItems = []; + if (loc) headerItems.push(escapeLatex(loc)); + if (phone) headerItems.push(`\\href{tel:${cleanPhone}}{${escapeLatex(phone)}}`); + if (email) headerItems.push(`\\href{mailto:${email}}{\\underline{${escapeLatex(email)}}}`); + if (linkedin) headerItems.push(`\\href{${liUrl}}{\\underline{${escapeLatex(linkedin)}}}`); + if (website) headerItems.push(`\\href{${webUrl}}{\\underline{${escapeLatex(website)}}}`); + + let out = `% !TEX root = resume.tex +% ============================================================================ +% DYNAMICALLY GENERATED FROM WebResume data/*.md +% Single Source of Truth: data/ directory +% Template: Classic Academic / Jake's Resume (Computer Modern Default LaTeX) +% ============================================================================ +\\documentclass[letterpaper,10pt]{article} + +\\usepackage{latexsym} +\\usepackage{titlesec} +\\usepackage{xcolor} +\\usepackage{enumitem} +\\usepackage{hyperref} +\\usepackage{fancyhdr} +\\usepackage{tabularx} +\\usepackage{textcomp} +\\usepackage{geometry} + +% --- Geometry (Precise 1-page fit with Jake's Resume 10pt/9pt font sizes) --- +\\geometry{ + letterpaper, + top=0.22in, + bottom=0.22in, + left=0.30in, + right=0.30in +} +\\pagestyle{empty} +\\urlstyle{same} +\\raggedbottom +\\raggedright +\\setlength{\\tabcolsep}{0in} +\\renewcommand{\\arraystretch}{0.92} + +% --- Color Setup --- +\\definecolor{textdark}{RGB}{0,0,0} +\\definecolor{accentcolor}{HTML}{0066CC} + +% --- Section Formatting (True Small Caps 12pt \\large matching Jake's Resume) --- +\\titleformat{\\section}{ + \\scshape\\raggedright\\large +}{}{0em}{}[\\color{black}\\titlerule] +\\titlespacing*{\\section}{0pt}{3pt}{1.5pt} + +% --- Custom Commands (Jake's Resume Macros) --- +\\newcommand{\\tech}[1]{\\textbf{#1}} +\\newcommand{\\resumeItem}[1]{ + \\item\\fontsize{8.5pt}{9.4pt}\\selectfont{#1} +} + +\\newcommand{\\resumeSubheading}[4]{ + \\item + \\begin{tabular*}{\\linewidth}{@{\\extracolsep{\\fill}}lr} + \\textbf{\\textcolor{accentcolor}{#1}} & #2 \\\\ + \\textbf{\\textit{#3}} & \\textit{#4} \\\\ + \\end{tabular*}\\vspace{-1.5pt} +} + +\\newcommand{\\resumeProjectHeading}[2]{ + \\item + \\begin{tabular*}{\\linewidth}{@{\\extracolsep{\\fill}}lr} + \\textbf{#1} & \\\\ + \\textit{\\small #2} & \\\\ + \\end{tabular*}\\vspace{-1.5pt} +} + +\\newcommand{\\resumeSubHeadingListStart}{\\begin{itemize}[leftmargin=0in, label={}, topsep=1pt, itemsep=1.5pt, parsep=0pt, partopsep=0pt]} +\\newcommand{\\resumeSubHeadingListEnd}{\\end{itemize}} +\\newcommand{\\resumeItemListStart}{\\begin{itemize}[leftmargin=10pt, label=\\small$\\bullet$, topsep=1pt, itemsep=-0.8pt, parsep=0pt, partopsep=0pt]} +\\newcommand{\\resumeItemListEnd}{\\end{itemize}} + +\\begin{document} + +% --- HEADER --- +\\begin{center} + \\textbf{\\Huge ${fullname}} \\\\[2pt] + \\small ${headerItems.join(' \\textopenbullet\\ ')} +\\end{center} +\\hypersetup{pdfborder={0 0 0}} +\\vspace{-11pt} + +`; + + if (summaryText) { + out += `% --- SUMMARY --- +\\section{Summary} +\\begin{itemize}[leftmargin=0in, label={}, topsep=1pt, itemsep=0pt, parsep=0pt, partopsep=0pt] + \\item\\fontsize{8.5pt}{9.4pt}\\selectfont{${mdToLatex(summaryText)}} +\\end{itemize} + +`; + } + + if (skillsLines.length) { + out += `% --- TECHNICAL SKILLS --- +\\section{Technical Skills} +\\resumeSubHeadingListStart +`; + skillsLines.forEach(line => { + out += ` \\item\\fontsize{8.5pt}{9.4pt}\\selectfont{${mdToLatex(line)}}\n`; + }); + out += `\\resumeSubHeadingListEnd\n\n`; + } + + // Experience + const expText = rawMarkdownCache['experience.md'] || ''; + if (expText.trim()) { + out += `% --- PROFESSIONAL EXPERIENCE --- +\\section{Professional Experience} +\\resumeSubHeadingListStart +`; + expText.split('---').forEach(sec => { + const lines = sec.trim().split('\n'); + let title = '', date = '', thesis = '', tech = ''; + const companies = []; + const bullets = []; + + lines.forEach(l => { + const trimmed = l.trim(); + if (trimmed.startsWith('## ')) title = trimmed.replace('## ', '').trim(); + else if (trimmed.startsWith('### ')) { + const val = trimmed.replace('### ', '').trim(); + if (val.startsWith("Master's Thesis:")) thesis = val; + else companies.push(val); + } + else if (trimmed.startsWith('#### ')) date = trimmed.replace('#### ', '').trim(); + else if (trimmed.startsWith('##### ')) tech = trimmed.replace('##### ', '').replace(/^\*(.*)\*$/, '$1').trim(); + else if (trimmed.startsWith('- ')) bullets.push(trimmed.replace(/^-\s*/, '').trim()); + }); + + if (!title) return; + const companyRaw = companies.join(' --- '); + let coTex = '', locTex = ''; + if (companyRaw.includes(' — ')) { + const p = companyRaw.split(' — '); + coTex = mdToLatex(p[0].trim(), false); + locTex = mdToLatex(p[1].trim(), false); + } else { + coTex = mdToLatex(companyRaw, false); + } + + out += ` \\resumeSubheading{${coTex}}{${locTex}}{${mdToLatex(title, false)}}{${mdToLatex(date, false)}}\n`; + if (thesis) { + out += ` \\vspace{-5pt}\\par\\noindent\\fontsize{8.5pt}{9.4pt}\\selectfont{\\textit{${mdToLatex(thesis)}}}\\vspace{-1.5pt}\n`; + } + if (tech || bullets.length) { + out += ` \\resumeItemListStart\n`; + if (tech) out += ` \\resumeItem{\\textit{${mdToLatex(tech)}}}\n`; + bullets.forEach(b => out += ` \\resumeItem{${mdToLatex(b)}}\n`); + out += ` \\resumeItemListEnd\n`; + } + }); + out += `\\resumeSubHeadingListEnd\n\n`; + } + + // Projects + const projText = rawMarkdownCache['projects.md'] || ''; + if (projText.trim()) { + out += `% --- RESEARCH AND PROJECTS --- +\\section{Research and Projects} +\\resumeSubHeadingListStart +`; + projText.split('---').forEach(sec => { + const lines = sec.trim().split('\n'); + let title = '', tech = ''; + const bullets = []; + lines.forEach(l => { + const trimmed = l.trim(); + if (trimmed.startsWith('### ')) title = trimmed.replace('### ', '').trim(); + else if (trimmed.startsWith('##### ')) tech = trimmed.replace('##### ', '').replace(/^\*(.*)\*$/, '$1').trim(); + else if (trimmed.startsWith('- ')) bullets.push(trimmed.replace(/^-\s*/, '').trim()); + }); + if (!title) return; + out += ` \\resumeProjectHeading{${mdToLatex(title)}}{${mdToLatex(tech)}}\n`; + if (bullets.length) { + out += ` \\resumeItemListStart\n`; + bullets.forEach(b => out += ` \\resumeItem{${mdToLatex(b)}}\n`); + out += ` \\resumeItemListEnd\n`; + } + }); + out += `\\resumeSubHeadingListEnd\n\n`; + } + + // Education + if (educationEntries.length) { + out += `% --- EDUCATION --- +\\section{Education} +\\resumeSubHeadingListStart +`; + educationEntries.forEach(edu => { + out += ` \\item\n \\begin{tabular*}{\\linewidth}{@{\\extracolsep{\\fill}}lr}\\textbf{${mdToLatex(edu.school || '')}} & \\textit{${mdToLatex(edu.degree || '')}} \\\\\\end{tabular*}\n`; + }); + out += `\\resumeSubHeadingListEnd\n\n`; + } + + // Publications & Awards + if (publicationsLines.length) { + out += `% --- PUBLICATIONS & AWARDS --- +\\section{Publications \\& Awards} +\\resumeItemListStart +`; + publicationsLines.forEach(pub => { + out += ` \\resumeItem{${mdToLatex(pub)}}\n`; + }); + out += `\\resumeItemListEnd\n\n`; + } + + out += `\\end{document}\n`; + return out; + } + + latexBtn.addEventListener('click', () => { + const code = generateLatexCode(); + display.textContent = code; + modal.style.display = 'flex'; + }); + + closeBtn.addEventListener('click', () => { + modal.style.display = 'none'; + }); + + modal.addEventListener('click', (e) => { + if (e.target === modal) modal.style.display = 'none'; + }); + + copyBtn.addEventListener('click', () => { + navigator.clipboard.writeText(display.textContent).then(() => { + copyBtn.classList.add('copied'); + copyBtn.querySelector('span').textContent = 'Copied!'; + setTimeout(() => { + copyBtn.classList.remove('copied'); + copyBtn.querySelector('span').textContent = 'Copy'; + }, 2000); + }); + }); + + downloadBtn.addEventListener('click', () => { + const blob = new Blob([display.textContent], { type: 'text/x-tex;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'resume.tex'; + a.click(); + URL.revokeObjectURL(url); + }); + + overleafBtn.addEventListener('click', () => { + if (overleafSnip && overleafForm) { + overleafSnip.value = display.textContent; + overleafForm.submit(); + } + }); + } + + initLatexExport(); }); diff --git a/scripts/generate_latex.py b/scripts/generate_latex.py new file mode 100755 index 0000000..112afbb --- /dev/null +++ b/scripts/generate_latex.py @@ -0,0 +1,616 @@ +#!/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": []} + 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("##### "): + 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 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"") + 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: + lines.append(rf" \item\fontsize{{8.5pt}}{{9.4pt}}\selectfont{{{md_to_latex(s_line)}}}") + 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: + 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) + 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"): + lines.append( + rf" \vspace{{-5pt}}\par\noindent\fontsize{{8.5pt}}{{9.4pt}}\selectfont{{\textit{{{md_to_latex(exp['thesis'])}}}}}\vspace{{-1.5pt}}" + ) + if exp.get("tech") or exp.get("content"): + lines.append(r" \resumeItemListStart") + if exp.get("tech"): + lines.append(rf" \resumeItem{{\textit{{{md_to_latex(exp['tech'])}}}}}") + for bullet in exp.get("content", []): + lines.append(rf" \resumeItem{{{md_to_latex(bullet)}}}") + 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 112543f..6e03d97 100644 --- a/styles.css +++ b/styles.css @@ -181,7 +181,8 @@ body.show-page-preview { .toggle-layout-btn, .reset-btn, .theme-toggle-btn, -.dev-toggle-btn { +.dev-toggle-btn, +.latex-btn { display: flex; align-items: center; justify-content: center; @@ -199,18 +200,28 @@ body.show-page-preview { .toggle-layout-btn:hover, .reset-btn:hover, .theme-toggle-btn:hover, -.dev-toggle-btn:hover { +.dev-toggle-btn:hover, +.latex-btn:hover { background: var(--control-hover-bg); color: var(--primary-color); } .toggle-layout-btn.active, .theme-toggle-btn.active, -.dev-toggle-btn.active { +.dev-toggle-btn.active, +.latex-btn.active { background: var(--control-active-bg); color: var(--accent-color); } +.latex-icon-text { + font-family: 'Times New Roman', 'Latin Modern Roman', 'Computer Modern', Georgia, serif; + font-weight: 700; + font-size: 13.5px; + letter-spacing: -0.5px; + line-height: 1; +} + .toggle-layout-btn svg path, .reset-btn svg path, .theme-toggle-btn svg path, @@ -1726,4 +1737,161 @@ h2.section-title { font-size: 8pt !important; font-weight: 300 !important; color: var(--text-light) !important; +} + +/* ======================================== + Dynamic LaTeX Export Modal + ======================================== */ +.latex-modal-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); + z-index: 20000; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + animation: fadeIn 0.2s ease-out; +} + +.latex-modal { + background: var(--white); + color: var(--text-color); + border: 1px solid var(--border-color); + border-radius: 12px; + width: 100%; + max-width: 860px; + max-height: 85vh; + display: flex; + flex-direction: column; + box-shadow: 0 16px 40px rgba(0, 0, 0, 0.25); + overflow: hidden; +} + +.latex-modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 14px 20px; + border-bottom: 1px solid var(--border-color); + background: var(--control-bg); +} + +.latex-modal-title { + display: flex; + align-items: center; + gap: 10px; + font-size: 14pt; + font-weight: 600; +} + +.latex-title-tex { + font-family: 'Times New Roman', serif; + font-weight: 700; + letter-spacing: -1px; +} + +.latex-title-tex .sub { + position: relative; + top: 3px; + font-size: 0.8em; +} + +.latex-badge-live { + font-size: 9pt; + font-weight: 500; + background: #10b981; + color: #ffffff; + padding: 2px 8px; + border-radius: 10px; +} + +.latex-close-btn { + background: transparent; + border: none; + font-size: 22pt; + line-height: 1; + cursor: pointer; + color: var(--text-light); + padding: 0 4px; +} + +.latex-close-btn:hover { + color: var(--primary-color); +} + +.latex-modal-toolbar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 20px; + border-bottom: 1px solid var(--border-color); + background: var(--control-hover-bg); + flex-wrap: wrap; + gap: 10px; +} + +.latex-meta-info { + font-size: 9pt; + color: var(--text-light); +} + +.latex-actions { + display: flex; + gap: 8px; +} + +.latex-action-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 14px; + border-radius: 6px; + font-size: 9pt; + font-weight: 500; + cursor: pointer; + border: 1px solid var(--border-color); + background: var(--white); + color: var(--text-color); + transition: all 0.15s ease; +} + +.latex-action-btn:hover { + background: var(--control-hover-bg); + border-color: var(--primary-color); +} + +.latex-action-btn.copied { + background: #10b981; + color: #ffffff; + border-color: #10b981; +} + +.latex-overleaf-btn { + background: #4f46e5; + color: #ffffff; + border-color: #4338ca; +} + +.latex-overleaf-btn:hover { + background: #4338ca; + color: #ffffff; +} + +.latex-code-container { + padding: 16px 20px; + overflow: auto; + flex: 1; + background: #1e1e1e; +} + +.latex-code-container pre { + margin: 0; + font-family: 'Space Grotesk', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 8.5pt; + line-height: 1.45; + color: #d4d4d4; + white-space: pre; } \ No newline at end of file diff --git a/test/test_generate_latex.py b/test/test_generate_latex.py new file mode 100644 index 0000000..45f70a9 --- /dev/null +++ b/test/test_generate_latex.py @@ -0,0 +1,134 @@ +#!/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" + "- 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"]) + + 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("JobPostHighlightsExtension", 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() From 18dae884401e589ac73762c483d9ae8becfcf07f Mon Sep 17 00:00:00 2001 From: Saumil Shah <6513922+hellosaumil@users.noreply.github.com> Date: Mon, 26 Oct 2026 23:41:36 -0700 Subject: [PATCH 3/4] fix: support experience subsections, prevent theme flash, and cleanup web latex UI --- docs/data-format.md | 1 + index.html | 65 ++---- script.js | 414 +++++------------------------------- scripts/generate_latex.py | 48 ++++- styles.css | 194 ++--------------- test/test_generate_latex.py | 7 +- 6 files changed, 141 insertions(+), 588 deletions(-) diff --git a/docs/data-format.md b/docs/data-format.md index 4b2c2cf..15d4e27 100644 --- a/docs/data-format.md +++ b/docs/data-format.md @@ -23,6 +23,7 @@ One or two sentence professional summary paragraph. ### [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** diff --git a/index.html b/index.html index d16fbc1..3276ced 100644 --- a/index.html +++ b/index.html @@ -6,7 +6,18 @@ Saumil Shah - Resume - + + +
- -
@@ -165,48 +177,7 @@

Publications & Awards

- - - - - - + diff --git a/script.js b/script.js index 656557c..c909159 100644 --- a/script.js +++ b/script.js @@ -233,7 +233,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(); @@ -244,8 +244,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; }); @@ -262,16 +279,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 || '')} @@ -280,9 +324,7 @@ document.addEventListener('DOMContentLoaded', function () { ${companyRows} ${exp.thesis ? `
    ${parseMarkdownLinks(exp.thesis)}
    ` : ''}
    ${exp.tech || ''}
    - + ${itemsHtml} `; container.appendChild(div); }); @@ -389,6 +431,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); @@ -1014,353 +1057,4 @@ document.addEventListener('DOMContentLoaded', function () { // Load saved order after all content is loaded setTimeout(() => loadSectionOrder(), 100); - - // ======================================== - // Dynamic LaTeX Export & Overleaf Integration - // ======================================== - function initLatexExport() { - const latexBtn = document.getElementById('latexExportBtn'); - const modal = document.getElementById('latexModal'); - const closeBtn = document.getElementById('closeLatexModal'); - const copyBtn = document.getElementById('copyLatexBtn'); - const downloadBtn = document.getElementById('downloadLatexBtn'); - const overleafBtn = document.getElementById('openOverleafBtn'); - const display = document.getElementById('latexCodeDisplay'); - const overleafForm = document.getElementById('overleafForm'); - const overleafSnip = document.getElementById('overleafSnipInput'); - - if (!latexBtn || !modal) return; - - function escapeLatex(text) { - if (!text) return ''; - const chars = { - '\\': '\\textbackslash{}', - '&': '\\&', - '%': '\\%', - '$': '\\$', - '#': '\\#', - '_': '\\_', - '{': '\\{', - '}': '\\}', - '~': '$\\sim$', - '^': '\\textasciicircum{}', - '<': '\\textless{}', - '>': '\\textgreater{}' - }; - return text.replace(/([\\&%$#_{}~^<>])/g, m => chars[m] || m); - } - - function mdToLatex(text, underlineLinks = true) { - if (!text) return ''; - let str = text.replace(/—/g, '---').replace(/–/g, '--'); - const tokens = []; - const saveToken = (val) => { - const id = tokens.length; - tokens.push(val); - return `@@TOK${id}@@`; - }; - - str = str.replace(/`\[([^\]]+)\]\(([^)]+)\)`/g, (_, label, url) => { - const esc = escapeLatex(label); - const inner = underlineLinks ? `\\underline{\\tech{${esc}}}` : `\\tech{${esc}}`; - return saveToken(`\\href{${url}}{${inner}}`); - }); - str = str.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, url) => { - const esc = escapeLatex(label); - const inner = underlineLinks ? `\\underline{${esc}}` : esc; - return saveToken(`\\href{${url}}{${inner}}`); - }); - str = str.replace(/`([^`]+)`/g, (_, code) => saveToken(`\\tech{${escapeLatex(code)}}`)); - str = str.replace(/\*\*([^*]+)\*\*/g, (_, b) => saveToken(`\\textbf{${escapeLatex(b)}}`)); - str = str.replace(/(? saveToken(`\\textit{${escapeLatex(it)}}`)); - - str = escapeLatex(str); - str = str.replace(/→/g, '$\\to$').replace(/·/g, '$\\cdot$'); - - tokens.forEach((tok, i) => { - str = str.replace(new RegExp(`@@TOK${i}@@`, 'g'), tok); - }); - return str; - } - - function generateLatexCode() { - const headerData = parseKeyValue(rawMarkdownCache['header.md'] || ''); - const summaryText = (rawMarkdownCache['summary.md'] || '').trim(); - const skillsLines = parseList(rawMarkdownCache['skills.md'] || ''); - const educationEntries = parseSections(rawMarkdownCache['education.md'] || ''); - const publicationsLines = parseList(rawMarkdownCache['publications.md'] || ''); - - const fname = headerData['firstname'] || 'Saumil'; - const lname = headerData['lastname'] || 'Shah'; - const fullname = `${fname} ${lname}`.trim().toUpperCase(); - const phone = headerData['phone'] || ''; - const cleanPhone = phone.replace(/[^\d+]/g, ''); - const email = headerData['email'] || ''; - const loc = headerData['location'] || ''; - const linkedin = headerData['linkedin'] || ''; - const liUrl = linkedin.startsWith('http') ? linkedin : `https://${linkedin}`; - const website = headerData['website'] || ''; - const webUrl = website.startsWith('http') ? website : `https://${website}`; - - const headerItems = []; - if (loc) headerItems.push(escapeLatex(loc)); - if (phone) headerItems.push(`\\href{tel:${cleanPhone}}{${escapeLatex(phone)}}`); - if (email) headerItems.push(`\\href{mailto:${email}}{\\underline{${escapeLatex(email)}}}`); - if (linkedin) headerItems.push(`\\href{${liUrl}}{\\underline{${escapeLatex(linkedin)}}}`); - if (website) headerItems.push(`\\href{${webUrl}}{\\underline{${escapeLatex(website)}}}`); - - let out = `% !TEX root = resume.tex -% ============================================================================ -% DYNAMICALLY GENERATED FROM WebResume data/*.md -% Single Source of Truth: data/ directory -% Template: Classic Academic / Jake's Resume (Computer Modern Default LaTeX) -% ============================================================================ -\\documentclass[letterpaper,10pt]{article} - -\\usepackage{latexsym} -\\usepackage{titlesec} -\\usepackage{xcolor} -\\usepackage{enumitem} -\\usepackage{hyperref} -\\usepackage{fancyhdr} -\\usepackage{tabularx} -\\usepackage{textcomp} -\\usepackage{geometry} - -% --- Geometry (Precise 1-page fit with Jake's Resume 10pt/9pt font sizes) --- -\\geometry{ - letterpaper, - top=0.22in, - bottom=0.22in, - left=0.30in, - right=0.30in -} -\\pagestyle{empty} -\\urlstyle{same} -\\raggedbottom -\\raggedright -\\setlength{\\tabcolsep}{0in} -\\renewcommand{\\arraystretch}{0.92} - -% --- Color Setup --- -\\definecolor{textdark}{RGB}{0,0,0} -\\definecolor{accentcolor}{HTML}{0066CC} - -% --- Section Formatting (True Small Caps 12pt \\large matching Jake's Resume) --- -\\titleformat{\\section}{ - \\scshape\\raggedright\\large -}{}{0em}{}[\\color{black}\\titlerule] -\\titlespacing*{\\section}{0pt}{3pt}{1.5pt} - -% --- Custom Commands (Jake's Resume Macros) --- -\\newcommand{\\tech}[1]{\\textbf{#1}} -\\newcommand{\\resumeItem}[1]{ - \\item\\fontsize{8.5pt}{9.4pt}\\selectfont{#1} -} - -\\newcommand{\\resumeSubheading}[4]{ - \\item - \\begin{tabular*}{\\linewidth}{@{\\extracolsep{\\fill}}lr} - \\textbf{\\textcolor{accentcolor}{#1}} & #2 \\\\ - \\textbf{\\textit{#3}} & \\textit{#4} \\\\ - \\end{tabular*}\\vspace{-1.5pt} -} - -\\newcommand{\\resumeProjectHeading}[2]{ - \\item - \\begin{tabular*}{\\linewidth}{@{\\extracolsep{\\fill}}lr} - \\textbf{#1} & \\\\ - \\textit{\\small #2} & \\\\ - \\end{tabular*}\\vspace{-1.5pt} -} - -\\newcommand{\\resumeSubHeadingListStart}{\\begin{itemize}[leftmargin=0in, label={}, topsep=1pt, itemsep=1.5pt, parsep=0pt, partopsep=0pt]} -\\newcommand{\\resumeSubHeadingListEnd}{\\end{itemize}} -\\newcommand{\\resumeItemListStart}{\\begin{itemize}[leftmargin=10pt, label=\\small$\\bullet$, topsep=1pt, itemsep=-0.8pt, parsep=0pt, partopsep=0pt]} -\\newcommand{\\resumeItemListEnd}{\\end{itemize}} - -\\begin{document} - -% --- HEADER --- -\\begin{center} - \\textbf{\\Huge ${fullname}} \\\\[2pt] - \\small ${headerItems.join(' \\textopenbullet\\ ')} -\\end{center} -\\hypersetup{pdfborder={0 0 0}} -\\vspace{-11pt} - -`; - - if (summaryText) { - out += `% --- SUMMARY --- -\\section{Summary} -\\begin{itemize}[leftmargin=0in, label={}, topsep=1pt, itemsep=0pt, parsep=0pt, partopsep=0pt] - \\item\\fontsize{8.5pt}{9.4pt}\\selectfont{${mdToLatex(summaryText)}} -\\end{itemize} - -`; - } - - if (skillsLines.length) { - out += `% --- TECHNICAL SKILLS --- -\\section{Technical Skills} -\\resumeSubHeadingListStart -`; - skillsLines.forEach(line => { - out += ` \\item\\fontsize{8.5pt}{9.4pt}\\selectfont{${mdToLatex(line)}}\n`; - }); - out += `\\resumeSubHeadingListEnd\n\n`; - } - - // Experience - const expText = rawMarkdownCache['experience.md'] || ''; - if (expText.trim()) { - out += `% --- PROFESSIONAL EXPERIENCE --- -\\section{Professional Experience} -\\resumeSubHeadingListStart -`; - expText.split('---').forEach(sec => { - const lines = sec.trim().split('\n'); - let title = '', date = '', thesis = '', tech = ''; - const companies = []; - const bullets = []; - - lines.forEach(l => { - const trimmed = l.trim(); - if (trimmed.startsWith('## ')) title = trimmed.replace('## ', '').trim(); - else if (trimmed.startsWith('### ')) { - const val = trimmed.replace('### ', '').trim(); - if (val.startsWith("Master's Thesis:")) thesis = val; - else companies.push(val); - } - else if (trimmed.startsWith('#### ')) date = trimmed.replace('#### ', '').trim(); - else if (trimmed.startsWith('##### ')) tech = trimmed.replace('##### ', '').replace(/^\*(.*)\*$/, '$1').trim(); - else if (trimmed.startsWith('- ')) bullets.push(trimmed.replace(/^-\s*/, '').trim()); - }); - - if (!title) return; - const companyRaw = companies.join(' --- '); - let coTex = '', locTex = ''; - if (companyRaw.includes(' — ')) { - const p = companyRaw.split(' — '); - coTex = mdToLatex(p[0].trim(), false); - locTex = mdToLatex(p[1].trim(), false); - } else { - coTex = mdToLatex(companyRaw, false); - } - - out += ` \\resumeSubheading{${coTex}}{${locTex}}{${mdToLatex(title, false)}}{${mdToLatex(date, false)}}\n`; - if (thesis) { - out += ` \\vspace{-5pt}\\par\\noindent\\fontsize{8.5pt}{9.4pt}\\selectfont{\\textit{${mdToLatex(thesis)}}}\\vspace{-1.5pt}\n`; - } - if (tech || bullets.length) { - out += ` \\resumeItemListStart\n`; - if (tech) out += ` \\resumeItem{\\textit{${mdToLatex(tech)}}}\n`; - bullets.forEach(b => out += ` \\resumeItem{${mdToLatex(b)}}\n`); - out += ` \\resumeItemListEnd\n`; - } - }); - out += `\\resumeSubHeadingListEnd\n\n`; - } - - // Projects - const projText = rawMarkdownCache['projects.md'] || ''; - if (projText.trim()) { - out += `% --- RESEARCH AND PROJECTS --- -\\section{Research and Projects} -\\resumeSubHeadingListStart -`; - projText.split('---').forEach(sec => { - const lines = sec.trim().split('\n'); - let title = '', tech = ''; - const bullets = []; - lines.forEach(l => { - const trimmed = l.trim(); - if (trimmed.startsWith('### ')) title = trimmed.replace('### ', '').trim(); - else if (trimmed.startsWith('##### ')) tech = trimmed.replace('##### ', '').replace(/^\*(.*)\*$/, '$1').trim(); - else if (trimmed.startsWith('- ')) bullets.push(trimmed.replace(/^-\s*/, '').trim()); - }); - if (!title) return; - out += ` \\resumeProjectHeading{${mdToLatex(title)}}{${mdToLatex(tech)}}\n`; - if (bullets.length) { - out += ` \\resumeItemListStart\n`; - bullets.forEach(b => out += ` \\resumeItem{${mdToLatex(b)}}\n`); - out += ` \\resumeItemListEnd\n`; - } - }); - out += `\\resumeSubHeadingListEnd\n\n`; - } - - // Education - if (educationEntries.length) { - out += `% --- EDUCATION --- -\\section{Education} -\\resumeSubHeadingListStart -`; - educationEntries.forEach(edu => { - out += ` \\item\n \\begin{tabular*}{\\linewidth}{@{\\extracolsep{\\fill}}lr}\\textbf{${mdToLatex(edu.school || '')}} & \\textit{${mdToLatex(edu.degree || '')}} \\\\\\end{tabular*}\n`; - }); - out += `\\resumeSubHeadingListEnd\n\n`; - } - - // Publications & Awards - if (publicationsLines.length) { - out += `% --- PUBLICATIONS & AWARDS --- -\\section{Publications \\& Awards} -\\resumeItemListStart -`; - publicationsLines.forEach(pub => { - out += ` \\resumeItem{${mdToLatex(pub)}}\n`; - }); - out += `\\resumeItemListEnd\n\n`; - } - - out += `\\end{document}\n`; - return out; - } - - latexBtn.addEventListener('click', () => { - const code = generateLatexCode(); - display.textContent = code; - modal.style.display = 'flex'; - }); - - closeBtn.addEventListener('click', () => { - modal.style.display = 'none'; - }); - - modal.addEventListener('click', (e) => { - if (e.target === modal) modal.style.display = 'none'; - }); - - copyBtn.addEventListener('click', () => { - navigator.clipboard.writeText(display.textContent).then(() => { - copyBtn.classList.add('copied'); - copyBtn.querySelector('span').textContent = 'Copied!'; - setTimeout(() => { - copyBtn.classList.remove('copied'); - copyBtn.querySelector('span').textContent = 'Copy'; - }, 2000); - }); - }); - - downloadBtn.addEventListener('click', () => { - const blob = new Blob([display.textContent], { type: 'text/x-tex;charset=utf-8' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = 'resume.tex'; - a.click(); - URL.revokeObjectURL(url); - }); - - overleafBtn.addEventListener('click', () => { - if (overleafSnip && overleafForm) { - overleafSnip.value = display.textContent; - overleafForm.submit(); - } - }); - } - - initLatexExport(); }); diff --git a/scripts/generate_latex.py b/scripts/generate_latex.py index 112afbb..537a5a1 100755 --- a/scripts/generate_latex.py +++ b/scripts/generate_latex.py @@ -98,8 +98,8 @@ def replace_italic(match): # 7. Convert math symbols and restore tokens text = text.replace("→", r"$\to$").replace("·", r"$\cdot$") - for i, tok in enumerate(tokens): - text = text.replace(f"@@TOK{i}@@", tok) + for i in reversed(range(len(tokens))): + text = text.replace(f"@@TOK{i}@@", tokens[i]) return text @@ -143,7 +143,7 @@ def parse_experience(content: str) -> list: raw_sections = content.split("---") for sec in raw_sections: lines = sec.strip().splitlines() - entry = {"companies": [], "content": []} + entry = {"companies": [], "content": [], "items": []} for line in lines: t = line.strip() if t.startswith("## "): @@ -157,9 +157,18 @@ def parse_experience(content: str) -> list: elif t.startswith("#### "): entry["date"] = t[5:].strip() elif t.startswith("##### "): - entry["tech"] = t[6:].strip().strip("*") + 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) @@ -308,6 +317,10 @@ def generate_latex( 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"") @@ -386,12 +399,29 @@ def generate_latex( lines.append( rf" \vspace{{-5pt}}\par\noindent\fontsize{{8.5pt}}{{9.4pt}}\selectfont{{\textit{{{md_to_latex(exp['thesis'])}}}}}\vspace{{-1.5pt}}" ) - if exp.get("tech") or exp.get("content"): + in_list = False + if exp.get("tech"): lines.append(r" \resumeItemListStart") - if exp.get("tech"): - lines.append(rf" \resumeItem{{\textit{{{md_to_latex(exp['tech'])}}}}}") - for bullet in exp.get("content", []): - lines.append(rf" \resumeItem{{{md_to_latex(bullet)}}}") + 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"") diff --git a/styles.css b/styles.css index 6e03d97..7d69448 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; @@ -181,8 +184,7 @@ body.show-page-preview { .toggle-layout-btn, .reset-btn, .theme-toggle-btn, -.dev-toggle-btn, -.latex-btn { +.dev-toggle-btn { display: flex; align-items: center; justify-content: center; @@ -200,28 +202,18 @@ body.show-page-preview { .toggle-layout-btn:hover, .reset-btn:hover, .theme-toggle-btn:hover, -.dev-toggle-btn:hover, -.latex-btn:hover { +.dev-toggle-btn:hover { background: var(--control-hover-bg); color: var(--primary-color); } .toggle-layout-btn.active, .theme-toggle-btn.active, -.dev-toggle-btn.active, -.latex-btn.active { +.dev-toggle-btn.active { background: var(--control-active-bg); color: var(--accent-color); } -.latex-icon-text { - font-family: 'Times New Roman', 'Latin Modern Roman', 'Computer Modern', Georgia, serif; - font-weight: 700; - font-size: 13.5px; - letter-spacing: -0.5px; - line-height: 1; -} - .toggle-layout-btn svg path, .reset-btn svg path, .theme-toggle-btn svg path, @@ -719,6 +711,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; @@ -1737,161 +1746,4 @@ h2.section-title { font-size: 8pt !important; font-weight: 300 !important; color: var(--text-light) !important; -} - -/* ======================================== - Dynamic LaTeX Export Modal - ======================================== */ -.latex-modal-backdrop { - position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.6); - backdrop-filter: blur(4px); - -webkit-backdrop-filter: blur(4px); - z-index: 20000; - display: flex; - align-items: center; - justify-content: center; - padding: 20px; - animation: fadeIn 0.2s ease-out; -} - -.latex-modal { - background: var(--white); - color: var(--text-color); - border: 1px solid var(--border-color); - border-radius: 12px; - width: 100%; - max-width: 860px; - max-height: 85vh; - display: flex; - flex-direction: column; - box-shadow: 0 16px 40px rgba(0, 0, 0, 0.25); - overflow: hidden; -} - -.latex-modal-header { - display: flex; - justify-content: space-between; - align-items: center; - padding: 14px 20px; - border-bottom: 1px solid var(--border-color); - background: var(--control-bg); -} - -.latex-modal-title { - display: flex; - align-items: center; - gap: 10px; - font-size: 14pt; - font-weight: 600; -} - -.latex-title-tex { - font-family: 'Times New Roman', serif; - font-weight: 700; - letter-spacing: -1px; -} - -.latex-title-tex .sub { - position: relative; - top: 3px; - font-size: 0.8em; -} - -.latex-badge-live { - font-size: 9pt; - font-weight: 500; - background: #10b981; - color: #ffffff; - padding: 2px 8px; - border-radius: 10px; -} - -.latex-close-btn { - background: transparent; - border: none; - font-size: 22pt; - line-height: 1; - cursor: pointer; - color: var(--text-light); - padding: 0 4px; -} - -.latex-close-btn:hover { - color: var(--primary-color); -} - -.latex-modal-toolbar { - display: flex; - justify-content: space-between; - align-items: center; - padding: 10px 20px; - border-bottom: 1px solid var(--border-color); - background: var(--control-hover-bg); - flex-wrap: wrap; - gap: 10px; -} - -.latex-meta-info { - font-size: 9pt; - color: var(--text-light); -} - -.latex-actions { - display: flex; - gap: 8px; -} - -.latex-action-btn { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 6px 14px; - border-radius: 6px; - font-size: 9pt; - font-weight: 500; - cursor: pointer; - border: 1px solid var(--border-color); - background: var(--white); - color: var(--text-color); - transition: all 0.15s ease; -} - -.latex-action-btn:hover { - background: var(--control-hover-bg); - border-color: var(--primary-color); -} - -.latex-action-btn.copied { - background: #10b981; - color: #ffffff; - border-color: #10b981; -} - -.latex-overleaf-btn { - background: #4f46e5; - color: #ffffff; - border-color: #4338ca; -} - -.latex-overleaf-btn:hover { - background: #4338ca; - color: #ffffff; -} - -.latex-code-container { - padding: 16px 20px; - overflow: auto; - flex: 1; - background: #1e1e1e; -} - -.latex-code-container pre { - margin: 0; - font-family: 'Space Grotesk', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; - font-size: 8.5pt; - line-height: 1.45; - color: #d4d4d4; - white-space: pre; } \ No newline at end of file diff --git a/test/test_generate_latex.py b/test/test_generate_latex.py index 45f70a9..cb8077d 100644 --- a/test/test_generate_latex.py +++ b/test/test_generate_latex.py @@ -82,6 +82,7 @@ def test_parse_experience(self): "## Senior Engineer\n" "### [Company](http://co.com) — City, ST\n" "#### 2021 - Present\n" + "**Core Platform**\n" "- Did amazing things\n" ) entries = parse_experience(raw) @@ -90,6 +91,10 @@ def test_parse_experience(self): 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 = ( @@ -109,7 +114,7 @@ def test_generate_latex_default(self): self.assertIn("SAUMIL SHAH", tex) self.assertIn("Qualcomm", tex) self.assertIn("San Diego State University", tex) - self.assertIn("JobPostHighlightsExtension", tex) + self.assertIn("Satellite Terrain Classification", tex) self.assertIn(r"\definecolor{accentcolor}{HTML}{0066CC}", tex) self.assertIn(r"\end{document}", tex) From 4ca162eaa4d657a606df197ee44a82e872d3d14a Mon Sep 17 00:00:00 2001 From: Saumil Shah <6513922+hellosaumil@users.noreply.github.com> Date: Fri, 30 Oct 2026 01:47:06 -0700 Subject: [PATCH 4/4] feat: refine resume content, format skills split row, and pass flake8 checks --- .gitignore | 3 ++- README.md | 30 +++++++++++++++--------------- data/experience.md | 32 +++++++++++++++++++++----------- data/projects.md | 17 ++--------------- data/skills.md | 4 ++-- data/summary.md | 4 +++- docs/controls.md | 2 +- docs/data-format.md | 8 ++++---- index.html | 4 ++-- script.js | 21 ++++++++++++++++++++- scripts/generate_latex.py | 12 ++++++++---- styles.css | 4 +++- 12 files changed, 83 insertions(+), 58 deletions(-) diff --git a/.gitignore b/.gitignore index eb7ef31..ffe7d41 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ __pycache__/ *.pyc test/exports/ -test/out/ \ No newline at end of file +test/out/ +*_raw diff --git a/README.md b/README.md index 563c901..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,42 +17,42 @@ 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 -- **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 +- **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. @@ -156,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 15d4e27..13dceb1 100644 --- a/docs/data-format.md +++ b/docs/data-format.md @@ -20,7 +20,7 @@ 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**) @@ -46,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 3276ced..dc2bac1 100644 --- a/index.html +++ b/index.html @@ -17,7 +17,7 @@ } catch (e) {} })(); - + Publications & Awards
    - + diff --git a/script.js b/script.js index c909159..e2e7b40 100644 --- a/script.js +++ b/script.js @@ -169,7 +169,7 @@ document.addEventListener('DOMContentLoaded', function () { ${edu.degree || ''}
    ${edu.school || ''} - ${edu.gpa ? ` — ${edu.gpa}` : ''} + ${edu.gpa ? ` - ${edu.gpa}` : ''} ${edu.date ? `${edu.date}` : ''}
    @@ -194,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) { diff --git a/scripts/generate_latex.py b/scripts/generate_latex.py index 537a5a1..137b693 100755 --- a/scripts/generate_latex.py +++ b/scripts/generate_latex.py @@ -364,7 +364,9 @@ def generate_latex( lines.append(r"\section{Technical Skills}") lines.append(r"\resumeSubHeadingListStart") for s_line in skills_lines: - lines.append(rf" \item\fontsize{{8.5pt}}{{9.4pt}}\selectfont{{{md_to_latex(s_line)}}}") + 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"") @@ -379,8 +381,8 @@ def generate_latex( company_raw = " --- ".join(exp.get("companies", [])) # Split company and location - if " — " in company_raw: - parts = company_raw.split(" — ", 1) + 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: @@ -396,8 +398,10 @@ def generate_latex( 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{{\textit{{{md_to_latex(exp['thesis'])}}}}}\vspace{{-1.5pt}}" + 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"): diff --git a/styles.css b/styles.css index 7d69448..0ed2127 100644 --- a/styles.css +++ b/styles.css @@ -598,6 +598,7 @@ body.dark-mode.show-page-preview .resume-page:hover { margin-bottom: 0; } + .skill-label { color: var(--primary-color); font-weight: 500; @@ -1172,7 +1173,7 @@ 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. Use darker ink tokens and stepped-up print weights instead of @@ -1562,6 +1563,7 @@ body.dark-mode .dev-inspector-tooltip .tooltip-row { gap: 4px; } + /* Experience and Projects */ .experience-item, .project-item {