mirror of
https://github.com/Patiun/com.gregkilmer.www.git
synced 2026-11-07 19:58:24 +00:00
0d0b8e4085
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
280 lines
12 KiB
TypeScript
280 lines
12 KiB
TypeScript
/**
|
||
* 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<Buffer> {
|
||
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<Buffer>((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<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());
|
||
}
|