Files
com.gregkilmer.www/services/site/src/lib/resume-pdf.ts
T
Claude 0d0b8e4085 Resume PDF on the Greg Kilmer letterhead
Each page of the PDF is laid over the brand letterhead with pdf-lib, both
kept as vectors. The letterhead carries the name, email, website and city, so
the resume's own header becomes the title, availability, profiles and date.
Type and two earlier-role lines are tightened so it stays on one page.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-11 22:36:19 +00:00

280 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* The printable resume, set on the Greg Kilmer letterhead. Built from the same
* ResumeData as the /resume page,
* laid out top to bottom (skills share a row with education and projects) so
* applicant tracking systems read it in order.
* Paper colors: ink on white, with the brand gold and teal darkened for print.
*/
import PDFDocument from "pdfkit";
import { PDFDocument as PdfLib } from "pdf-lib";
import letterheadPdf from "../assets/letterhead/Greg-Kilmer-Letterhead.pdf?inline";
import cinzelBold from "../assets/pdf-fonts/Cinzel-Bold.ttf?inline";
import cinzelSemi from "../assets/pdf-fonts/Cinzel-SemiBold.ttf?inline";
import montReg from "../assets/pdf-fonts/Montserrat-Regular.ttf?inline";
import montMed from "../assets/pdf-fonts/Montserrat-Medium.ttf?inline";
import montSemi from "../assets/pdf-fonts/Montserrat-SemiBold.ttf?inline";
import { listOf, month, period, type ResumeData } from "./resume.ts";
const fromDataUrl = (u: string) => Buffer.from(u.slice(u.indexOf(",") + 1), "base64");
const FONTS = {
title: fromDataUrl(cinzelBold),
heading: fromDataUrl(cinzelSemi),
body: fromDataUrl(montReg),
medium: fromDataUrl(montMed),
strong: fromDataUrl(montSemi),
};
const INK = "#131c2b";
const MUTED = "#566173";
const GOLD = "#a8842f";
const TEAL = "#0f7f78";
const RULE = "#d9cfb8";
/**
* The writing area inside the Greg Kilmer letterhead (branding/letterhead), in
* points: below its header rule and above its footer rule, between its margins.
*/
const PAGE = { size: "LETTER" as const, left: 54, right: 54, top: 112, bottom: 70 };
const TEXT = 8.6;
/** A column of the page: where text starts and how wide it runs. */
interface Col {
x: number;
w: number;
}
function renderBody(r: ResumeData): Promise<Buffer> {
const doc = new PDFDocument({
size: PAGE.size,
margins: { top: PAGE.top, bottom: PAGE.bottom, left: PAGE.left, right: PAGE.right },
bufferPages: true,
info: { Title: `${r.name} – Resume`, Author: r.name, Subject: r.headline, Keywords: "resume" },
lang: "en-US",
displayTitle: true,
});
for (const [name, data] of Object.entries(FONTS)) doc.registerFont(name, data);
const full: Col = { x: doc.page.margins.left, w: doc.page.width - doc.page.margins.left - doc.page.margins.right };
const chunks: Buffer[] = [];
doc.on("data", (c: Buffer) => chunks.push(c));
const done = new Promise<Buffer>((resolve, reject) => {
doc.on("end", () => resolve(Buffer.concat(chunks)));
doc.on("error", reject);
});
/** Starts a new page when fewer than `need` points remain. */
const room = (need: number) => {
if (doc.y + need > doc.page.height - doc.page.margins.bottom) doc.addPage();
};
const diamond = (x: number, y: number, s: number, color = GOLD) => {
doc.save().moveTo(x, y - s).lineTo(x + s, y).lineTo(x, y + s).lineTo(x - s, y).closePath().fill(color).restore();
};
const section = (title: string, c: Col = full) => {
room(50);
doc.moveDown(0.4);
const y = doc.y;
doc.font("heading").fontSize(10.5).fillColor(GOLD).text(title.toUpperCase(), c.x, y, { characterSpacing: 1.6, width: c.w });
const ruleY = doc.y + 1;
doc.save().lineWidth(0.5).strokeColor(RULE).moveTo(c.x, ruleY).lineTo(c.x + c.w, ruleY).stroke().restore();
doc.y = ruleY + 6;
};
const bullet = (text: string, c: Col = full, lead?: string) => {
const indent = 11;
const w = c.w - indent;
doc.font("body").fontSize(TEXT);
room(doc.heightOfString(lead ? `${lead} ${text}` : text, { width: w, lineGap: 1 }) + 3);
const y = doc.y;
diamond(c.x + 3.5, y + 5.5, 2.1);
if (lead) {
doc.font("strong").fillColor(INK).text(lead, c.x + indent, y, { width: w, continued: true, lineGap: 1 });
doc.font("body").text(` ${text}`, { lineGap: 1 });
} else {
doc.fillColor(INK).text(text, c.x + indent, y, { width: w, lineGap: 1 });
}
doc.moveDown(0.12);
};
/** A title on the left with its dates set flush right on the same line. */
const titled = (title: string, when: string, size: number, c: Col = full) => {
room(30);
const y = doc.y;
doc.font("medium").fontSize(8.5).fillColor(GOLD);
const whenW = when ? doc.widthOfString(when) : 0;
if (when) doc.text(when, c.x + c.w - whenW, y + (size - 8.5) / 2 + 0.5, { lineBreak: false });
doc.font("strong").fontSize(size).fillColor(INK).text(title, c.x, y, { width: c.w - whenW - 12 });
};
// The letterhead carries the name, email, website and city; the resume adds
// the title, availability, profiles and date.
if (r.availability) {
const y = doc.y;
doc.font("medium").fontSize(8.5).fillColor(INK);
const w = doc.widthOfString(r.availability);
doc.text(r.availability, full.x + full.w - w, y + 2.5, { lineBreak: false });
doc.x = full.x;
doc.y = y;
}
if (r.headline) doc.font("medium").fontSize(11).fillColor(TEAL).text(r.headline, full.x, doc.y, { width: full.w - 160 });
// Only the professional profiles belong on paper; the hobby links stay on the site.
const profiles = r.socials.filter((s) => /github|gitea|linkedin/i.test(`${s.label} ${s.url}`));
const updated = r.updated ? `Updated ${month(r.updated.slice(0, 7))}` : "";
{
const y = doc.moveDown(0.15).y;
doc.font("body").fontSize(8).fillColor(MUTED);
if (updated) doc.text(updated, full.x + full.w - doc.widthOfString(updated), y, { lineBreak: false });
const links = profiles.map((s) => s.url.replace(/^https?:\/\/(www\.)?/, "").replace(/\/$/, "")).join(" · ");
doc.text(links, full.x, y, { width: full.w - 120 });
}
doc.moveDown(0.6);
if (r.summary) {
doc.font("body").fontSize(9).fillColor(INK).text(r.summary.replace(/\s*\n\s*/g, " "), full.x, doc.y, { width: full.w, lineGap: 1.4 });
}
if (r.stats.length) {
doc.moveDown(0.6);
const cols = Math.min(r.stats.length, 4);
const colW = full.w / cols;
const top = doc.y;
let bottom = top;
r.stats.forEach((st, i) => {
const x = full.x + (i % cols) * colW;
const y = top + Math.floor(i / cols) * 36;
doc.font("title").fontSize(14).fillColor(GOLD).text(st.value, x, y, { width: colW - 8, lineBreak: false });
doc.font("strong").fontSize(8).fillColor(INK).text(st.label, x, y + 16, { width: colW - 8, continued: !!st.detail });
if (st.detail) doc.font("body").fillColor(MUTED).text(` ${st.detail}`);
bottom = Math.max(bottom, doc.y);
});
doc.x = full.x;
doc.y = bottom;
}
if (r.roles.length) {
section("Experience");
r.roles.forEach((role, i) => {
const d = role.data;
if (i > 0) doc.moveDown(0.5);
titled(d.role, period(role), 10.5);
const at = [d.company, d.location].filter(Boolean).join(" · ");
doc.font("medium").fontSize(9).fillColor(TEAL).text(at, full.x, doc.y, { width: full.w });
if (d.previousRoles?.length) doc.font("body").fontSize(8.5).fillColor(MUTED).text(`Previously ${listOf(d.previousRoles)}`, { width: full.w });
// The focus line repeats what the bullets say, so paper skips it when there are bullets.
if (d.focus && !d.bullets?.length) doc.font("body").fontSize(8.5).fillColor(MUTED).text(d.focus, { width: full.w });
doc.moveDown(0.25);
for (const b of d.bullets ?? []) bullet(b);
doc.x = full.x;
});
}
if (r.earlier.length) {
section("Earlier");
r.earlier.forEach((role, i) => {
const d = role.data;
if (i > 0) doc.moveDown(0.3);
titled(d.role, period(role), 9.5);
doc.font("medium").fontSize(8.5).fillColor(TEAL).text(d.company, full.x, doc.y, { width: full.w });
if (d.focus) doc.font("body").fontSize(8).fillColor(MUTED).text(d.focus, { width: full.w });
});
}
// Skills on the left; education, certifications and projects on the right.
const gap = 22;
const leftCol: Col = { x: full.x, w: full.w * 0.55 };
const rightCol: Col = { x: full.x + leftCol.w + gap, w: full.w - leftCol.w - gap };
const labelW = 70;
const SKILL = 8;
const skillRows = (r.skills?.stack ?? []).map((g) => ({ label: g.label, items: g.items.join(", ") }));
// Measure both columns first: the block moves to a new page whole rather than splitting.
doc.fontSize(SKILL);
const leftH = skillRows.reduce((h, g) => {
doc.font("strong");
const lh = doc.heightOfString(g.label, { width: labelW });
doc.font("body");
return h + Math.max(lh, doc.heightOfString(g.items, { width: leftCol.w - labelW, lineGap: 0.6 })) + 2;
}, 24);
const entries = (r.education?.degrees.length ?? 0) + (r.education?.certifications?.length ?? 0);
doc.font("body");
const rightH =
(entries ? 24 + entries * 26 : 0) +
(r.projects.length ? 24 : 0) +
r.projects.reduce((h, p) => h + doc.heightOfString(`${p.title} ${p.detail}`, { width: rightCol.w - 11, lineGap: 1 }) + 3, 0);
if (doc.y + Math.max(leftH, rightH) > doc.page.height - doc.page.margins.bottom) doc.addPage();
const start = doc.y;
let leftEnd = start;
if (skillRows.length) {
section("Skills", leftCol);
for (const { label, items } of skillRows) {
const y = doc.y;
doc.font("strong").fontSize(SKILL).fillColor(INK).text(label, leftCol.x, y, { width: labelW });
const afterLabel = doc.y;
doc.font("body").fontSize(SKILL).fillColor(INK).text(items, leftCol.x + labelW, y, { width: leftCol.w - labelW, lineGap: 0.6 });
doc.y = Math.max(doc.y, afterLabel) + 2;
}
leftEnd = doc.y;
}
doc.y = start;
const edu = r.education;
if (edu?.degrees.length || edu?.certifications?.length) {
section("Education", rightCol);
for (const dg of edu?.degrees ?? []) {
titled(dg.credential, dg.period, 9.5, rightCol);
doc.font("medium").fontSize(8.5).fillColor(TEAL).text(dg.institution, rightCol.x, doc.y, { width: rightCol.w });
doc.moveDown(0.3);
}
for (const c of edu?.certifications ?? []) {
titled(c.name, c.year ?? "", 9.5, rightCol);
if (c.issuer) doc.font("medium").fontSize(8.5).fillColor(TEAL).text(c.issuer, rightCol.x, doc.y, { width: rightCol.w });
doc.moveDown(0.3);
}
}
if (r.projects.length) {
section("Selected projects", rightCol);
for (const p of r.projects) bullet(p.detail, rightCol, p.title);
}
doc.x = full.x;
doc.y = Math.max(doc.y, leftEnd);
// Page numbers sit just above the letterhead's footer rule, only when there is more than one page.
const range = doc.bufferedPageRange();
if (range.count > 1) {
for (let i = range.start; i < range.start + range.count; i++) {
doc.switchToPage(i);
const bottom = doc.page.margins.bottom;
doc.page.margins.bottom = 0;
const label = `${i - range.start + 1} of ${range.count}`;
doc.font("body").fontSize(8).fillColor(MUTED);
doc.text(label, full.x + full.w - doc.widthOfString(label), doc.page.height - PAGE.bottom + 8, { lineBreak: false });
doc.page.margins.bottom = bottom;
}
}
doc.end();
return done;
}
/** The resume laid over the letterhead: each page draws the letterhead, then the resume on top, both as vectors. */
export async function renderResumePdf(r: ResumeData): Promise<Buffer> {
const body = await PdfLib.load(await renderBody(r));
const out = await PdfLib.create();
out.setTitle(`${r.name} – Resume`, { showInWindowTitleBar: true });
out.setAuthor(r.name);
out.setSubject(r.headline);
out.setKeywords(["resume"]);
out.setLanguage("en-US");
out.setProducer("GregKilmer.com");
out.setCreator("GregKilmer.com");
const [letterhead] = await out.embedPdf(fromDataUrl(letterheadPdf), [0]);
const pages = await out.embedPdf(body, body.getPageIndices());
for (const p of pages) {
const page = out.addPage([p.width, p.height]);
page.drawPage(letterhead!);
page.drawPage(p);
}
return Buffer.from(await out.save());
}