195 lines
6.6 KiB
Python
195 lines
6.6 KiB
Python
#!/usr/bin/env python3
|
|
"""Render resume.yaml -> a .tex file.
|
|
|
|
Two output flavors are supported:
|
|
--level=short (default) -> condensed single-page resume.
|
|
--level=full -> detailed resume; concatenates each list with its
|
|
`_full` sibling (e.g. bullets + bullets_full).
|
|
|
|
The LaTeX preamble (styles, macros, page layout) lives inline as PREAMBLE
|
|
below; tweak typography or colors there. Content is read from resume.yaml.
|
|
"""
|
|
import argparse
|
|
import os
|
|
import yaml
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
DATA = os.path.join(HERE, "resume.yaml")
|
|
DEFAULT_OUT = {
|
|
"short": os.path.join(HERE, "MikeEberlein_Resume.tex"),
|
|
"full": os.path.join(HERE, "MikeEberlein_Resume_Detailed.tex"),
|
|
}
|
|
|
|
PREAMBLE = r"""% Mike Eberlein — single-page resume (generated from resume.yaml).
|
|
% Build: pdflatex MikeEberlein_Resume.tex (run twice for stable layout)
|
|
% Do not edit by hand — re-run `python3 render_tex.py` after changing resume.yaml.
|
|
%
|
|
% Fonts: Roboto via the roboto LaTeX package (texlive-fonts-extra). The official
|
|
% Google Roboto font repo (with OFL license) is also bundled at ./fonts/ —
|
|
% individual weights live in ./fonts/static/, variable fonts at ./fonts/.
|
|
|
|
\documentclass[letterpaper,10pt]{extarticle}
|
|
|
|
\usepackage[T1]{fontenc}
|
|
\usepackage[utf8]{inputenc}
|
|
\usepackage[sfdefault]{roboto}
|
|
\usepackage{microtype}
|
|
\DisableLigatures{encoding = *, family = *} % keep fi/fl as two letters so PDF text search / ATS works
|
|
\usepackage[letterpaper,margin=0.5in,top=0.35in,bottom=0.3in]{geometry}
|
|
\usepackage[dvipsnames]{xcolor}
|
|
\usepackage{titlesec}
|
|
\usepackage{enumitem}
|
|
\usepackage{hyperref}
|
|
\usepackage{ragged2e}
|
|
\usepackage{parskip}
|
|
|
|
\newcommand{\RobotoLight}{\fontseries{l}\selectfont}
|
|
\newcommand{\RobotoMedium}{\fontseries{m}\selectfont}
|
|
|
|
\definecolor{accent}{HTML}{1F3864}
|
|
\definecolor{body}{HTML}{222222}
|
|
\definecolor{muted}{HTML}{555555}
|
|
\color{body}
|
|
|
|
\hypersetup{colorlinks=true, urlcolor=accent, linkcolor=accent}
|
|
|
|
\setlength{\parindent}{0pt}
|
|
\setlength{\parskip}{0pt}
|
|
\linespread{1.0}
|
|
\pagestyle{empty}
|
|
|
|
\titleformat{\section}
|
|
{\color{accent}\RobotoMedium\bfseries\large}
|
|
{}{0pt}
|
|
{\MakeUppercase}
|
|
\titlespacing*{\section}{0pt}{4pt}{0pt}
|
|
\newcommand{\sectionrule}{\vspace{-4pt}{\color{accent}\rule{\linewidth}{0.5pt}}\par\vspace{1pt}}
|
|
|
|
\newcommand{\job}[2]{%
|
|
\vspace{3pt}%
|
|
\noindent\textbf{#1}\hfill\textbf{#2}\par\vspace{0pt}%
|
|
}
|
|
\newcommand{\role}[2]{%
|
|
\vspace{1pt}%
|
|
\noindent\textit{\textcolor{muted}{#1}}\hfill\textit{\textcolor{muted}{#2}}\par%
|
|
}
|
|
\newcommand{\subrole}[2]{%
|
|
\vspace{1pt}%
|
|
\noindent\hspace{0.18in}#1\hfill\textit{\textcolor{muted}{#2}}\par%
|
|
}
|
|
|
|
\newlist{bullets}{itemize}{2}
|
|
\setlist[bullets]{leftmargin=0.22in, itemsep=0pt, topsep=0pt, parsep=0pt, label={\textbullet}}
|
|
\newlist{subbullets}{itemize}{2}
|
|
\setlist[subbullets]{leftmargin=0.42in, itemsep=0pt, topsep=0pt, parsep=0pt, label={\textbullet}, before=\color{muted}}
|
|
|
|
\newcommand{\name}[1]{%
|
|
\begin{center}%
|
|
{\color{accent}\fontseries{l}\fontsize{26}{30}\selectfont #1}%
|
|
\end{center}%
|
|
\vspace{10pt}%
|
|
}
|
|
\newcommand{\contact}[1]{%
|
|
\begin{center}\textcolor{muted}{\small #1}\end{center}%
|
|
\vspace{2pt}%
|
|
}
|
|
"""
|
|
|
|
# LaTeX special characters that need escaping in content text.
|
|
LATEX_ESCAPES = {
|
|
"&": r"\&",
|
|
"%": r"\%",
|
|
"$": r"\$",
|
|
"#": r"\#",
|
|
"_": r"\_",
|
|
"{": r"\{",
|
|
"}": r"\}",
|
|
"~": r"\textasciitilde{}",
|
|
"^": r"\textasciicircum{}",
|
|
"\\": r"\textbackslash{}",
|
|
}
|
|
|
|
def tex_escape(s: str) -> str:
|
|
out = []
|
|
for ch in s:
|
|
out.append(LATEX_ESCAPES.get(ch, ch))
|
|
return "".join(out)
|
|
|
|
def merged(item, key, level):
|
|
"""Return item[key] for short, or item[key] + item[key + '_full'] for full."""
|
|
base = item.get(key) or []
|
|
if level == "full":
|
|
base = list(base) + list(item.get(key + "_full") or [])
|
|
return base
|
|
|
|
def emit_bullets(items, env="bullets"):
|
|
lines = [f"\\begin{{{env}}}"]
|
|
for b in items:
|
|
lines.append(f" \\item {tex_escape(b)}")
|
|
lines.append(f"\\end{{{env}}}")
|
|
return "\n".join(lines)
|
|
|
|
def render(data, level="short") -> str:
|
|
out = [PREAMBLE, ""]
|
|
out.append(r"\begin{document}")
|
|
out.append("")
|
|
h = data["header"]
|
|
out.append(f"\\name{{{tex_escape(h['name'])}}}")
|
|
contact = (
|
|
f"{tex_escape(h['tagline'])} \\textbullet{{}} "
|
|
f"\\href{{mailto:{h['email']}}}{{{tex_escape(h['email'])}}}"
|
|
)
|
|
out.append(f"\\contact{{{contact}}}")
|
|
out.append("")
|
|
out.append("{\\justifying\\noindent")
|
|
out.append(tex_escape(data["summary"].strip()) + r"\par}")
|
|
out.append("")
|
|
out.append(r"\section{Experience}\sectionrule")
|
|
out.append("")
|
|
for company in merged(data, "experience", level):
|
|
out.append(f"\\job{{{tex_escape(company['company'])}}}{{{tex_escape(company['dates'])}}}")
|
|
for role in merged(company, "roles", level):
|
|
out.append(f"\\role{{{tex_escape(role['title'])}}}{{{tex_escape(role['dates'])}}}")
|
|
subroles = merged(role, "subroles", level)
|
|
if subroles:
|
|
out.append("")
|
|
for sr in subroles:
|
|
out.append(f"\\subrole{{{tex_escape(sr['title'])}}}{{{tex_escape(sr['dates'])}}}")
|
|
out.append(emit_bullets(merged(sr, "bullets", level), env="subbullets"))
|
|
out.append("")
|
|
else:
|
|
bullets = merged(role, "bullets", level)
|
|
if bullets:
|
|
out.append(emit_bullets(bullets, env="bullets"))
|
|
out.append("")
|
|
out.append(r"\section{Education}\sectionrule")
|
|
out.append("")
|
|
for ed in merged(data, "education", level):
|
|
out.append(f"\\job{{{tex_escape(ed['school'])}}}{{{tex_escape(ed['dates'])}}}")
|
|
out.append(emit_bullets(merged(ed, "bullets", level), env="bullets"))
|
|
out.append("")
|
|
out.append(r"\section{Certifications}\sectionrule")
|
|
out.append("")
|
|
out.append(" \\textbullet{} ".join(tex_escape(c) for c in merged(data, "certifications", level)))
|
|
out.append("")
|
|
out.append(r"\end{document}")
|
|
return "\n".join(out) + "\n"
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--level", choices=["short", "full"], default="short")
|
|
ap.add_argument("--output", default=None,
|
|
help="Output .tex path (default: MikeEberlein_Resume.tex / MikeEberlein_Resume_Detailed.tex)")
|
|
args = ap.parse_args()
|
|
|
|
out_path = args.output or DEFAULT_OUT[args.level]
|
|
with open(DATA, "r", encoding="utf-8") as f:
|
|
data = yaml.safe_load(f)
|
|
tex = render(data, level=args.level)
|
|
with open(out_path, "w", encoding="utf-8") as f:
|
|
f.write(tex)
|
|
print(f"wrote {out_path} (level={args.level})")
|
|
|
|
if __name__ == "__main__":
|
|
main()
|