resume-free.openapps.xyz

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.
Owned app · rev 3f16b2375723
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.
← All files
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
3f16b23757 · 5 hours ago
Bounded source sync
b7eb5b26b5 · 1 day ago
Bounded source sync
adae97536e · 1 day ago
Bounded source sync
3b0cf36b39 · 1 day ago