CV & Downloads
The CV is the theme’s most unusual subsystem, and the one most likely to make you look twice: /cv.pdf is a real PDF, generated at build time by a writer in src/js/cv.ts, with no PDF dependency of any kind.
The reason is not novelty. A CV that exists as a page and as a download is a drift hazard — the usual arrangement is a hand-maintained file in public/ that falls out of date the first time the page is edited. Here all three renderings are pure functions of one record, so they cannot disagree.
One record, one join
src/config/cvData.json.ts holds the career record: a revised stamp, a standfirst, and three arrays — careers, education and toolkit. It holds no identity facts.
The identity facts live in siteData.author, and the two meet in exactly one place:
export interface CvRecord extends CvData {
name: string;
role: string;
location: string;
email: string;
}
export function cvRecord(): CvRecord {
const { name, role, location, email } = siteData.author;
return { name, role, location, email, ...cvData };
}
That function is the single join. /cv/, /cv.txt and /cv.pdf all go through it, so none of them can assemble the record differently. The /about/ page reads cvData for its career log too, through the shared CareerList section.
The three renderings
| Route | Rendered by | Notes |
|---|---|---|
/cv/ |
src/pages/cv.astro |
the page inside the tube |
/cv.txt |
renderCvText |
plain text, monospace columns |
/cv.pdf |
renderCvPdf |
one A4 page, base-14 Courier |
Both endpoints are two-line files:
export const GET: APIRoute = () =>
new Response(renderCvPdf(), { headers: { "Content-Type": "application/pdf" } });
They are dynamic endpoints rather than files in public/ for the drift reason, and both prerender to static files at build.
The shared line grammar
The text and PDF renderers do not each format the record their own way. The parts that are content — how a numbered entry reads, how the toolkit columns align — are shared functions, and each renderer owns only presentation:
function entryPair(e: CvRecord["careers"][number], i: number): [string, string] {
return [
`${pad2(i + 1)} ${e.title.toUpperCase()}`,
` ${e.meta.toUpperCase()} · ${e.tag.toUpperCase()}`,
];
}
So the plain text renders that pair with dashes and columns; the PDF renders it with points and font switches. Neither can print a different set of facts.
The PDF writer
Roughly eighty lines, and worth reading if you have never seen the format up close. The design decisions that make it small:
Base-14 fonts, no embedding. Courier and Courier-Bold are two of the fourteen fonts every PDF reader is required to carry, so the file references them by name and ships no font data. The demo CV builds to 2,588 bytes — a PDF library would ship more than that in its own code before writing a line of the document.
Courier’s metrics are a constant. Every glyph is 600/1000 em wide, so column arithmetic is one multiplication:
const COURIER_EM = 0.6;
const columns = (size: number): number => Math.floor(bodyWidth / (COURIER_EM * size));
WinAnsi encoding, converted deliberately. toWinAnsi maps the typographic characters the record actually uses — the en dash, the middle dot — to their WinAnsi code points, passes ASCII through, and substitutes ? for anything it cannot represent. After that pass every character is at most 0xFF, which means string length is byte length — and that is the fact the byte-exact cross-reference table depends on.
pdfEscape handles the three characters that end or nest a PDF literal string — backslash and both parentheses. Miss one and the file is corrupt in a way that some readers render anyway, which is the worst kind of bug to find later.
The xref table is where a hand-rolled PDF usually goes wrong: every object’s byte offset must be exact, and a reader that validates strictly will reject the file otherwise. cv.test.ts is the check that keeps it honest.
The derived download labels
The /cv/ page’s two buttons illustrate the theme’s derivation habit at its sharpest:
const pdfName = `${slugify(siteData.name)}-cv.pdf`;
const pdfKb = Math.max(1, Math.round(renderCvPdf().byteLength / 1024));
const txtReadable = readableUrl(new URL("/cv.txt", Astro.site));
The filename comes from siteData.name, so what the page prints matches what the browser’s “save as” offers. The size label is measured from the same bytes the endpoint serves — on the demo record that renders as “3 KB”, and it will say something else the moment your CV is longer. And the plain-text button prints its own address through readableUrl, which strips the scheme and trailing slash.
None of those three values can drift, because none of them is stored.
Structured data
/cv/ emits a Person node with the job title, address, email, the standfirst as description, sameAs, and a reference to the site’s Organization.
It deliberately emits no image, and the comment says why: the CV frame draws no portrait, and schema must never state what the markup does not. That rule is worth carrying into your own edits — it is the difference between structured data that describes the page and structured data that describes what you wish were on it.
Editing the record
cvData.json.ts is 75 lines and typed against CvData. Each career and education entry is { title, meta, tag }; the toolkit is { key, value } pairs.
careers: [
{
title: "Independent — broadcast & telemetry tooling",
meta: "2022 — present · Bordeaux, FR · 22 engagements",
tag: "Tooling",
},
// …
],
Edit it and all four surfaces follow — the page, both downloads, and the About page’s career log. There is nothing to regenerate; the PDF is rebuilt every time the build runs.
One thing to watch: the renderers uppercase most fields, and the PDF wraps the standfirst at a computed column width. The writer emits a single page object (/Kids [3 0 R] /Count 1) and does not paginate, so a much longer record will run off the bottom of the sheet rather than flowing onto a second one. Check /cv.pdf after any substantial addition; adding pagination means emitting more page objects and tracking y across them.