Source
This app’s code and revision history are public. Download a copy or explore the files below.
Public
The app’s code and revision history are public.
Run it yourself
Take the whole tree and run it on your own machine. The download needs no account. Cloning needs a sign-in.
Download .zip
bounded clone 70bace54357d4b57b674108f
A copy you download is not bounded. The guarantees this app runs under - declared egress, proven invariants, writes that fail closed - live in the runtime serving this page, not in the files. What you get is auditable source you can read and run, with no runtime guarantees attached.
src/layout.ts
// Lays a resume (or its cover letter) out on pages: every line of text with its font, size and position.
// The on-screen paper (Paper.tsx) and the PDF (pdf.ts) both draw this same layout, so what you see is what
// downloads, page breaks included. Widths come from the resume fonts' own metrics (scripts/build-fonts.py).
// With reflow, the same resume is laid out as one long page that narrow, for reading on a phone.
import metrics from "./fontMetrics.json";
import {
cleanResume, colorOf, hasSection, sectionTitle, dateRange, fmtDate, degreeOf, displayUrl, hrefOf, COLORS, SECTIONS,
type Resume, type TemplateId, type PaperSize,
} from "../functions/lib/resume";
import type { Letter } from "./store";
export type FontKey = "sans" | "sansBold" | "serif";
export interface TextOp { kind: "text"; x: number; y: number; text: string; font: FontKey; size: number; color: string; width: number; tracking?: number; link?: string }
export interface RuleOp { kind: "rule"; x1: number; x2: number; y: number; width: number; color: string }
export type Op = TextOp | RuleOp;
/** y is measured from the top of the page, in points; text y is the baseline. */
export interface Layout { width: number; height: number; pages: Op[][]; missing: string[] }
/** placeholders shows where things go on an empty resume (on screen only); reflow is a page width for phones. */
export interface LayoutOptions { placeholders: boolean; reflow?: number }
export const PAGE_SIZES: Record<PaperSize, [number, number]> = { Letter: [612, 792], A4: [595.28, 841.89] };
export const TEMPLATE_NAMES: Record<TemplateId, string> = { classic: "Classic", modern: "Modern", compact: "Compact" };
/** The phone reading width: on a 390px screen, body text shows at about 13px. */
export const REFLOW_WIDTH = 280;
const FONTS = metrics as Record<FontKey, { upm: number; widths: Record<string, number> }>;
const INK = "#15171b";
const MUTED = "#5d6169";
const RULE = "#c4cad3";
const GHOST = "#b3b7be";
interface Theme {
margin: [number, number, number];
body: number;
lh: number;
small: number;
align: "left" | "center";
name: { font: FontKey; size: number };
/** accent: the job title under the name takes the resume's color. */
label: { size: number; accent: boolean };
contact: number;
headerRule: boolean;
head: { font: FontKey; size: number; upper: boolean; tracking: number; rule: boolean; before: number; after: number };
/** stacked: title, then company and place; joined: title · company, then place; inline: all on one line. */
entry: "stacked" | "joined" | "inline";
entryGap: number;
}
const THEMES: Record<TemplateId, Theme> = {
classic: {
margin: [52, 56, 50], body: 10, lh: 1.42, small: 9.5, align: "center",
name: { font: "serif", size: 26 }, label: { size: 11, accent: false }, contact: 9.5, headerRule: false,
head: { font: "serif", size: 13.5, upper: false, tracking: 0, rule: true, before: 15, after: 7 },
entry: "stacked", entryGap: 9,
},
modern: {
margin: [48, 54, 48], body: 10, lh: 1.42, small: 9.5, align: "left",
name: { font: "sansBold", size: 24 }, label: { size: 12, accent: true }, contact: 9.5, headerRule: true,
head: { font: "sansBold", size: 9, upper: true, tracking: 0.35, rule: false, before: 17, after: 6 },
entry: "joined", entryGap: 9,
},
compact: {
margin: [36, 42, 36], body: 9, lh: 1.32, small: 8.5, align: "left",
name: { font: "sansBold", size: 18 }, label: { size: 10, accent: false }, contact: 8.5, headerRule: false,
head: { font: "sansBold", size: 8.5, upper: true, tracking: 0.3, rule: true, before: 10, after: 4 },
entry: "inline", entryGap: 5,
},
};
/** Text the fonts can print: letters they lack lose their accent, or become "?" (and are reported). */
function printable(s: string, font: FontKey, missing: Set<string>): string {
const w = FONTS[font].widths;
let out = "";
for (const ch of s.normalize("NFC").replace(/[-]/g, "")) {
if (w[ch.codePointAt(0)!] != null) { out += ch; continue; }
if (/\s/.test(ch)) { out += " "; continue; }
const base = ch.normalize("NFD").replace(/[̀-ͯ]/g, "");
out += base && [...base].every((c) => w[c.codePointAt(0)!] != null) ? base : "?";
missing.add(ch);
}
return out;
}
export function measure(s: string, font: FontKey, size: number, tracking = 0): number {
const { upm, widths } = FONTS[font];
let units = 0;
let n = 0;
for (const ch of s) { units += widths[ch.codePointAt(0)!] ?? widths[63]; n++; }
return (units / upm) * size + tracking * n;
}
interface Run { text: string; font: FontKey; color: string; link?: string | null; nobreak?: boolean; sep?: boolean }
interface Tok { text: string; run: Run; w: number; kind: "word" | "space" | "sep" }
interface Block { before: number; height: number; ops: Op[]; keep?: boolean }
/** Baseline of a line of `size` text in a line box `lineHeight` tall. */
const baseline = (size: number, lineHeight: number) => (lineHeight - size) / 2 + size * 0.8;
const shift = (ops: Op[], dy: number): Op[] => ops.map((op) => ({ ...op, y: op.y + dy }));
/** The page, the theme and the building blocks both the resume and the letter are made of. */
function compose(r: Resume, { placeholders, reflow }: LayoutOptions) {
const t = THEMES[r.settings.template] ?? THEMES.classic;
const accent = COLORS[colorOf(r.settings)] ?? INK;
const [W, H] = reflow ? [reflow, Infinity] : PAGE_SIZES[r.settings.paper] ?? PAGE_SIZES.Letter;
const [top, side, bottom] = reflow ? [22, 16, 24] : t.margin;
const x0 = side;
const cw = W - 2 * side;
const missing = new Set<string>();
const blocks: Block[] = [];
const b = r.basics;
/** Breaks runs into lines no wider than max, at spaces; separators never start or end a line. */
function wrap(runs: Run[], size: number, max: number): Tok[][] {
const toks: Tok[] = [];
for (const run of runs) {
const text = printable(run.text, run.font, missing);
for (const p of run.nobreak ? [text] : text.split(/( +)/)) {
if (!p) continue;
const kind = !run.nobreak && /^ +$/.test(p) ? "space" : run.sep ? "sep" : "word";
toks.push({ text: p, run, w: measure(p, run.font, size), kind });
}
}
const out: Tok[][] = [];
let cur: Tok[] = [];
let w = 0;
const hasWord = () => cur.some((x) => x.kind === "word");
const flush = () => {
while (cur.length && cur[cur.length - 1].kind !== "word") cur.pop();
if (cur.length) out.push(cur);
cur = [];
w = 0;
};
for (let tok of toks) {
if (tok.kind !== "word" && !hasWord()) continue;
if (tok.kind === "word" && w + tok.w > max && hasWord()) flush();
// A word longer than the whole line (a long link) is cut where it stops fitting.
while (tok.kind === "word" && tok.w > max) {
const chars = [...tok.text];
let cut = chars.length - 1;
while (cut > 1 && measure(chars.slice(0, cut).join(""), tok.run.font, size) > max) cut--;
const head = chars.slice(0, cut).join("");
cur.push({ ...tok, text: head, w: measure(head, tok.run.font, size) });
flush();
const rest = chars.slice(cut).join("");
tok = { ...tok, text: rest, w: measure(rest, tok.run.font, size) };
}
cur.push(tok);
w += tok.w;
}
flush();
return out;
}
/** Text ops for wrapped lines, one line every lineHeight from y = 0: one op per run on a line. */
function place(lines: Tok[][], x: number, size: number, lineHeight: number, align: "left" | "center" = "left"): Op[] {
const ops: Op[] = [];
lines.forEach((line, n) => {
const lineW = line.reduce((a, tok) => a + tok.w, 0);
let cx = align === "center" ? x0 + (cw - lineW) / 2 : x;
const y = n * lineHeight + baseline(size, lineHeight);
for (let i = 0; i < line.length;) {
const { run } = line[i];
// Spaces at either end of a run become a gap, so no text op starts or ends with a space.
for (; i < line.length && line[i].run === run && line[i].kind === "space"; i++) cx += line[i].w;
const group: Tok[] = [];
for (; i < line.length && line[i].run === run; i++) group.push(line[i]);
const gap: Tok[] = [];
while (group.length && group[group.length - 1].kind === "space") gap.push(group.pop()!);
if (group.length) {
const text = group.map((tok) => tok.text).join("");
const width = group.reduce((a, tok) => a + tok.w, 0);
ops.push({ kind: "text", x: cx, y, text, font: run.font, size, color: run.color, width, ...(run.link ? { link: run.link } : {}) });
cx += width;
}
cx += gap.reduce((a, tok) => a + tok.w, 0);
}
});
return ops;
}
const run = (text: string, color = INK, font: FontKey = "sans", extra: Partial<Run> = {}): Run => ({ text, font, color, ...extra });
const sep = (text: string, color = MUTED): Run => ({ text, font: "sans", color, sep: true });
/** Name, title and the contact line. */
function header() {
const ops: Op[] = [];
let y = 0;
const group = (runs: Run[], size: number, lineHeight: number, gapBefore: number) => {
const lines = wrap(runs, size, cw);
if (!lines.length) return;
y += y ? gapBefore : 0;
ops.push(...shift(place(lines, x0, size, lineHeight, t.align), y));
y += lines.length * lineHeight;
};
const name = b.name || (placeholders ? "Your name" : "");
group([run(name, b.name ? INK : GHOST, t.name.font)], t.name.size, t.name.size * 1.18, 0);
group([run(b.label, t.label.accent ? accent : MUTED)], t.label.size, t.label.size * 1.35, 2);
const contact: Run[] = [];
const item = (text: string, link: string | null = null, color = MUTED) => {
if (!text) return;
if (contact.length) contact.push(sep(" · "));
contact.push(run(text, color, "sans", { nobreak: true, link }));
};
item(b.email, hrefOf(b.email));
item(b.phone);
item(b.location);
b.links.forEach((l) => item(displayUrl(l), hrefOf(l)));
if (!contact.length && placeholders) ["email@example.com", "Phone", "City"].forEach((s) => item(s, null, GHOST));
group(contact, t.contact, t.contact * 1.45, t.align === "center" ? 5 : 4);
if (t.headerRule && y) {
y += 10;
ops.push({ kind: "rule", x1: x0, x2: x0 + cw, y, width: 0.6, color: RULE });
y += 2;
}
if (y) blocks.push({ before: 0, height: y, ops });
}
function heading(text: string, color = accent) {
const h = t.head;
const s = printable(h.upper ? text.toUpperCase() : text, h.font, missing);
const lineHeight = h.size * 1.3;
const ops: Op[] = [{ kind: "text", x: x0, y: baseline(h.size, lineHeight), text: s, font: h.font, size: h.size, color, width: measure(s, h.font, h.size, h.tracking), ...(h.tracking ? { tracking: h.tracking } : {}) }];
let height = lineHeight;
if (h.rule) {
height += 2;
ops.push({ kind: "rule", x1: x0, x2: x0 + cw, y: height, width: 0.6, color: RULE });
}
blocks.push({ before: blocks.length ? h.before : 0, height: height + h.after, ops, keep: true });
}
/** keep holds every line with the next block: an entry's description stays with its first bullet. */
function paragraph(text: string, color = INK, before = 0, keep = false, size = t.body) {
const lineHeight = size * t.lh;
text.split(/\n+/).forEach((para, p) => {
wrap([run(para, color)], size, cw).forEach((line, i) => {
blocks.push({ before: i === 0 ? (p ? 3 : before) : 0, height: lineHeight, ops: place([line], x0, size, lineHeight), keep });
});
});
}
function bullets(items: string[], color = INK) {
const indent = t.body * 1.15;
const lineHeight = t.body * t.lh;
items.forEach((text, i) => {
const lines = wrap([run(text, color)], t.body, cw - indent);
if (!lines.length) return;
const ops = place(lines, x0 + indent, t.body, lineHeight);
const dot = "•";
ops.unshift({ kind: "text", x: x0 + t.body * 0.15, y: baseline(t.body, lineHeight), text: dot, font: "sans", size: t.body, color: color === GHOST ? GHOST : MUTED, width: measure(dot, "sans", t.body) });
blocks.push({ before: i === 0 ? 2 : 1.5, height: lines.length * lineHeight, ops });
});
}
/** A line of runs that wraps, like a skill group or the languages. */
function line(runs: Run[], before: number) {
const lineHeight = t.body * t.lh;
const lines = wrap(runs, t.body, cw);
if (lines.length) blocks.push({ before, height: lines.length * lineHeight, ops: place(lines, x0, t.body, lineHeight) });
}
/** An entry's opening lines: what (bold), where, place and dates, arranged by the template. */
function entry(o: { primary?: Run | null; secondary?: Run | null; place?: string; date?: string; first: boolean; keep: boolean; inline?: boolean; color?: string }) {
const primary = o.primary?.text ? o.primary : o.secondary?.text ? o.secondary : null;
const secondary = primary === o.primary && o.secondary?.text ? o.secondary : null;
const style = o.inline ? "inline" : t.entry;
const l1: Run[] = [];
const l2: Run[] = [];
const placeRun = o.place ? run(o.place, o.color ?? MUTED) : null;
if (primary) l1.push({ ...primary, font: "sansBold" });
if (style === "stacked") {
if (secondary) l2.push(secondary);
if (placeRun) l2.push(...(secondary ? [sep(" · ")] : []), placeRun);
} else if (style === "joined") {
if (secondary) l1.push(sep(" · "), secondary);
if (placeRun) l2.push(placeRun);
} else {
if (secondary) l1.push(sep(", ", INK), secondary);
if (placeRun) l1.push(sep(", ", INK), placeRun);
}
const titleSize = style === "inline" ? t.body : t.body + 0.5;
const lh1 = titleSize * t.lh;
const lh2 = t.body * t.lh;
const date = o.date ? printable(o.date, "sans", missing) : "";
const dateW = date ? measure(date, "sans", t.small) : 0;
const lines1 = wrap(l1, titleSize, cw - (date ? dateW + 14 : 0));
const ops = place(lines1, x0, titleSize, lh1);
if (date) ops.push({ kind: "text", x: x0 + cw - dateW, y: baseline(titleSize, lh1), text: date, font: "sans", size: t.small, color: o.color ?? MUTED, width: dateW });
let height = Math.max(lines1.length, date ? 1 : 0) * lh1;
const lines2 = wrap(l2, t.body, cw);
ops.push(...shift(place(lines2, x0, t.body, lh2), height));
height += lines2.length * lh2;
if (height) blocks.push({ before: o.first ? 0 : t.entryGap, height, ops, keep: o.keep });
}
/** Pages: a heading stays with its first lines, an entry's title with its first bullet. */
function finish(): Layout {
const pages: Op[][] = [[]];
const limit = H - bottom;
let y = top;
let atTop = true;
blocks.forEach((block, i) => {
let need = (atTop ? 0 : block.before) + block.height;
for (let k = i; blocks[k].keep && k + 1 < blocks.length; k++) need += blocks[k + 1].before + blocks[k + 1].height;
if (!atTop && y + need > limit) {
pages.push([]);
y = top;
atTop = true;
}
if (!atTop) y += block.before;
pages[pages.length - 1].push(...shift(block.ops, y));
y += block.height;
atTop = false;
});
if (pages.length > 1) {
pages.forEach((ops, i) => {
const text = `Page ${i + 1} of ${pages.length}`;
const width = measure(text, "sans", 8);
ops.push({ kind: "text", x: x0 + cw - width, y: H - bottom / 2 + 3, text, font: "sans", size: 8, color: MUTED, width });
});
}
return { width: W, height: reflow ? y + bottom : H, pages, missing: [...missing] };
}
return { t, b, blocks, placeholders, run, sep, header, heading, paragraph, bullets, line, entry, finish };
}
export function layout(input: Resume, opts: LayoutOptions): Layout {
const r = cleanResume(input);
const { b, placeholders, run, sep, header, heading, paragraph, bullets, line, entry, finish } = compose(r, opts);
header();
for (const key of r.settings.order.filter((k) => SECTIONS.includes(k))) {
if (!hasSection(r, key)) continue;
heading(sectionTitle(r, key));
if (key === "summary") paragraph(b.summary);
if (key === "work") r.work.forEach((w, i) => {
const body = Boolean(w.summary || w.highlights.length);
entry({ primary: run(w.position), secondary: run(w.name), place: w.location, date: dateRange(w.startDate, w.endDate), first: i === 0, keep: body });
if (w.summary) paragraph(w.summary, INK, 1, w.highlights.length > 0);
bullets(w.highlights);
});
if (key === "volunteer") r.volunteer.forEach((v, i) => {
const body = Boolean(v.summary || v.highlights.length);
entry({ primary: run(v.position), secondary: run(v.organization), date: dateRange(v.startDate, v.endDate), first: i === 0, keep: body });
if (v.summary) paragraph(v.summary, INK, 1, v.highlights.length > 0);
bullets(v.highlights);
});
if (key === "education") r.education.forEach((e, i) => {
const degree = [degreeOf(e), e.score].filter(Boolean).join(", ");
entry({ primary: run(e.institution), secondary: run(degree), place: e.location, date: dateRange(e.startDate, e.endDate), first: i === 0, keep: e.courses.length > 0 });
bullets(e.courses);
});
const groups = (list: { name: string; keywords: string }[]) => list.forEach((s, i) =>
line(s.name && s.keywords ? [run(`${s.name}: `, INK, "sansBold"), run(s.keywords)] : [run(s.name || s.keywords)], i ? 2 : 0));
if (key === "skills") groups(r.skills);
if (key === "interests") groups(r.interests);
if (key === "projects") r.projects.forEach((p, i) => {
const body = Boolean(p.description || p.highlights.length);
const url = p.url ? run(displayUrl(p.url), MUTED, "sans", { link: hrefOf(p.url) }) : null;
entry({ primary: run(p.name, INK, "sans", { link: hrefOf(p.url) }), secondary: url, date: dateRange(p.startDate, p.endDate), first: i === 0, keep: body });
if (p.description) paragraph(p.description, INK, 1, p.highlights.length > 0);
bullets(p.highlights);
});
if (key === "awards") r.awards.forEach((a, i) => {
entry({ primary: run(a.title), secondary: run(a.awarder), date: fmtDate(a.date), first: i === 0, keep: Boolean(a.summary), inline: true });
if (a.summary) paragraph(a.summary, INK, 1);
});
if (key === "publications") r.publications.forEach((p, i) => {
entry({ primary: run(p.name, INK, "sans", { link: hrefOf(p.url) }), secondary: run(p.publisher), date: fmtDate(p.releaseDate), first: i === 0, keep: Boolean(p.summary), inline: true });
if (p.summary) paragraph(p.summary, INK, 1);
});
if (key === "languages") {
const runs: Run[] = [];
r.languages.forEach((l) => {
if (runs.length) runs.push(sep(" · "));
runs.push(run(l.fluency ? `${l.language} (${l.fluency})` : l.language, INK, "sans", { nobreak: true }));
});
line(runs, 0);
}
if (key === "certificates") r.certificates.forEach((c, i) => {
entry({ primary: run(c.name, INK, "sans", { link: hrefOf(c.url) }), secondary: run(c.issuer), date: fmtDate(c.date), first: i === 0, keep: false, inline: true });
});
}
// An empty resume shows where things go, on screen only.
if (placeholders && !SECTIONS.some((k) => hasSection(r, k))) {
heading(r.settings.titles.work?.trim() || "Experience", GHOST);
entry({ primary: run("Your role", GHOST), secondary: run("Company", GHOST), place: "City", date: "2022 – Present", first: true, keep: true, color: GHOST });
bullets(["What you did, and what changed because of it", "One line per result, with numbers where you have them"], GHOST);
}
return finish();
}
/** Today, written out the way this browser writes dates ("October 19, 2026"). */
export const letterDate = () => new Date().toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" });
const SAMPLE_LETTER = "Dear Ms. Lee,\n\nWhy you want this job, in two or three sentences.\n\nOne or two results from your experience that matter for this role.\n\nKind regards,\nYour name";
/** The cover letter: the resume's header, the date, who it is for, then the letter. Blank lines start paragraphs. */
export function layoutLetter(input: Resume, letter: Letter, opts: LayoutOptions): Layout {
const r = cleanResume(input);
const { t, header, paragraph, finish } = compose(r, opts);
const size = Math.max(t.body, 10) + 0.5;
/** Each line as written, the first one `before` below what came before. */
const lines = (text: string, color: string, before: number) =>
text.split("\n").map((l) => l.trim()).filter(Boolean).forEach((l, i) => paragraph(l, color, i ? 0 : before, false, size));
header();
lines(letterDate(), MUTED, t.head.before + 8);
lines(letter.to, INK, size);
const written = letter.body.trim();
const body = written || (opts.placeholders ? SAMPLE_LETTER : "");
body.split(/\n\s*\n/).forEach((para) => lines(para, written ? INK : GHOST, size * 0.9));
return finish();
}
Change history
Quality pass: several resumes, cover letters, phone-readable shared pages
Bounded source sync
Bounded source sync
Bounded source sync