/previz: password protect, add PDF export of the crowd previz
Deploy / deploy (push) Successful in 1m5s
Deploy / deploy (push) Successful in 1m5s
- nginx basic auth on /previz/ (login file lives on the server, mounted from docker/auth/, see DEPLOY.md step 8); private, no-store so Cloudflare never caches it - Print styles for previz pages: dark, A4 landscape, one section a page; videos print as poster frames with a link - scripts/previz-pdf.mjs (npm run previz:pdf) prints the page to public/previz/crowd/crowd-previz.pdf via Edge/Chrome DevTools - "PDF" download link on the page Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
// Prints a /previz page from the static export to a PDF next to its assets.
|
||||
// npm run build && node scripts/previz-pdf.mjs crowd
|
||||
// Drives Edge/Chrome over the DevTools protocol instead of --print-to-pdf: Chrome sizes the
|
||||
// print layout from the paper it is given, not from CSS @page, so the paper (A4 landscape)
|
||||
// has to be passed here for the desktop layout to kick in.
|
||||
import { spawn } from "node:child_process";
|
||||
import { createReadStream, existsSync, mkdtempSync, statSync, writeFileSync } from "node:fs";
|
||||
import { createServer } from "node:http";
|
||||
import { tmpdir } from "node:os";
|
||||
import { extname, join, resolve } from "node:path";
|
||||
|
||||
const slug = process.argv[2] ?? "crowd";
|
||||
const out = resolve("out");
|
||||
const dest = resolve("public/previz", slug, `${slug}-previz.pdf`);
|
||||
|
||||
const browser =
|
||||
process.env.CHROME ??
|
||||
[
|
||||
"C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe",
|
||||
"C:/Program Files/Google/Chrome/Application/chrome.exe",
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
"/usr/bin/google-chrome",
|
||||
].find(existsSync);
|
||||
if (!browser) throw new Error("No Chrome/Edge found; set CHROME=/path/to/browser");
|
||||
if (!existsSync(join(out, "previz", slug, "index.html"))) throw new Error("Run `npm run build` first");
|
||||
|
||||
const types = { ".html": "text/html", ".css": "text/css", ".js": "text/javascript", ".jpg": "image/jpeg", ".png": "image/png", ".svg": "image/svg+xml", ".mp4": "video/mp4", ".woff2": "font/woff2", ".txt": "text/plain" };
|
||||
const server = createServer((req, res) => {
|
||||
let file = join(out, decodeURIComponent(new URL(req.url, "http://x").pathname));
|
||||
if (existsSync(file) && statSync(file).isDirectory()) file = join(file, "index.html");
|
||||
if (!file.startsWith(out) || !existsSync(file)) return res.writeHead(404).end();
|
||||
res.writeHead(200, { "Content-Type": types[extname(file)] ?? "application/octet-stream" });
|
||||
createReadStream(file).pipe(res);
|
||||
}).listen(0);
|
||||
const port = server.address().port;
|
||||
|
||||
const proc = spawn(browser, [
|
||||
"--headless=new",
|
||||
"--disable-gpu",
|
||||
"--remote-debugging-port=0",
|
||||
`--user-data-dir=${mkdtempSync(join(tmpdir(), "previz-pdf-"))}`,
|
||||
"about:blank",
|
||||
]);
|
||||
const wsUrl = await new Promise((ok, fail) => {
|
||||
let buf = "";
|
||||
proc.stderr.on("data", (d) => {
|
||||
buf += d;
|
||||
const m = buf.match(/DevTools listening on (ws:\/\/\S+)/);
|
||||
if (m) ok(m[1]);
|
||||
});
|
||||
proc.on("exit", () => fail(new Error("Browser exited early")));
|
||||
});
|
||||
|
||||
// Attach to the blank tab and talk to it
|
||||
const targets = await (await fetch(wsUrl.replace("ws://", "http://").replace(/\/devtools\/browser\/.*/, "/json"))).json();
|
||||
const ws = new WebSocket(targets.find((t) => t.type === "page").webSocketDebuggerUrl);
|
||||
await new Promise((ok) => ws.addEventListener("open", ok, { once: true }));
|
||||
let id = 0;
|
||||
const pending = new Map();
|
||||
const events = [];
|
||||
ws.addEventListener("message", ({ data }) => {
|
||||
const msg = JSON.parse(data);
|
||||
if (msg.id && pending.has(msg.id)) {
|
||||
const { ok, fail } = pending.get(msg.id);
|
||||
pending.delete(msg.id);
|
||||
if (msg.error) fail(new Error(msg.error.message));
|
||||
else ok(msg.result);
|
||||
} else if (msg.method) events.push(msg.method);
|
||||
});
|
||||
const send = (method, params = {}) =>
|
||||
new Promise((ok, fail) => {
|
||||
pending.set(++id, { ok, fail });
|
||||
ws.send(JSON.stringify({ id, method, params }));
|
||||
});
|
||||
|
||||
try {
|
||||
await send("Page.enable");
|
||||
await send("Page.navigate", { url: `http://localhost:${port}/previz/${slug}/` });
|
||||
while (!events.includes("Page.loadEventFired")) await new Promise((r) => setTimeout(r, 100));
|
||||
// Lazy images and fonts: load everything, then give layout a beat
|
||||
await send("Runtime.evaluate", {
|
||||
expression: `Promise.all([document.fonts.ready, ...[...document.images].map(i => (i.loading = "eager", i.decode().catch(() => {})))])`,
|
||||
awaitPromise: true,
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
const { data } = await send("Page.printToPDF", {
|
||||
landscape: true,
|
||||
paperWidth: 8.27, // A4, inches (landscape swaps them)
|
||||
paperHeight: 11.69,
|
||||
marginTop: 0,
|
||||
marginBottom: 0,
|
||||
marginLeft: 0,
|
||||
marginRight: 0,
|
||||
printBackground: true,
|
||||
scale: 0.68, // lays out ~1650px wide: each section fits a page
|
||||
});
|
||||
writeFileSync(dest, Buffer.from(data, "base64"));
|
||||
console.log(`Wrote ${dest} (${Math.round(Buffer.byteLength(data, "base64") / 1024)} KB)`);
|
||||
} finally {
|
||||
ws.close();
|
||||
proc.kill();
|
||||
server.close();
|
||||
}
|
||||
Reference in New Issue
Block a user