#!/usr/bin/env node /** * Renders `/bio/` to `public/pouya-lajevardi-bio.pdf`. `npm run bio:pdf`, * after `npm run build`. Discharges `AGENTS.md` R16 / Q45. * * WHY A LOCAL SCRIPT AND NOT A BUILD STEP. It drives Chrome, and the Gitea * runner has none (`AGENTS.md` §7, Q23) — the same reason `npm run lighthouse` * is a local gate. A build step that cannot run in CI is a control that exists * on paper, which is the shape Q22 turned out to be. So the PDF is **committed**: * the artefact is in the repository, which is also what R14 asks for. * * ⚠️ IT IS NOT BYTE-REPRODUCIBLE, AND AN EARLIER VERSION OF THIS COMMENT SAID * "deterministically". Two consecutive runs produced 89,496 bytes both times and * DIFFERENT SHA-256 digests — Chrome stamps a `/CreationDate` into the document. * Measured by `adversarial-reviewer`, 2026-08-31. * * The consequence is not cosmetic: the "regenerate and re-commit the PDF" item on * `docs/06`'s cutover checklist therefore always produces a binary diff, so a * reviewer cannot tell a real content change from a no-op re-render. Do not * re-commit it out of habit — re-commit it when `/bio/`, §4, the rate card or the * print styles actually changed, and say which in the commit message. * * WHY THE PDF IS A RENDERING OF A PAGE RATHER THAN A DESIGNED DOCUMENT. R16's * worry was never tooling: *"a PDF circulated with an appointment proposal is * read once, by the reader who matters most, and never seen by a reviewer * again."* Rendering it from `/bio/` puts it back inside this project's review * apparatus — `astro check`, `check:claims` on the built HTML, the adversarial * review and the cutover claims pass all see every word of it, because every * word of it is on a page. (That is what caught `/bio/` opening with a clause * that scoped mediation commercial, which Q56 forbids.) * * ⚠️ IT ASSERTS ONE PAGE. A one-page bio that silently becomes two is the defect * this script exists to catch, and it is invisible from the source: it depends on * the print stylesheet, the paper size, and how much §4 has grown since anyone * looked. `printBackground: false` matches Chrome's own default print dialog, * where "Background graphics" is unchecked — `global.css` records what that did * to `/about/`'s inverse band when nobody checked. */ import { createServer } from 'node:http'; import { createReadStream } from 'node:fs'; import { writeFile, stat } from 'node:fs/promises'; import { join, extname } from 'node:path'; import * as chromeLauncher from 'chrome-launcher'; const ROOT = process.cwd(); const DIST = join(ROOT, 'dist'); const OUT = join(ROOT, 'public', 'pouya-lajevardi-bio.pdf'); const MIME = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.avif': 'image/avif', '.webp': 'image/webp', '.woff2': 'font/woff2', '.ico': 'image/x-icon', '.svg': 'image/svg+xml', }; try { await stat(join(DIST, 'bio', 'index.html')); } catch { console.error( 'dist/bio/index.html is missing. Run `npm run build` first — this renders ' + 'the BUILT page, not the dev server, so what ships is what is measured.', ); process.exit(2); } const server = createServer((req, res) => { const pathname = decodeURIComponent(new URL(req.url, 'http://x').pathname); const file = pathname.endsWith('/') ? join(DIST, pathname, 'index.html') : join(DIST, pathname); const stream = createReadStream(file); stream.on('error', () => { res.writeHead(404); res.end('404'); }); stream.once('open', () => { res.writeHead(200, { 'content-type': MIME[extname(file)] ?? 'application/octet-stream', }); stream.pipe(res); }); }); await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); const port = server.address().port; const chrome = await chromeLauncher.launch({ chromeFlags: ['--headless', '--no-sandbox', '--disable-gpu'], }); /** Minimal CDP client over the DevTools WebSocket. `chrome-launcher` starts the * browser and does not speak the protocol; adding a client library for four * calls would be a dependency for nothing. */ async function cdp(port, fn) { const list = await fetch(`http://127.0.0.1:${port}/json/list`).then((r) => r.json(), ); const target = list.find((t) => t.type === 'page'); if (!target) throw new Error('no page target in Chrome'); const ws = new WebSocket(target.webSocketDebuggerUrl); await new Promise((resolve, reject) => { ws.addEventListener('open', resolve, { once: true }); ws.addEventListener('error', reject, { once: true }); }); let id = 0; const pending = new Map(); const events = new Map(); ws.addEventListener('message', (event) => { const message = JSON.parse(event.data); if (message.id && pending.has(message.id)) { const { resolve, reject } = pending.get(message.id); pending.delete(message.id); if (message.error) reject(new Error(JSON.stringify(message.error))); else resolve(message.result); } else if (message.method && events.has(message.method)) { events.get(message.method)(); } }); const send = (method, params = {}) => new Promise((resolve, reject) => { id += 1; pending.set(id, { resolve, reject }); ws.send(JSON.stringify({ id, method, params })); }); const once = (method) => new Promise((resolve) => events.set(method, resolve)); try { return await fn({ send, once }); } finally { ws.close(); } } let pdfBase64; try { pdfBase64 = await cdp(chrome.port, async ({ send, once }) => { await send('Page.enable'); const loaded = once('Page.loadEventFired'); await send('Page.navigate', { url: `http://127.0.0.1:${port}/bio/` }); await loaded; // The page self-hosts its fonts and `document.fonts.ready` is the only // reliable signal that they are laid out — a PDF printed before the serif // arrives is set in the fallback and looks nothing like the site. await send('Runtime.evaluate', { expression: 'document.fonts.ready', awaitPromise: true, }); const result = await send('Page.printToPDF', { // Letter, because this circulates in Canada with Canadian counsel. paperWidth: 8.5, paperHeight: 11, marginTop: 0.55, marginBottom: 0.55, marginLeft: 0.6, marginRight: 0.6, printBackground: false, preferCSSPageSize: false, }); return result.data; }); } finally { chrome.kill(); server.close(); } const pdf = Buffer.from(pdfBase64, 'base64'); /** * PAGE COUNT, ASSERTED. Counted from the PDF's own page objects rather than * trusting the layout — this is the whole reason the script exists rather than a * note telling someone to check. A one-page bio that quietly becomes two pages * is exactly the class of defect nobody looks for again. */ const text = pdf.toString('latin1'); const pageCount = (text.match(/\/Type\s*\/Page[^s]/g) ?? []).length || Number((/\/Count\s+(\d+)/.exec(text) ?? [])[1] ?? 0); console.log( `bio:pdf — ${pdf.length.toLocaleString()} bytes, ${pageCount} page(s), Letter.`, ); if (pageCount !== 1) { console.error( `\nTHE BIO IS ${pageCount} PAGES AND MUST BE ONE.\n` + ' It is specified as a one-page bio (docs/01 §/about/ item 7, R16), and a\n' + ' second sheet carrying three lines is worse than a denser first one.\n' + ' Tighten the @media print block in src/pages/bio.astro — do not widen\n' + ' the margins here, which changes the document rather than the layout.\n' + ' Nothing was written.', ); process.exit(1); } await writeFile(OUT, pdf); console.log(`wrote public/pouya-lajevardi-bio.pdf`); console.log( 'It is COMMITTED. Regenerate and re-commit it whenever /bio/, §4, the rate ' + 'card or the print styles change — nothing in the build does this for you.', );