From 0d0b8e4085da7b2fc38c9c700f9c01f8db3c3da9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 22:36:19 +0000 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx --- docs/site.md | 5 +- package-lock.json | 55 +++++++ services/site/package.json | 1 + .../letterhead/Greg-Kilmer-Letterhead.pdf | Bin 0 -> 36235 bytes services/site/src/lib/resume-pdf.ts | 136 ++++++++++-------- tools/migrate/src/seed.ts | 8 +- 6 files changed, 140 insertions(+), 65 deletions(-) create mode 100644 services/site/src/assets/letterhead/Greg-Kilmer-Letterhead.pdf diff --git a/docs/site.md b/docs/site.md index 9607c12..558a843 100644 --- a/docs/site.md +++ b/docs/site.md @@ -86,7 +86,10 @@ or admin token. Never set it where the public can reach the site. ## The resume PDF `/resume.pdf` is drawn with pdfkit from the same `resumeData()` as `/resume` -(`src/lib/resume.ts`), so the two never disagree. It reads top to bottom so +(`src/lib/resume.ts`), so the two never disagree. Each page is laid over the +Greg Kilmer letterhead (`src/assets/letterhead`, a copy of +`branding/letterhead/Greg-Kilmer-Letterhead.pdf`; re-copy it when the letterhead +changes) with pdf-lib, which keeps both as vectors. It reads top to bottom so applicant tracking systems take it in order, aims for one page (roles marked `earlier` print as a short list; skills share a row with education and projects), uses the brand TTFs in diff --git a/package-lock.json b/package-lock.json index a0f7d2c..9a2aead 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1780,6 +1780,36 @@ "url": "https://github.com/sponsors/oxc-project" } }, + "node_modules/@pdf-lib/standard-fonts": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz", + "integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.6" + } + }, + "node_modules/@pdf-lib/standard-fonts/node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "node_modules/@pdf-lib/upng": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz", + "integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.10" + } + }, + "node_modules/@pdf-lib/upng/node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/@rolldown/binding-android-arm-eabi": { "version": "1.2.13", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.13.tgz", @@ -4880,6 +4910,30 @@ "url": "https://opencollective.com/express" } }, + "node_modules/pdf-lib": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz", + "integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==", + "license": "MIT", + "dependencies": { + "@pdf-lib/standard-fonts": "^1.0.0", + "@pdf-lib/upng": "^1.0.1", + "pako": "^1.0.11", + "tslib": "^1.11.1" + } + }, + "node_modules/pdf-lib/node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "node_modules/pdf-lib/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, "node_modules/pdfkit": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.20.2.tgz", @@ -6824,6 +6878,7 @@ "@gk/ui": "*", "astro": "7.3.5", "marked": "18.0.14", + "pdf-lib": "^1.17.1", "pdfkit": "^0.20.2" }, "devDependencies": { diff --git a/services/site/package.json b/services/site/package.json index af895a9..e1f7ea5 100644 --- a/services/site/package.json +++ b/services/site/package.json @@ -16,6 +16,7 @@ "@gk/ui": "*", "astro": "7.3.5", "marked": "18.0.14", + "pdf-lib": "^1.17.1", "pdfkit": "^0.20.2" }, "devDependencies": { diff --git a/services/site/src/assets/letterhead/Greg-Kilmer-Letterhead.pdf b/services/site/src/assets/letterhead/Greg-Kilmer-Letterhead.pdf new file mode 100644 index 0000000000000000000000000000000000000000..7594dca34f0bc5c313f85eeac8524204397fe787 GIT binary patch literal 36235 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 literal 0 HcmV?d00001 diff --git a/services/site/src/lib/resume-pdf.ts b/services/site/src/lib/resume-pdf.ts index 72e379b..7cb7821 100644 --- a/services/site/src/lib/resume-pdf.ts +++ b/services/site/src/lib/resume-pdf.ts @@ -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 { +function renderBody(r: ResumeData): Promise { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { + 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()); +} diff --git a/tools/migrate/src/seed.ts b/tools/migrate/src/seed.ts index 0fe086b..76832e4 100644 --- a/tools/migrate/src/seed.ts +++ b/tools/migrate/src/seed.ts @@ -289,7 +289,7 @@ export const experience: Record> = { role: "Assistant System Administrator", end: "2018-06", earlier: true, - focus: "Ran the college's 4 Linux servers and automated 3 computer labs and the laptop carts with Ansible; stayed on at the lab help desk to 2018.", + focus: "Ran the college's 4 Linux servers and automated 3 labs and the laptop carts with Ansible, then the lab help desk to 2018.", bullets: [ "Ran the Linux server network for the whole College of Computing and Informatics (4 servers) with Ansible, Bash, Python and MySQL.", "Automated deployment and maintenance of every workstation in the 3 computer labs and the mobile laptop carts with Ansible.", @@ -300,7 +300,7 @@ export const experience: Record> = { comcast_1: { role: "Senior Intern and Junior Developer (co-op)", earlier: true, - focus: "Ran a team of 10 co-ops building smart remote, call center and inventory tools; built an NLP pipeline turning customer speech and reviews into sentiment for leadership.", + focus: "Ran a team of 10 co-ops on smart remote, call center and inventory tools; built an NLP customer sentiment pipeline for leadership.", bullets: [ "Hired back after the co-op as Senior Intern to run a team of 10 co-ops building smart remote, call center and inventory tools.", "Built an NLP system that turned remote speech input and reviews into customer sentiment for leadership teams (Java, Groovy, Grails, Elasticsearch, MySQL).", @@ -323,8 +323,8 @@ export const resumePage = { { value: "10", label: "People led", detail: "Development and Managed Services" }, ], projects: [ - { title: "Cosmos homelab", detail: "Runs 20+ Docker containers alongside 17 AI agents, all on a private network." }, - { title: "GregKilmer.com", detail: "This site: pages fed from an Obsidian vault through a rule-checked content service, with an MCP server for AI agents." }, + { title: "Cosmos homelab", detail: "20+ Docker containers and 17 AI agents on a private network." }, + { title: "GregKilmer.com", detail: "This site, fed from an Obsidian vault by a rule-checked content service and MCP server." }, ], body: `Software architect and team lead who takes SAP BTP cloud and mobile systems from the first customer demo through architecture, delivery and support. Broad background across cloud, app and game development. Uses AI tools to work faster, with the fundamentals to work without them and the guidelines to use them responsibly.`, };