You can turn any HTML into a print-ready PDF with headless Chrome and about 60 lines of Node.js, no Puppeteer and no npm packages: start Chrome with a debugging port, open its WebSocket, and send two DevTools Protocol commands, Page.navigate and Page.printToPDF. Here is the whole script, the CSS that makes the page size right, and four things that go wrong.
We use exactly this on Triplli, our sister product for travel agencies, to make its free downloads: a blank and a filled itinerary format and a payment receipt format, each printed from the same HTML that the guide page shows. The document is drawn once, in HTML, and the PDF, the picture and the page can never disagree.
Why not Puppeteer
Puppeteer is fine, and it downloads its own copy of Chrome on install. For a build script that prints a handful of documents from a Chrome already on the machine, the Chrome DevTools Protocol (CDP) directly is smaller and has nothing to keep up to date. Node.js 22 has a global WebSocket, so there is no dependency at all.
Step 1: start Chrome with a debugging port
import { spawn } from "node:child_process"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; const CHROME = process.env.CHROME || "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"; const port = 9222 + Math.floor(Math.random() * 500); const profile = mkdtempSync(join(tmpdir(), "pdf-")); const chrome = spawn(CHROME, [ "--headless=new", "--disable-gpu", "--no-first-run", `--remote-debugging-port=${port}`, `--user-data-dir=${profile}`, "about:blank", ], { stdio: "ignore" }); A fresh --user-data-dir per run keeps your own Chrome profile out of it, and a random port lets two runs work side by side. On Linux the binary is usually google-chrome or chromium.
Step 2: find the page and connect
Chrome lists its open pages, each with a WebSocket URL, at /json/list. It takes a moment to start, so poll:
const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); let target; for (let i = 0; i < 50 && !target; i++) { try { const list = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json(); target = list.find((t) => t.type === "page"); } catch { await sleep(200); } } if (!target) throw new Error("chrome did not start"); const ws = new WebSocket(target.webSocketDebuggerUrl); await new Promise((r) => (ws.onopen = r)); let id = 0; const pending = new Map(); ws.onmessage = (ev) => { const m = JSON.parse(ev.data); if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); } }; const send = (method, params = {}) => new Promise((r) => { const i = ++id; pending.set(i, r); ws.send(JSON.stringify({ id: i, method, params })); }); const evaluate = async (expression) => (await send("Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true })).result.result.value; Every CDP command is a JSON message with an id; the reply carries the same id. That small send is the whole client.
Step 3: open the page and wait for fonts
await send("Page.enable"); await send("Emulation.setDeviceMetricsOverride", { width: 860, height: 1400, deviceScaleFactor: 1.5, mobile: false }); await send("Page.navigate", { url: "http://localhost:4323/guides/itinerary-format/" }); await sleep(1500); await evaluate("document.fonts.ready.then(() => true)"); Emulation.setDeviceMetricsOverride sets the layout width the PDF is printed from. Use it rather than --window-size: headless Chrome's window will not go below about 500 pixels wide, which matters when you also screenshot phone layouts.
Step 4: keep only the document
The guide page has a header, a footer and the article around the document. For the PDF we want the document alone, with its styles:
await evaluate(`(() => { const el = document.querySelector("#it-sample"); const s = document.createElement("style"); s.textContent = "@page { size: A4; margin: 12mm } html, body { background: #fff; margin: 0 }"; document.head.append(s); document.body.replaceChildren(el); return true; })()`); replaceChildren moves the element into an otherwise empty body. The page's stylesheets stay in <head>, so it looks exactly as it did on the page. A blank version of the same document can sit on the page hidden (display: none) and be printed the same way after setting el.style.display = "block".
Step 5: print
import { writeFileSync } from "node:fs"; const r = await send("Page.printToPDF", { preferCSSPageSize: true, printBackground: true }); writeFileSync("itinerary-format.pdf", Buffer.from(r.result.data, "base64")); Two options do most of the work:
| Option | What it does | Without it |
|---|---|---|
preferCSSPageSize: true | Uses @page { size: A4 } from your CSS | US Letter, 8.5 by 11 inches |
printBackground: true | Prints background colours and shading | Table headers and boxes print white |
To keep a block of the document from splitting across two pages, give it break-inside: avoid in CSS. Chrome honours it in printToPDF.
Step 6: a picture of it, for image search
The same session can screenshot the element, cropped to its size, even when it is taller than the viewport:
const rect = await evaluate(`(() => { const r = document.body.firstElementChild.getBoundingClientRect(); return { x: r.x, y: r.y + scrollY, width: r.width, height: r.height }; })()`); const shot = await send("Page.captureScreenshot", { format: "png", captureBeyondViewport: true, clip: { ...rect, scale: 1 }, }); writeFileSync("itinerary-format.png", Buffer.from(shot.result.data, "base64")); On Triplli the filled itinerary picture sits beside the page heading with ImageObject markup, so the format can show up in image results as well as web results.
Four things that go wrong
- Fonts print as the fallback. The PDF was printed before the web font arrived. Wait on
document.fonts.readyfirst. - Everything prints on Letter.
@page { size: A4 }does nothing untilpreferCSSPageSizeis true. - Animated content stays hidden. Headless Chrome reports
prefers-reduced-motion, and elements that fade in on scroll may stay at opacity 0. Force them visible in the injected style, or callEmulation.setEmulatedMediawithprefers-reduced-motion: no-preference. - Cleanup crashes after a good run. This one bit us: deleting the temporary profile on exit failed with
ENOTEMPTY, because Chrome was still writing to it while being killed. All the files were made; wrap the cleanup intry { rmSync(profile, { recursive: true, force: true }) } catch {}.
Closing
ws.close(); chrome.kill(); try { rmSync(profile, { recursive: true, force: true }); } catch {} That is the whole printer. The same approach makes Cabaro's duty slip for taxi operators, another sister product, in blank and filled form. For documents a server makes on demand, for every customer, a PDF library in the app is usually the better fit; for a build step that prints a few files from HTML you already have, headless Chrome and CDP are hard to beat.
First published on signops.dev. Written with AI assistance and checked against the script it describes.
