mirror of
https://github.com/Patiun/com.gregkilmer.www.git
synced 2026-11-07 19:58:24 +00:00
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:
+4
-1
@@ -86,7 +86,10 @@ or admin token. Never set it where the public can reach the site.
|
|||||||
## The resume PDF
|
## The resume PDF
|
||||||
|
|
||||||
`/resume.pdf` is drawn with pdfkit from the same `resumeData()` as `/resume`
|
`/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
|
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
|
`earlier` print as a short list; skills share a row with education and
|
||||||
projects), uses the brand TTFs in
|
projects), uses the brand TTFs in
|
||||||
|
|||||||
Generated
+55
@@ -1780,6 +1780,36 @@
|
|||||||
"url": "https://github.com/sponsors/oxc-project"
|
"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": {
|
"node_modules/@rolldown/binding-android-arm-eabi": {
|
||||||
"version": "1.2.13",
|
"version": "1.2.13",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.13.tgz",
|
"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"
|
"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": {
|
"node_modules/pdfkit": {
|
||||||
"version": "0.20.2",
|
"version": "0.20.2",
|
||||||
"resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.20.2.tgz",
|
"resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.20.2.tgz",
|
||||||
@@ -6824,6 +6878,7 @@
|
|||||||
"@gk/ui": "*",
|
"@gk/ui": "*",
|
||||||
"astro": "7.3.5",
|
"astro": "7.3.5",
|
||||||
"marked": "18.0.14",
|
"marked": "18.0.14",
|
||||||
|
"pdf-lib": "^1.17.1",
|
||||||
"pdfkit": "^0.20.2"
|
"pdfkit": "^0.20.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"@gk/ui": "*",
|
"@gk/ui": "*",
|
||||||
"astro": "7.3.5",
|
"astro": "7.3.5",
|
||||||
"marked": "18.0.14",
|
"marked": "18.0.14",
|
||||||
|
"pdf-lib": "^1.17.1",
|
||||||
"pdfkit": "^0.20.2"
|
"pdfkit": "^0.20.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
Binary file not shown.
@@ -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
|
* laid out top to bottom (skills share a row with education and projects) so
|
||||||
* applicant tracking systems read it in order.
|
* applicant tracking systems read it in order.
|
||||||
* Paper colors: ink on white, with the brand gold and teal darkened for print.
|
* Paper colors: ink on white, with the brand gold and teal darkened for print.
|
||||||
*/
|
*/
|
||||||
import PDFDocument from "pdfkit";
|
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 cinzelBold from "../assets/pdf-fonts/Cinzel-Bold.ttf?inline";
|
||||||
import cinzelSemi from "../assets/pdf-fonts/Cinzel-SemiBold.ttf?inline";
|
import cinzelSemi from "../assets/pdf-fonts/Cinzel-SemiBold.ttf?inline";
|
||||||
import montReg from "../assets/pdf-fonts/Montserrat-Regular.ttf?inline";
|
import montReg from "../assets/pdf-fonts/Montserrat-Regular.ttf?inline";
|
||||||
@@ -27,9 +30,12 @@ const GOLD = "#a8842f";
|
|||||||
const TEAL = "#0f7f78";
|
const TEAL = "#0f7f78";
|
||||||
const RULE = "#d9cfb8";
|
const RULE = "#d9cfb8";
|
||||||
|
|
||||||
const PAGE = { size: "LETTER" as const, margin: 36 };
|
/**
|
||||||
const SITE = "gregkilmer.com";
|
* The writing area inside the Greg Kilmer letterhead (branding/letterhead), in
|
||||||
const TEXT = 9;
|
* 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. */
|
/** A column of the page: where text starts and how wide it runs. */
|
||||||
interface Col {
|
interface Col {
|
||||||
@@ -37,10 +43,10 @@ interface Col {
|
|||||||
w: number;
|
w: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderResumePdf(r: ResumeData): Promise<Buffer> {
|
function renderBody(r: ResumeData): Promise<Buffer> {
|
||||||
const doc = new PDFDocument({
|
const doc = new PDFDocument({
|
||||||
size: PAGE.size,
|
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,
|
bufferPages: true,
|
||||||
info: { Title: `${r.name} – Resume`, Author: r.name, Subject: r.headline, Keywords: "resume" },
|
info: { Title: `${r.name} – Resume`, Author: r.name, Subject: r.headline, Keywords: "resume" },
|
||||||
lang: "en-US",
|
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();
|
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) => {
|
const section = (title: string, c: Col = full) => {
|
||||||
room(50);
|
room(50);
|
||||||
doc.moveDown(0.5);
|
doc.moveDown(0.4);
|
||||||
const y = doc.y;
|
const y = doc.y;
|
||||||
doc.font("heading").fontSize(10.5).fillColor(GOLD).text(title.toUpperCase(), c.x, y, { characterSpacing: 1.6, width: c.w });
|
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;
|
const ruleY = doc.y + 1;
|
||||||
@@ -89,14 +85,14 @@ export function renderResumePdf(r: ResumeData): Promise<Buffer> {
|
|||||||
const indent = 11;
|
const indent = 11;
|
||||||
const w = c.w - indent;
|
const w = c.w - indent;
|
||||||
doc.font("body").fontSize(TEXT);
|
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;
|
const y = doc.y;
|
||||||
diamond(c.x + 3.5, y + 5.5, 2.1);
|
diamond(c.x + 3.5, y + 5.5, 2.1);
|
||||||
if (lead) {
|
if (lead) {
|
||||||
doc.font("strong").fillColor(INK).text(lead, c.x + indent, y, { width: w, continued: true, 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.2 });
|
doc.font("body").text(` ${text}`, { lineGap: 1 });
|
||||||
} else {
|
} 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);
|
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 });
|
doc.font("strong").fontSize(size).fillColor(INK).text(title, c.x, y, { width: c.w - whenW - 12 });
|
||||||
};
|
};
|
||||||
|
|
||||||
// Header
|
// The letterhead carries the name, email, website and city; the resume adds
|
||||||
doc.font("title").fontSize(24).fillColor(INK).text(r.name.toUpperCase(), full.x, doc.y, { characterSpacing: 2 });
|
// the title, availability, profiles and date.
|
||||||
if (r.availability) {
|
if (r.availability) {
|
||||||
const y = doc.y;
|
const y = doc.y;
|
||||||
doc.font("medium").fontSize(8.5).fillColor(INK);
|
doc.font("medium").fontSize(8.5).fillColor(INK);
|
||||||
@@ -121,33 +117,35 @@ export function renderResumePdf(r: ResumeData): Promise<Buffer> {
|
|||||||
doc.x = full.x;
|
doc.x = full.x;
|
||||||
doc.y = y;
|
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.
|
// 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 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(/\/$/, ""))]
|
const updated = r.updated ? `Updated ${month(r.updated.slice(0, 7))}` : "";
|
||||||
.filter(Boolean)
|
{
|
||||||
.join(" · ");
|
const y = doc.moveDown(0.15).y;
|
||||||
doc.moveDown(0.2).font("body").fontSize(8).fillColor(MUTED).text(contact, { width: full.w });
|
doc.font("body").fontSize(8).fillColor(MUTED);
|
||||||
doc.moveDown(0.7);
|
if (updated) doc.text(updated, full.x + full.w - doc.widthOfString(updated), y, { lineBreak: false });
|
||||||
starRule(doc.y);
|
const links = profiles.map((s) => s.url.replace(/^https?:\/\/(www\.)?/, "").replace(/\/$/, "")).join(" · ");
|
||||||
doc.moveDown(0.5);
|
doc.text(links, full.x, y, { width: full.w - 120 });
|
||||||
|
}
|
||||||
|
doc.moveDown(0.6);
|
||||||
|
|
||||||
if (r.summary) {
|
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) {
|
if (r.stats.length) {
|
||||||
doc.moveDown(0.7);
|
doc.moveDown(0.6);
|
||||||
const cols = Math.min(r.stats.length, 4);
|
const cols = Math.min(r.stats.length, 4);
|
||||||
const colW = full.w / cols;
|
const colW = full.w / cols;
|
||||||
const top = doc.y;
|
const top = doc.y;
|
||||||
let bottom = top;
|
let bottom = top;
|
||||||
r.stats.forEach((st, i) => {
|
r.stats.forEach((st, i) => {
|
||||||
const x = full.x + (i % cols) * colW;
|
const x = full.x + (i % cols) * colW;
|
||||||
const y = top + Math.floor(i / cols) * 44;
|
const y = top + Math.floor(i / cols) * 36;
|
||||||
doc.font("title").fontSize(15).fillColor(GOLD).text(st.value, x, y, { width: colW - 10, lineBreak: false });
|
doc.font("title").fontSize(14).fillColor(GOLD).text(st.value, x, y, { width: colW - 8, lineBreak: false });
|
||||||
doc.font("strong").fontSize(8.5).fillColor(INK).text(st.label, x, y + 18, { width: colW - 10 });
|
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").fontSize(8).fillColor(MUTED).text(st.detail, x, doc.y, { width: colW - 10 });
|
if (st.detail) doc.font("body").fillColor(MUTED).text(` ${st.detail}`);
|
||||||
bottom = Math.max(bottom, doc.y);
|
bottom = Math.max(bottom, doc.y);
|
||||||
});
|
});
|
||||||
doc.x = full.x;
|
doc.x = full.x;
|
||||||
@@ -178,7 +176,7 @@ export function renderResumePdf(r: ResumeData): Promise<Buffer> {
|
|||||||
if (i > 0) doc.moveDown(0.3);
|
if (i > 0) doc.moveDown(0.3);
|
||||||
titled(d.role, period(role), 9.5);
|
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 });
|
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 gap = 22;
|
||||||
const leftCol: Col = { x: full.x, w: full.w * 0.55 };
|
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 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(", ") }));
|
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.
|
// 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) => {
|
const leftH = skillRows.reduce((h, g) => {
|
||||||
doc.font("strong");
|
doc.font("strong");
|
||||||
const lh = doc.heightOfString(g.label, { width: labelW });
|
const lh = doc.heightOfString(g.label, { width: labelW });
|
||||||
doc.font("body");
|
doc.font("body");
|
||||||
return h + Math.max(lh, doc.heightOfString(g.items, { width: leftCol.w - labelW, lineGap: 0.8 })) + 2;
|
return h + Math.max(lh, doc.heightOfString(g.items, { width: leftCol.w - labelW, lineGap: 0.6 })) + 2;
|
||||||
}, 30);
|
}, 24);
|
||||||
const entries = (r.education?.degrees.length ?? 0) + (r.education?.certifications?.length ?? 0);
|
const entries = (r.education?.degrees.length ?? 0) + (r.education?.certifications?.length ?? 0);
|
||||||
doc.font("body");
|
doc.font("body");
|
||||||
const rightH =
|
const rightH =
|
||||||
(entries ? 26 + entries * 28 : 0) +
|
(entries ? 24 + entries * 26 : 0) +
|
||||||
(r.projects.length ? 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.2 }) + 3, 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();
|
if (doc.y + Math.max(leftH, rightH) > doc.page.height - doc.page.margins.bottom) doc.addPage();
|
||||||
const start = doc.y;
|
const start = doc.y;
|
||||||
let leftEnd = start;
|
let leftEnd = start;
|
||||||
@@ -210,9 +209,9 @@ export function renderResumePdf(r: ResumeData): Promise<Buffer> {
|
|||||||
section("Skills", leftCol);
|
section("Skills", leftCol);
|
||||||
for (const { label, items } of skillRows) {
|
for (const { label, items } of skillRows) {
|
||||||
const y = doc.y;
|
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;
|
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;
|
doc.y = Math.max(doc.y, afterLabel) + 2;
|
||||||
}
|
}
|
||||||
leftEnd = doc.y;
|
leftEnd = doc.y;
|
||||||
@@ -240,24 +239,41 @@ export function renderResumePdf(r: ResumeData): Promise<Buffer> {
|
|||||||
doc.x = full.x;
|
doc.x = full.x;
|
||||||
doc.y = Math.max(doc.y, leftEnd);
|
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 range = doc.bufferedPageRange();
|
||||||
const updated = r.updated ? `Updated ${month(r.updated.slice(0, 7))}` : "";
|
if (range.count > 1) {
|
||||||
for (let i = range.start; i < range.start + range.count; i++) {
|
for (let i = range.start; i < range.start + range.count; i++) {
|
||||||
doc.switchToPage(i);
|
doc.switchToPage(i);
|
||||||
const y = doc.page.height - PAGE.margin + 4;
|
const bottom = doc.page.margins.bottom;
|
||||||
const bottom = doc.page.margins.bottom;
|
doc.page.margins.bottom = 0;
|
||||||
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) {
|
|
||||||
const label = `${i - range.start + 1} of ${range.count}`;
|
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();
|
doc.end();
|
||||||
return done;
|
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());
|
||||||
|
}
|
||||||
|
|||||||
@@ -289,7 +289,7 @@ export const experience: Record<string, Record<string, unknown>> = {
|
|||||||
role: "Assistant System Administrator",
|
role: "Assistant System Administrator",
|
||||||
end: "2018-06",
|
end: "2018-06",
|
||||||
earlier: true,
|
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: [
|
bullets: [
|
||||||
"Ran the Linux server network for the whole College of Computing and Informatics (4 servers) with Ansible, Bash, Python and MySQL.",
|
"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.",
|
"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<string, Record<string, unknown>> = {
|
|||||||
comcast_1: {
|
comcast_1: {
|
||||||
role: "Senior Intern and Junior Developer (co-op)",
|
role: "Senior Intern and Junior Developer (co-op)",
|
||||||
earlier: true,
|
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: [
|
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.",
|
"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).",
|
"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" },
|
{ value: "10", label: "People led", detail: "Development and Managed Services" },
|
||||||
],
|
],
|
||||||
projects: [
|
projects: [
|
||||||
{ title: "Cosmos homelab", detail: "Runs 20+ Docker containers alongside 17 AI agents, all on a private network." },
|
{ title: "Cosmos homelab", detail: "20+ Docker containers and 17 AI agents 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: "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.`,
|
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.`,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user