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 0000000..7594dca Binary files /dev/null and b/services/site/src/assets/letterhead/Greg-Kilmer-Letterhead.pdf differ 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.`, };