Files
landing-api/docs/build-pdf.mjs
Josep Piguillem 3897113313 Initial commit: Pireon Admin — headless admin panel and public API
Laravel 13 + Filament 5 admin panel and REST API for a non-profit developer
collective, backed by the Pireon OAuth identity server. The public site is a
separate, server-rendered application consuming /api/public/v1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 17:55:27 +02:00

161 lines
5.1 KiB
JavaScript

#!/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 = `
<div style="width:100%;font-size:8px;font-family:Helvetica,Arial,sans-serif;
color:#8a8f9c;padding:0 18mm;display:flex;justify-content:space-between;">
<span>Manual de uso · Panel del colectivo</span>
<span><span class="pageNumber"></span> / <span class="totalPages"></span></span>
</div>`;
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: '<div></div>',
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();
}