/** * 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 { 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((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 { 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()); }