Files

154 lines
4.8 KiB
TypeScript

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}
`;
}