mirror of
https://github.com/prdlk/cv.git
synced 2026-08-02 17:31:41 +00:00
init(app): Setup client package for web api specific logic
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
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}
|
||||
`;
|
||||
}
|
||||
Reference in New Issue
Block a user