#!/usr/bin/env node /** * Renders docs/manual-de-uso.html to PDF with page numbers. * * Chrome's `--print-to-pdf` flag cannot produce a custom footer, and Blink does * not implement CSS paged-media margin boxes (`@page { @bottom-center { … } }`), * so page numbers are impossible from CSS alone. Driving Chrome through the * DevTools protocol instead gives access to `Page.printToPDF`, which accepts a * footer template. * * Usage: node docs/build-pdf.mjs */ import { spawn } from 'node:child_process'; import { readFile, writeFile, mkdtemp } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join, resolve, dirname } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; const HERE = dirname(fileURLToPath(import.meta.url)); const SOURCE = resolve(HERE, 'manual-de-uso.html'); const TARGET = resolve(HERE, 'manual-de-uso.pdf'); const PORT = 9333; const CHROME_CANDIDATES = [ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Chromium.app/Contents/MacOS/Chromium', '/usr/bin/google-chrome', '/usr/bin/chromium', ]; async function findChrome() { for (const path of CHROME_CANDIDATES) { try { await readFile(path); return path; } catch { // Not readable as a file but may still be executable; probe via access. try { const { accessSync, constants } = await import('node:fs'); accessSync(path, constants.X_OK); return path; } catch { /* keep looking */ } } } throw new Error('No Chrome or Chromium found. Install one, or add its path to CHROME_CANDIDATES.'); } /** Poll the debugging endpoint until Chrome is ready to talk. */ async function waitForDebugger(timeoutMs = 15000) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { try { const response = await fetch(`http://127.0.0.1:${PORT}/json/version`); if (response.ok) return (await response.json()).webSocketDebuggerUrl; } catch { /* not up yet */ } await new Promise((r) => setTimeout(r, 150)); } throw new Error('Chrome did not expose its debugging port in time.'); } /** Minimal CDP client over the WebSocket built into Node 22. */ function connect(url) { const socket = new WebSocket(url); const pending = new Map(); const listeners = new Map(); let nextId = 0; socket.addEventListener('message', (event) => { const message = JSON.parse(event.data); if (message.id !== undefined && pending.has(message.id)) { const { resolve: ok, reject } = pending.get(message.id); pending.delete(message.id); message.error ? reject(new Error(message.error.message)) : ok(message.result); return; } (listeners.get(message.method) ?? []).forEach((fn) => fn(message.params)); }); const ready = new Promise((ok, reject) => { socket.addEventListener('open', ok, { once: true }); socket.addEventListener('error', () => reject(new Error('Could not open the CDP socket.')), { once: true }); }); return { ready, send(method, params = {}, sessionId) { const id = ++nextId; return new Promise((ok, reject) => { pending.set(id, { resolve: ok, reject }); socket.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) })); }); }, on(method, fn) { listeners.set(method, [...(listeners.get(method) ?? []), fn]); }, close: () => socket.close(), }; } const FOOTER = `
Manual de uso · Panel del colectivo /
`; const chrome = await findChrome(); const profile = await mkdtemp(join(tmpdir(), 'pdf-chrome-')); const process_ = spawn(chrome, [ '--headless', '--disable-gpu', '--no-first-run', `--remote-debugging-port=${PORT}`, `--user-data-dir=${profile}`, 'about:blank', ], { stdio: 'ignore' }); try { const browserUrl = await waitForDebugger(); const client = connect(browserUrl); await client.ready; const { targetId } = await client.send('Target.createTarget', { url: 'about:blank' }); const { sessionId } = await client.send('Target.attachToTarget', { targetId, flatten: true }); await client.send('Page.enable', {}, sessionId); const loaded = new Promise((ok) => client.on('Page.loadEventFired', ok)); await client.send('Page.navigate', { url: pathToFileURL(SOURCE).href }, sessionId); await loaded; // Give web fonts and layout a moment to settle before measuring pages. await new Promise((r) => setTimeout(r, 400)); const { data } = await client.send('Page.printToPDF', { printBackground: true, preferCSSPageSize: true, displayHeaderFooter: true, headerTemplate: '
', footerTemplate: FOOTER, marginTop: 0.79, marginBottom: 0.71, marginLeft: 0.71, marginRight: 0.71, }, sessionId); await writeFile(TARGET, Buffer.from(data, 'base64')); client.close(); console.log(`PDF escrito en ${TARGET}`); } finally { process_.kill(); }