Updating my Résumé Like It's 1989
Or, Maximalist PDF generation with Nix, Make, Emacs, and LaTeX
I recently realized that I have been working at Spec for five years. This means I have a variety of “Five Years of X” posts that I'd like to write, where “Five” is shorthand for “at least five:”: Five Years of Rust, Five Years of Loving Async Rust (just for the haters), Five Years of Doing My Job with Emacs, Five Years of NixOS, and so on. In the same bout of introspection, I also realized that I hadn't updated my résumé in five years. My system for résumé writing has evolved over the years, up to the last time I had to do it in 2021, but it was pretty much what you'd expect:
- Dropbox folder
Applications, created in I think 2012 - Files in various folders like
Resume Spring 2012.odt,MPlanchard CV Spring 2013.docx, GRE scores, and so on. - It looks like I was still using Word up through 2014, and then switched
to Pages for
2016-Resume-non-science.pages, with an associated PDF, and an obviously misplaced2019-08-26-Mom-Bday-Compost-Stuff.pages(?) - The Pages → PDF process was pretty durable, remaining in place for several job hops.
- In 2021, having now been working in software for eight years, I guess I
decided it was time to get back to my academic roots and use LaTeX
(pronounced with a “k” at the end, since it's technically a chi),
with
2021-Q1-Resume.texand its associated PDF.
So, the question becomes, where to go from there? Obviously, I wanted to
get it under version control. No more 2026-q2-resume for me. No sir,
instead we will have commit hash 4bfb984.
Also, obviously, I needed a nix derivation.
How else could I be sure that my résumé build would be reproducible
locally and in CI? Oh, and speaking of CI, I obviously wanted this
website to automatically include
the most recent PDF whenever it was updated.
That's all easy enough though; straight down the fairway you might say. What would be even cooler, I thought, would be if I could dynamically choose some particular résumé “skew,” like “systems programming” or “principal engineer” or “rust monkey,” and generate a variety of different artifacts. Now that would be pod racing.
Clearly, the first thing you think of when you're wanting to dynamically generate a résumé is that you're going to need to define blocks of text that you conditionally include in the generated result. Because I exist in approximately 1990 in terms of my preferred development tools (sharks not cattle), that immediately put me in mind of Donald Knuth and the dream of literate programming, which is where you write a little book, and it incidentally spits out a program at the end. I figure, if I'm already using LaTeX, I might as well go whole-hog.
Luckily, it seems like the main environment for literate programming
is, you guessed it, emacs. Literate programming is a two-step process
of “weaving” (writing the book) and “tangling”
(pulling the code out and making source files out of it). Org-mode
comes with builtin functions for tangling
like org-babel-tangle and org-babel-tangle-file, and the docs even
provide a simple example of doing literate programming
to generate an R program. None of the docs I skimmed contained
anything about dynamic content inclusion but, I figured, emacs is
infinitely hackable, so it should be pretty easy (note: this is not
sarcastic foreshadowing, it actually was pretty easy).
In this post, I'm going to cover how it all works, so that you too can party like it's:
- Emacs: released 1976, GNU emacs released 1985
- LaTeX: TeX released 1978, LaTeX released 1986
- Make: released 1976, GNU make released 1988
- Org Mode: released 2003
- Nix: released 2003
We like to live on the bleeding edge around here.
Aside: Example Files
If you'd like to follow along even more closely, or replicate some of this locally without doing it all from scratch, I uploaded all of the files referenced here to a simple directory you can browse here.
Step Zero: Dev Environment
Either install LaTeX, emacs, and gmake or install nix and put a flake like this in a directory:
{
description = "recipe";
inputs = {
nixpkgs.url = "nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = inputs@{self, nixpkgs, flake-utils, ...}:
flake-utils.lib.eachDefaultSystem (
system:
let
pkgs = import nixpkgs { inherit system; };
in {
devShells.default = pkgs.mkShell {
buildInputs = with pkgs; [gnumake emacs-nox texliveMedium watchexec];
};
}
);
}
Either add use flake to an .envrc file and use direnv,
or run nix develop, which will drop you into a shell with all your
requisite dependencies installed.
watchexec is not required
but makes iteration more pleasant.
Step One: Org-Mode to LaTeX
So, this is the literate programming bit, and it's actually pretty easy.
Let's make a recipe for beans and rice. We'll start with a file called something
like recipe.org.
#+TITLE: Beans and Rice
# Default to ":tangle yes", which means include the source block in
# the generated code. This can always be overridden for any given block.
#+PROPERTY: header-args :tangle yes
* First things First
You can make source blocks with ~C-c C-,~, then ~s~.
LaTeX files begin with a preamble, so we're going to want to include
that. A basic one might look like this:
#+begin_src tex
% start with the "article" class, b/c there's no "recipe" class, and
% you've got to start somewhere
\documentclass[oneside, 12pt]{article}
% define the canvas
\usepackage[letterpaper, margin=1in]{geometry}
#+end_src
* The Document
So, now we can start the document. "Environments" in LaTeX start with ~\begin{name}~
and end with ~\end{name}~, so don't forget to do the end part at the end.
To help with that, it seems useful to have a "start" section here at the
beginning, to cry out for the symmetrical "end" on the other side.
** Start
#+begin_src tex
\begin{document}
#+end_src tex
** Ingredients
Rice and beans is a classic, complex dish, comprising rice and beans.
#+begin_src tex
\section*{Ingredients}
\begin{itemize}
\item Beans
\item Rice
\end{itemize}
#+end_src
** Method
When making rice and beans, it is critical that you cook the beans
and the rice.
#+begin_src tex
\section*{Method}
\begin{enumerate}
\item Cook beans
\item Cook rice
\item Add beans to rice
\end{enumerate}
#+end_src
** End
#+begin_src tex
\end{document}
#+end_src tex
Apologies for the weird syntax highlighting (I guess highlight.js
doesn't care about emacs users 😡, so I'm using ini, which looked
moderately better than plaintext).
Anyway, once you've got something like this, you can generate a LaTeX
file from it easily. In emacs, restart org-mode to be sure the header
properties were picked up, with M-x restart-org-mode. Then run
org-babel-tangle-file, which is C-c C-v C-f. Or, if you don't
want to get your hands dirty in emacs, you can run the following
from the commandline:
$ emacs --batch --eval "(require 'org)" --eval '(org-babel-tangle-file "recipe.org")'
Either way, you should get a new recipe.tex file, like:
% start with the "article" class, b/c there's no "recipe" class, and
% you've got to start somewhere
\documentclass[oneside, 12pt]{article}
% define the canvas
\usepackage[letterpaper, margin=1in]{geometry}
\begin{document}
\section*{Ingredients}
\begin{itemize}
\item Beans
\item Rice
\end{itemize}
\section*{Method}
\begin{enumerate}
\item Cook beans
\item Cook rice
\item Add beans to rice
\end{enumerate}
\end{document}
Hopefully you can see that what's happening is that all of our source
blocks are getting “tangled” into the resulting .tex file (it guesses
the extension based on the source block type, but of course you can
specify it if you need to).
Step Two: LaTeX to PDF
From there, generating a PDF should be a simple matter of:
$ pdflatex recipe.tex
Which will generate recipe.pdf and a bunch of other trash files, which
you should be sure are in your .gitignore if this is getting checked
in. You can see what it looks like here.
Step Three: Invoking Make
Write a simple Makefile (if copy/pasting, don't forget that the indentation MUST be real, organic TAB characters, not four spaces):
# If you want to keep the .tex file around when generating the PDF,
# include this line
.SECONDARY:
# Generate the PDF file from the tex file
recipe.pdf: recipe.tex
pdflatex -interaction=nonstopmode recipe.tex
# Generate the tex file from the org file
recipe.tex: recipe.org
emacs --batch --eval "(require 'org)" --eval '(org-babel-tangle-file "recipe.org")'
watch:
watchexec --watch recipe.org make recipe.pdf
.PHONY: watch
Now you can run make watch, open the PDF in your preferred PDF viewing
apparatus (e.g. the excellent emacs package pdf-tools
with auto-revert-mode enabled), and any time you make a change to
the org file, your PDF file will be summarily updated.
Step Four: Make it Dynamic
We're going to need a little bit more elisp, and it's a pain to keep
passing it in through --eval args, so let's make a little
recipe.el file. We can start with it doing exactly what we're doing
on the command line:
(require 'org)
(org-babel-tangle-file "recipe.org")
And then we can run that like:
emacs --script recipe.el
So we update our Makefile appropriately:
recipe.tex: recipe.org
emacs --script recipe.el
To dynamically include or exclude content, we're going to have to pass some kind of argument into the tangling process. One easy way to do that is with an environment variable. Let's call it “skew.” We want to have two versions of our résumé, one for real humans who want to use the recipe and the other to provide our SEO overlords with sufficient seemingly human backstory to promote our recipe in search results.
One way to do this is with an environment variable. We'll call it SKEW,
as you might expect. Once we have a skew, we will want the ability to
somehow tag each source block to indicate whether it should be included
in a given skew.
For this example, we'll support two skews, normal and fluffy. For
ease of use, we can default to including any block, unless it's marked
as fluff, in which case we only include it in the fluffy skew.
We'll use our emacs script to grab the environment variable and define
it in the context of the org-babel call. We'll also go ahead and be
fancy and define some functions to check whether a given tag is enabled
for the selected skew and error with reasonable messages if we get a weird
tag or a weird skew:
;;; -*- lexical-binding: t -*-
(require 'org)
;; Mapping of skews to tags active for that skew.
;; Pairs with symbol keys makes it a p-list.
(defconst recipe-skews '(normal (core) fluffy (core fluff)))
(defun recipe-get-skew ()
"Get the current skew, error if invalid."
(let* ((skew-str (or (getenv "SKEW") "normal"))
(skew (car (read-from-string skew-str)))) ;; converts str "normal" to symbol 'normal
(unless (plist-member recipe-skews skew)
(error (format "Invalid skew: %s" skew)))
skew))
(defun recipe-active (tag)
"Return t if TAG active or nil if not."
(let ((active-tags (plist-get recipe-skews recipe-skew)))
(if (not active-tags)
(error (format "No tags for skew: %s" recipe-skew))
(member tag active-tags))))
(defun recipe-tangle-for (tag)
"Return :tangle output for TAG, depending on whether it is active."
(if (recipe-active tag) recipe-out-file "no"))
;; The current skew
(defvar recipe-skew (recipe-get-skew))
;; Define the out file based on the current skew
(defvar recipe-out-file (format "recipe-%s.tex" recipe-skew))
;; Turn off eval confirmation, since we're using in script mode.
(let ((org-confirm-babel-evaluate nil))
(org-babel-tangle-file "recipe.org"))
All these functions and variables will now be defined in the context
of the org-mode file, but we will only use recipe-out-file and
recipe-tangle-for, and the tags.
At this point, you should still be able to generate your LaTeX file with
make recipe.org. Now comes the fun part: making the org-mode file
dynamic. Org-mode headers like :tangle yes can take arbitrary elisp,
so :tangle (identity "yes") is the same as :tangle yes. We will
use this to our advantage, defaulting to tangling to the recipe-out-file
we defined above:
#+TITLE: Beans and Rice
# By default, tangle to whatever the outfile has been determined to be
#+PROPERTY: header-args :tangle (identity recipe-out-file)
There may be a better way to do this than using the identity function,
but I'm not sure how to make it evaluate as elisp without the parentheses.
Now, if you run make recipe.tex, you should see that you actually
make recipe-normal.tex. That's expected, we wanted to adjust the output
file name based on the skew. But, it does mean we'll want to update the
Makefile. We could use different targets for each expected skew, or
we could use pattern rules!
# Keep the .tex file around when generating the PDF
.SECONDARY:
# For recipe-whatever.pdf, first make recipe-whatever.tex.
# Within the recipe, $* is the pattern bit, e.g. 'whatever' above.
recipe-%.pdf: recipe-%.tex
pdflatex -interaction=nonstopmode recipe-$*.tex
# Here, we also use a pattern rule, and we pass the pattern in to emacs
# as our expected SKEW env var.
recipe-%.tex: recipe.org
SKEW=$* emacs --script recipe.el
# Make the watch command also match on pattern
watch-%:
watchexec --watch recipe.org make recipe-$*.pdf
.PHONY: watch
Now, you should be able to run make recipe-normal.pdf and see it
pop right out! And you can run make watch-normal.pdf to automatically
update the PDF whenever you save an update the org-mode file.
Okay, so now let's tag some blocks. First, we will say that every
block is default core, unless specified otherwise:
#+TITLE: Beans and Rice
# By default, tangle to whatever the outfile has been determined to be
#+PROPERTY: header-args :tangle (recipe-tangle-for 'core)
Now, let's add some fluff (rest of file omitted):
# ...everything above still the same
** Start
#+begin_src tex
\begin{document}
#+end_src
** A Very Human Preamble
#+begin_src tex :tangle (recipe-tangle-for 'fluff)
"What a story, Mark!" I laughed, my friend having just told me
a story, harrowing but short, and strangely lacking in detail,
of a woman who, her indiscretions having been discovered by one
of her many illicit lovers, was savagely beaten by said lover.
Anyway, I didn't have to worry about anything like that. I knew
Lisa was loyal, even though she had falsely claimed that I
assaulted her. But I did not hit her, it's not true. It's bullshit.
I did not hit her. I did not.
Thoughts and doubts swirling, I sat on my steel patio chair on the
roof in the smog and started thinking about my favorite rice and beans
recipe. Boy do I love rice and beans, and I'm sure you do too.
Here's my favorite rice and beans recipe, for those days when you
just don't know what's going on.
** Ingredients
# ...rest of file still the same
#+end_src
Notice our special header argument on the source block! (recipe-tangle-for 'fluff)
hopefully reads clearly enough. If we look back at our lisp file, we
can see that this should return "no" for our normal skew, and the
out file for our fluffy skew. So, let's try to make it fluffy!
$ make recipe-fluffy.pdf
You should in the output that we have now tangled 6 source blocks,
instead of the 5 source blocks we were tangling before. And we now have
our very human story at the top of our recipe-fluffy.pdf.
Nix Build
Okay great! We now have a dynamically generated PDF, based on tagged source blocks in our org-mode file. All that's left is to package it up declaratively with Nix. Easy (again, legitimately, this part is pretty easy).
We'll add a simple default.nix file:
# The pkgs argument is normal for any package, and we add skew to pass in the skew.
{ pkgs, skew, ... }:
with pkgs;
stdenvNoCC.mkDerivation {
name = "recipe";
src = lib.fileset.toSource {
root = ./.;
# We only need these files, no sense including anything else
fileset = lib.fileset.unions [ ./Makefile ./recipe.el ./recipe.org ];
};
buildInputs = [gnumake emacs-nox texliveMedium];
phases = [ "unpackPhase" "buildPhase" "installPhase" ];
# Generate ze files
buildPhase = ''
set -euo pipefail
make recipe-${skew}.pdf
'';
# Copy ze files into the out dir
installPhase = ''
set -euo pipefail
mkdir -p $out
cp recipe-${skew}.tex $out/
cp recipe-${skew}.pdf $out/
'';
}
And call it from our flake:
{
description = "recipe";
inputs = {
nixpkgs.url = "nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = inputs@{self, nixpkgs, flake-utils, ...}:
flake-utils.lib.eachDefaultSystem (
system:
let
pkgs = import nixpkgs { inherit system; };
in {
devShells.default = pkgs.mkShell {
buildInputs = with pkgs; [gnumake emacs-nox texliveMedium watchexec];
};
packages = rec {
normal = pkgs.callPackage ./. { skew = "normal"; };
fluffy = pkgs.callPackage ./. { skew = "fluffy"; };
default = normal;
};
}
);
}
Now that's pod racing! We can now reproducibly create either our normal or or fluffy recipe:
# Default target: normal
$ nix build .#
# Explicit normal
$ nix build .#normal
# Fluffy
$ nix build .#fluffy
After any build, you will have a result symlink in your current
directory pointing to a directory in the nix store containing your
built PDF and LaTeX files.
Wrap-up
This system here is actually a bit nicer than the one I feverishly worked out in my real repository, which does not have the nice mapping of skews to tags but instead just looks at the skews directly. I might find the time to update it to use this approach.
Anyway, now you have a nice, easily extensible, dynamically generated LaTeX doc, which you can tweak to your heart's content!