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
This commit is contained in:
Claude
2026-10-11 22:36:19 +00:00
parent a834451def
commit 0d0b8e4085
6 changed files with 140 additions and 65 deletions
+1
View File
@@ -16,6 +16,7 @@
"@gk/ui": "*",
"astro": "7.3.5",
"marked": "18.0.14",
"pdf-lib": "^1.17.1",
"pdfkit": "^0.20.2"
},
"devDependencies": {
+76 -60
View File
@@ -1,10 +1,13 @@
/**
* The printable resume. Built from the same ResumeData as the /resume page,
* 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";
@@ -27,9 +30,12 @@ const GOLD = "#a8842f";
const TEAL = "#0f7f78";
const RULE = "#d9cfb8";
const PAGE = { size: "LETTER" as const, margin: 36 };
const SITE = "gregkilmer.com";
const TEXT = 9;
/**
* 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 {
@@ -37,10 +43,10 @@ interface Col {
w: number;
}
export function renderResumePdf(r: ResumeData): Promise<Buffer> {
function renderBody(r: ResumeData): Promise<Buffer> {
const doc = new PDFDocument({
size: PAGE.size,
margins: { top: PAGE.margin, bottom: PAGE.margin + 14, left: PAGE.margin, right: PAGE.margin },
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",
@@ -65,19 +71,9 @@ export function renderResumePdf(r: ResumeData): Promise<Buffer> {
doc.save().moveTo(x, y - s).lineTo(x + s, y).lineTo(x, y + s).lineTo(x - s, y).closePath().fill(color).restore();
};
/** A thin gold rule with a small star at its center. */
const starRule = (y: number) => {
const mid = full.x + full.w / 2;
doc.save().lineWidth(0.6).strokeColor(GOLD);
doc.moveTo(full.x, y).lineTo(mid - 10, y).stroke();
doc.moveTo(mid + 10, y).lineTo(full.x + full.w, y).stroke();
doc.restore();
diamond(mid, y, 4);
};
const section = (title: string, c: Col = full) => {
room(50);
doc.moveDown(0.5);
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;
@@ -89,14 +85,14 @@ export function renderResumePdf(r: ResumeData): Promise<Buffer> {
const indent = 11;
const w = c.w - indent;
doc.font("body").fontSize(TEXT);
room(doc.heightOfString(lead ? `${lead} ${text}` : text, { width: w, lineGap: 1.2 }) + 3);
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.2 });
doc.font("body").text(` ${text}`, { lineGap: 1.2 });
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.2 });
doc.fillColor(INK).text(text, c.x + indent, y, { width: w, lineGap: 1 });
}
doc.moveDown(0.12);
};
@@ -111,8 +107,8 @@ export function renderResumePdf(r: ResumeData): Promise<Buffer> {
doc.font("strong").fontSize(size).fillColor(INK).text(title, c.x, y, { width: c.w - whenW - 12 });
};
// Header
doc.font("title").fontSize(24).fillColor(INK).text(r.name.toUpperCase(), full.x, doc.y, { characterSpacing: 2 });
// 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);
@@ -121,33 +117,35 @@ export function renderResumePdf(r: ResumeData): Promise<Buffer> {
doc.x = full.x;
doc.y = y;
}
if (r.headline) doc.font("medium").fontSize(10.5).fillColor(TEAL).text(r.headline, full.x, doc.y, { width: full.w - 160 });
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 contact = [r.location, r.email, SITE, ...profiles.map((s) => s.url.replace(/^https?:\/\/(www\.)?/, "").replace(/\/$/, ""))]
.filter(Boolean)
.join(" · ");
doc.moveDown(0.2).font("body").fontSize(8).fillColor(MUTED).text(contact, { width: full.w });
doc.moveDown(0.7);
starRule(doc.y);
doc.moveDown(0.5);
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.5).fillColor(INK).text(r.summary.replace(/\s*\n\s*/g, " "), full.x, doc.y, { width: full.w, lineGap: 1.6 });
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.7);
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) * 44;
doc.font("title").fontSize(15).fillColor(GOLD).text(st.value, x, y, { width: colW - 10, lineBreak: false });
doc.font("strong").fontSize(8.5).fillColor(INK).text(st.label, x, y + 18, { width: colW - 10 });
if (st.detail) doc.font("body").fontSize(8).fillColor(MUTED).text(st.detail, x, doc.y, { width: colW - 10 });
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;
@@ -178,7 +176,7 @@ export function renderResumePdf(r: ResumeData): Promise<Buffer> {
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.5).fillColor(MUTED).text(d.focus, { width: full.w });
if (d.focus) doc.font("body").fontSize(8).fillColor(MUTED).text(d.focus, { width: full.w });
});
}
@@ -186,22 +184,23 @@ export function renderResumePdf(r: ResumeData): Promise<Buffer> {
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 = 64;
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(8.5);
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.8 })) + 2;
}, 30);
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 ? 26 + entries * 28 : 0) +
(r.projects.length ? 26 : 0) +
r.projects.reduce((h, p) => h + doc.heightOfString(`${p.title} ${p.detail}`, { width: rightCol.w - 11, lineGap: 1.2 }) + 3, 0);
(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;
@@ -210,9 +209,9 @@ export function renderResumePdf(r: ResumeData): Promise<Buffer> {
section("Skills", leftCol);
for (const { label, items } of skillRows) {
const y = doc.y;
doc.font("strong").fontSize(8.5).fillColor(INK).text(label, leftCol.x, y, { width: labelW });
doc.font("strong").fontSize(SKILL).fillColor(INK).text(label, leftCol.x, y, { width: labelW });
const afterLabel = doc.y;
doc.font("body").fontSize(8.5).fillColor(INK).text(items, leftCol.x + labelW, y, { width: leftCol.w - labelW, lineGap: 0.8 });
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;
@@ -240,24 +239,41 @@ export function renderResumePdf(r: ResumeData): Promise<Buffer> {
doc.x = full.x;
doc.y = Math.max(doc.y, leftEnd);
// Footer on every page.
// Page numbers sit just above the letterhead's footer rule, only when there is more than one page.
const range = doc.bufferedPageRange();
const updated = r.updated ? `Updated ${month(r.updated.slice(0, 7))}` : "";
for (let i = range.start; i < range.start + range.count; i++) {
doc.switchToPage(i);
const y = doc.page.height - PAGE.margin + 4;
const bottom = doc.page.margins.bottom;
doc.page.margins.bottom = 0;
doc.save().lineWidth(0.5).strokeColor(RULE).moveTo(full.x, y - 8).lineTo(full.x + full.w, y - 8).stroke().restore();
doc.font("body").fontSize(8).fillColor(MUTED);
doc.text([`${SITE}/resume`, updated].filter(Boolean).join(" · "), full.x, y, { lineBreak: false });
if (range.count > 1) {
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.text(label, full.x + full.w - doc.widthOfString(label), y, { lineBreak: false });
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.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());
}