mirror of
https://github.com/Patiun/com.gregkilmer.www.git
synced 2026-11-08 04:08: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:
@@ -16,6 +16,7 @@
|
||||
"@gk/ui": "*",
|
||||
"astro": "7.3.5",
|
||||
"marked": "18.0.14",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdfkit": "^0.20.2"
|
||||
},
|
||||
"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
|
||||
* 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<Buffer> {
|
||||
function renderBody(r: ResumeData): Promise<Buffer> {
|
||||
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<Buffer> {
|
||||
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<Buffer> {
|
||||
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<Buffer> {
|
||||
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<Buffer> {
|
||||
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<Buffer> {
|
||||
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<Buffer> {
|
||||
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<Buffer> {
|
||||
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<Buffer> {
|
||||
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<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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user