mirror of
https://github.com/Patiun/com.gregkilmer.www.git
synced 2026-11-08 04:08:24 +00:00
Resume from Greg's interview, one-page PDF, store slots closed
The experience notes, skills, education and a new /resume Page text now come from Greg's Resume Interview answers (tools/migrate/src/seed.ts). Comcast and Drexel move to a short Earlier list, and the page and PDF gain Selected projects. Carrier Connect's numbers are Greg's corrected ones. The PDF fits on one page and carries the email from site settings. Store lines migrate with no open slots until Greg opens orders. Schema: experience earlier, page projects, site-settings email. 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:
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* The printable resume. Built from the same ResumeData as the /resume page,
|
||||
* laid out as one plain column so applicant tracking systems read it in order.
|
||||
* 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";
|
||||
@@ -9,7 +10,7 @@ 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 { month, period, type ResumeData } from "./resume.ts";
|
||||
import { listOf, month, period, type ResumeData } from "./resume.ts";
|
||||
|
||||
const fromDataUrl = (u: string) => Buffer.from(u.slice(u.indexOf(",") + 1), "base64");
|
||||
const FONTS = {
|
||||
@@ -26,8 +27,15 @@ const GOLD = "#a8842f";
|
||||
const TEAL = "#0f7f78";
|
||||
const RULE = "#d9cfb8";
|
||||
|
||||
const PAGE = { size: "LETTER" as const, margin: 50 };
|
||||
const PAGE = { size: "LETTER" as const, margin: 36 };
|
||||
const SITE = "gregkilmer.com";
|
||||
const TEXT = 9;
|
||||
|
||||
/** A column of the page: where text starts and how wide it runs. */
|
||||
interface Col {
|
||||
x: number;
|
||||
w: number;
|
||||
}
|
||||
|
||||
export function renderResumePdf(r: ResumeData): Promise<Buffer> {
|
||||
const doc = new PDFDocument({
|
||||
@@ -40,8 +48,7 @@ export function renderResumePdf(r: ResumeData): Promise<Buffer> {
|
||||
});
|
||||
for (const [name, data] of Object.entries(FONTS)) doc.registerFont(name, data);
|
||||
|
||||
const left = doc.page.margins.left;
|
||||
const width = doc.page.width - left - doc.page.margins.right;
|
||||
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<Buffer>((resolve, reject) => {
|
||||
@@ -60,137 +67,177 @@ export function renderResumePdf(r: ResumeData): Promise<Buffer> {
|
||||
|
||||
/** A thin gold rule with a small star at its center. */
|
||||
const starRule = (y: number) => {
|
||||
const mid = left + width / 2;
|
||||
const mid = full.x + full.w / 2;
|
||||
doc.save().lineWidth(0.6).strokeColor(GOLD);
|
||||
doc.moveTo(left, y).lineTo(mid - 10, y).stroke();
|
||||
doc.moveTo(mid + 10, y).lineTo(left + width, y).stroke();
|
||||
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) => {
|
||||
room(60);
|
||||
doc.moveDown(0.75);
|
||||
const section = (title: string, c: Col = full) => {
|
||||
room(50);
|
||||
doc.moveDown(0.5);
|
||||
const y = doc.y;
|
||||
doc.font("heading").fontSize(11).fillColor(GOLD).text(title.toUpperCase(), left, y, { characterSpacing: 1.6 });
|
||||
const ruleY = doc.y + 2;
|
||||
doc.save().lineWidth(0.5).strokeColor(RULE).moveTo(left, ruleY).lineTo(left + width, ruleY).stroke().restore();
|
||||
doc.y = ruleY + 8;
|
||||
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, indent = 12) => {
|
||||
doc.font("body").fontSize(9.5);
|
||||
const h = doc.heightOfString(text, { width: width - indent, lineGap: 1.5 });
|
||||
room(h + 4);
|
||||
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.2 }) + 3);
|
||||
const y = doc.y;
|
||||
diamond(left + 3.5, y + 6, 2.2);
|
||||
doc.fillColor(INK).text(text, left + indent, y, { width: width - indent, lineGap: 1.5 });
|
||||
doc.moveDown(0.15);
|
||||
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 });
|
||||
} else {
|
||||
doc.fillColor(INK).text(text, c.x + indent, y, { width: w, lineGap: 1.2 });
|
||||
}
|
||||
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 });
|
||||
};
|
||||
|
||||
// Header
|
||||
doc.font("title").fontSize(28).fillColor(INK).text(r.name.toUpperCase(), left, doc.y, { characterSpacing: 2 });
|
||||
if (r.headline) doc.font("medium").fontSize(11).fillColor(TEAL).text(r.headline, { width });
|
||||
doc.font("title").fontSize(24).fillColor(INK).text(r.name.toUpperCase(), full.x, doc.y, { characterSpacing: 2 });
|
||||
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(10.5).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, SITE, ...profiles.map((s) => s.url.replace(/^https?:\/\/(www\.)?/, "").replace(/\/$/, ""))].filter(Boolean).join(" · ");
|
||||
doc.moveDown(0.3).font("body").fontSize(9).fillColor(MUTED).text(contact, { width });
|
||||
if (r.availability) doc.font("medium").fontSize(9).fillColor(INK).text(r.availability, { width });
|
||||
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.6);
|
||||
doc.moveDown(0.5);
|
||||
|
||||
if (r.summary) {
|
||||
doc.font("body").fontSize(10).fillColor(INK).text(r.summary.replace(/\s*\n\s*/g, " "), left, doc.y, { width, lineGap: 2 });
|
||||
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 });
|
||||
}
|
||||
|
||||
if (r.stats.length) {
|
||||
section("Highlights");
|
||||
doc.moveDown(0.7);
|
||||
const cols = Math.min(r.stats.length, 4);
|
||||
const colW = width / cols;
|
||||
let top = doc.y;
|
||||
const colW = full.w / cols;
|
||||
const top = doc.y;
|
||||
let bottom = top;
|
||||
r.stats.forEach((st, i) => {
|
||||
if (i > 0 && i % cols === 0) top = doc.y + 6;
|
||||
const x = left + (i % cols) * colW;
|
||||
doc.font("title").fontSize(16).fillColor(GOLD).text(st.value, x, top, { width: colW - 10 });
|
||||
doc.font("strong").fontSize(9).fillColor(INK).text(st.label, x, doc.y, { width: colW - 10 });
|
||||
if (st.detail) doc.font("body").fontSize(8.5).fillColor(MUTED).text(st.detail, x, doc.y, { width: colW - 10 });
|
||||
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 });
|
||||
bottom = Math.max(bottom, doc.y);
|
||||
});
|
||||
doc.x = left;
|
||||
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.6);
|
||||
room(64);
|
||||
const y = doc.y;
|
||||
const when = period(role);
|
||||
doc.font("medium").fontSize(9).fillColor(GOLD);
|
||||
const whenW = doc.widthOfString(when);
|
||||
doc.text(when, left + width - whenW, y + 1.5, { lineBreak: false });
|
||||
doc.font("strong").fontSize(11).fillColor(INK).text(d.role, left, y, { width: width - whenW - 16 });
|
||||
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.5).fillColor(TEAL).text(at, left, doc.y, { width });
|
||||
if (d.previousRoles?.length) {
|
||||
doc.font("body").fontSize(8.5).fillColor(MUTED).text(`Previously ${d.previousRoles.join(", ")} at ${d.company}`, { width });
|
||||
}
|
||||
if (d.focus) doc.moveDown(0.2).font("body").fontSize(9).fillColor(MUTED).text(d.focus, { width, lineGap: 1 });
|
||||
doc.moveDown(0.3);
|
||||
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 });
|
||||
if (d.focus) 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);
|
||||
if (d.tech?.length) {
|
||||
doc.font("body").fontSize(8.5).fillColor(MUTED).text(d.tech.join(" · "), left + 12, doc.y, { width: width - 12 });
|
||||
}
|
||||
doc.x = left;
|
||||
doc.x = full.x;
|
||||
});
|
||||
}
|
||||
|
||||
if (r.skills?.stack.length) {
|
||||
section("Skills");
|
||||
for (const g of r.skills.stack) {
|
||||
const items = g.items.join(", ");
|
||||
doc.font("body").fontSize(9.5);
|
||||
room(doc.heightOfString(items, { width: width - 110 }) + 4);
|
||||
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.5).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 = 64;
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
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(9.5).fillColor(INK).text(g.label, left, y, { width: 100 });
|
||||
doc.font("strong").fontSize(8.5).fillColor(INK).text(label, leftCol.x, y, { width: labelW });
|
||||
const afterLabel = doc.y;
|
||||
doc.font("body").fontSize(9.5).fillColor(INK).text(items, left + 110, y, { width: width - 110, lineGap: 1 });
|
||||
doc.y = Math.max(doc.y, afterLabel) + 3;
|
||||
doc.font("body").fontSize(8.5).fillColor(INK).text(items, leftCol.x + labelW, y, { width: leftCol.w - labelW, lineGap: 0.8 });
|
||||
doc.y = Math.max(doc.y, afterLabel) + 2;
|
||||
}
|
||||
if (r.skills.core?.length) {
|
||||
room(30);
|
||||
const y = doc.y;
|
||||
doc.font("strong").fontSize(9.5).fillColor(INK).text("Strengths", left, y, { width: 100 });
|
||||
doc.font("body").fontSize(9.5).fillColor(INK).text(r.skills.core.join(", "), left + 110, y, { width: width - 110, lineGap: 1 });
|
||||
}
|
||||
doc.x = left;
|
||||
leftEnd = doc.y;
|
||||
}
|
||||
|
||||
if (r.education?.degrees.length) {
|
||||
section("Education");
|
||||
for (const dg of r.education.degrees) {
|
||||
room(40);
|
||||
const y = doc.y;
|
||||
doc.font("medium").fontSize(9).fillColor(GOLD);
|
||||
const w = doc.widthOfString(dg.period);
|
||||
doc.text(dg.period, left + width - w, y + 1.5, { lineBreak: false });
|
||||
doc.font("strong").fontSize(10.5).fillColor(INK).text(dg.credential, left, y, { width: width - w - 16 });
|
||||
doc.font("medium").fontSize(9.5).fillColor(TEAL).text(dg.institution, left, doc.y, { width });
|
||||
if (dg.detail) doc.font("body").fontSize(9).fillColor(MUTED).text(dg.detail, { width });
|
||||
doc.moveDown(0.4);
|
||||
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.education?.certifications?.length) {
|
||||
section("Certifications");
|
||||
for (const c of r.education.certifications) {
|
||||
room(20);
|
||||
const line = [c.issuer, c.year].filter(Boolean).join(" · ");
|
||||
doc.font("strong").fontSize(9.5).fillColor(INK).text(c.name, left, doc.y, { continued: !!line });
|
||||
if (line) doc.font("body").fillColor(MUTED).text(` ${line}`);
|
||||
}
|
||||
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);
|
||||
|
||||
// Footer on every page.
|
||||
const range = doc.bufferedPageRange();
|
||||
@@ -200,12 +247,12 @@ export function renderResumePdf(r: ResumeData): Promise<Buffer> {
|
||||
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(left, y - 8).lineTo(left + width, y - 8).stroke().restore();
|
||||
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(" · "), left, y, { lineBreak: false });
|
||||
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}`;
|
||||
doc.text(label, left + width - doc.widthOfString(label), y, { lineBreak: false });
|
||||
doc.text(label, full.x + full.w - doc.widthOfString(label), y, { lineBreak: false });
|
||||
}
|
||||
doc.page.margins.bottom = bottom;
|
||||
}
|
||||
|
||||
@@ -8,8 +8,13 @@ export interface ResumeData {
|
||||
location?: string;
|
||||
summary?: string;
|
||||
availability?: string;
|
||||
email?: string;
|
||||
stats: { value: string; label: string; detail?: string }[];
|
||||
/** Roles shown in full, current first. */
|
||||
roles: Note<"experience">[];
|
||||
/** Roles marked earlier: a short list of title, company, dates and focus. */
|
||||
earlier: Note<"experience">[];
|
||||
projects: { title: string; detail: string; link?: string }[];
|
||||
skills?: Note<"skills">["data"];
|
||||
education?: Note<"education">["data"];
|
||||
socials: { label: string; handle: string; url: string }[];
|
||||
@@ -26,6 +31,9 @@ export const month = (ym: string | undefined) => {
|
||||
|
||||
export const period = (r: Note<"experience">) => `${month(r.data.start)} – ${month(r.data.end)}`;
|
||||
|
||||
/** "A, B and C". */
|
||||
export const listOf = (items: string[]) => new Intl.ListFormat("en-US", { style: "long", type: "conjunction" }).format(items);
|
||||
|
||||
export function resumeData(s: SiteIndex): ResumeData {
|
||||
const settings = s.settings();
|
||||
const page = s.pageText("/resume");
|
||||
@@ -39,8 +47,11 @@ export function resumeData(s: SiteIndex): ResumeData {
|
||||
location: settings?.location,
|
||||
summary: page?.body?.trim() || undefined,
|
||||
availability: settings?.availability,
|
||||
email: settings?.email,
|
||||
stats: page?.data.stats ?? [],
|
||||
roles,
|
||||
roles: roles.filter((r) => !r.data.earlier),
|
||||
earlier: roles.filter((r) => r.data.earlier),
|
||||
projects: page?.data.projects ?? [],
|
||||
skills: s.one("skills")?.data,
|
||||
education: s.one("education")?.data,
|
||||
socials: settings?.socials ?? [],
|
||||
|
||||
Reference in New Issue
Block a user