import type { ResumePlan } from "../shared/types"; import { formatRange, type Corpus } from "./db"; /** Escape dynamic text for pdflatex (inputenc utf8 + fontenc T1, Computer Modern). */ export function escapeLatex(s: string): string { let t = s.replace(/\\/g, "\u0000"); t = t.replace(/([&%$#_{}])/g, "\\$1"); t = t.replace(/~/g, "\\textasciitilde{}").replace(/\^/g, "\\textasciicircum{}"); t = t.replace(/\u0000/g, "\\textbackslash{}"); t = t .replace(/[\u2018\u2019\u02bc]/g, "'") .replace(/\u201c/g, "``") .replace(/\u201d/g, "''") .replace(/"([^"]*)"/g, "``$1''") .replace(/"/g, "''") .replace(/\u2014/g, "---") .replace(/[\u2012\u2013]/g, "--") .replace(/[\u2010\u2011\u2212]/g, "-") .replace(/\u00b7/g, "\\textperiodcentered{}") .replace(/[\u2022\u25aa\u25cf]/g, "\\textbullet{}") .replace(/\u2026/g, "\\ldots{}") .replace(/[\u00a0\u2007\u202f]/g, "~"); return t.replace(/[^\x20-\x7e]/g, ""); } function itemize(bullets: string[]): string { const items = bullets.map((b) => ` \\item \\small{${escapeLatex(b)}}`).join("\n"); return `\\begin{itemize}[leftmargin=*, nosep, topsep=2pt]\n${items}\n\\end{itemize}`; } /** * Fill the shared résumé template (same preamble and layout as ../src/*.tex) * with a tailored plan plus the role-independent corpus blocks. */ export function renderLatex(corpus: Corpus, plan: ResumePlan): string { const p = corpus.profile; const contact: string[] = []; if (p.email) contact.push(`\\href{mailto:${p.email}}{${escapeLatex(p.email)}}`); if (p.phone) contact.push(escapeLatex(p.phone)); if (p.website) contact.push(`\\href{${p.website}}{Website}`); if (p.linkedin) contact.push(`\\href{${p.linkedin}}{LinkedIn}`); if (p.github) contact.push(`\\href{${p.github}}{GitHub}`); if (p.twitter) contact.push(`\\href{${p.twitter}}{Twitter}`); const skillGroups = plan.skills .map((g) => `\\textbf{${escapeLatex(g.group)}}\\\\[1pt]\n${escapeLatex(g.items.join(", "))}\\\\[5pt]`) .join("\n"); const honors = corpus.honors .map( (h) => `\\textbf{${escapeLatex(h.title)}}\\\\[1pt]\n${escapeLatex(h.institution ?? "")} \\hfill \\textit{${h.date?.slice(0, 4) ?? ""}}\\\\[4pt]`, ) .join("\n"); const organizations = corpus.organizations .map((o) => `\\textbf{${escapeLatex(o.name)}}\\\\[1pt]\n${escapeLatex(o.title ?? "")}\\\\[4pt]`) .join("\n"); const experience = plan.experience .flatMap((sel) => { const row = corpus.experience.find((e) => e.slug === sel.slug); if (!row || sel.bullets.length === 0) return []; const dates = escapeLatex(formatRange(row.start_date, row.end_date, "month")); const where = row.location ? `${escapeLatex(row.company)} --- ${escapeLatex(row.location)}` : escapeLatex(row.company); return [ `\\textbf{${escapeLatex(row.title)}} \\hfill \\textit{${dates}}\\\\\n\\textit{${where}}\n${itemize(sel.bullets)}\n\\vspace{6pt}`, ]; }) .join("\n"); const projects = plan.projects .flatMap((sel) => { const row = corpus.projects.find((pr) => pr.slug === sel.slug); if (!row || sel.bullets.length === 0) return []; const dates = escapeLatex(formatRange(row.start_date, row.end_date, "year")); return [`\\textbf{${escapeLatex(sel.heading)}} \\hfill \\textit{${dates}}\n${itemize(sel.bullets)}\n\\vspace{4pt}`]; }) .join("\n"); const edu = corpus.education; const education = edu ? `\\textbf{B.S. in ${escapeLatex(edu.field ?? "")}} \\hfill \\textit{${escapeLatex(formatRange(edu.start_date, edu.end_date, "year"))}}\\\\\n\\textit{${escapeLatex(edu.school)}}` : ""; return `\\documentclass[11pt]{article} \\usepackage[utf8]{inputenc} \\usepackage[T1]{fontenc} \\usepackage{geometry} \\usepackage{enumitem} \\usepackage{paracol} \\usepackage[hidelinks]{hyperref} \\geometry{a4paper, margin=0.5in} \\setlength{\\parindent}{0pt} \\setlength{\\parskip}{2pt} \\columnratio{0.3} \\begin{document} {\\centering {\\Huge \\textbf{${escapeLatex(p.full_name)}}}\\\\[2pt] {\\large ${escapeLatex(plan.title)}} \\par } \\vspace{14pt} \\begin{paracol}{2} \\raggedright \\small {\\bfseries Contact}\\\\[2pt] \\rule{\\linewidth}{0.4pt}\\\\[4pt] ${contact.join("\\\\[2pt]\n")} \\vspace{8pt} {\\bfseries Skills}\\\\[2pt] \\rule{\\linewidth}{0.4pt}\\\\[4pt] ${skillGroups} \\vspace{8pt} {\\bfseries Honors}\\\\[2pt] \\rule{\\linewidth}{0.4pt}\\\\[4pt] ${honors} \\vspace{8pt} {\\bfseries Organizations}\\\\[2pt] \\rule{\\linewidth}{0.4pt}\\\\[4pt] ${organizations} \\switchcolumn \\normalsize \\raggedright {\\bfseries Experience}\\\\[2pt] \\rule{\\linewidth}{0.4pt}\\\\[4pt] ${experience} {\\bfseries Projects}\\\\[2pt] \\rule{\\linewidth}{0.4pt}\\\\[4pt] ${projects} {\\bfseries Education}\\\\[2pt] \\rule{\\linewidth}{0.4pt}\\\\[4pt] ${education} \\vspace{4pt} \\end{paracol} \\end{document} `; }