\n\n
\n\n\nRendering HTML to PDF from a script looks like it should be one line:
chrome --headless --print-to-pdf=out.pdf page.html
It works. The file appears. And then the command sits there forever. Your build hangs, your CI job times out, and the obvious fix quietly corrupts your output.
Everyone reaches for the same thing:
timeout 30 chrome --headless --print-to-pdf=out.pdf page.html
This is worse than the hang, because it fails silently. Chrome writes the PDF incrementally. If the timer fires while bytes are still going out, you get a file that exists, has a plausible size, and opens in most viewers — missing its last pages. Nothing errors. Nothing warns you. You find out when a client asks where page 9 went.
The reliable signal is not "has enough time passed" but "has the file stopped growing". Poll the size; when it holds steady across consecutive checks and the file is non-empty, the write is done and you can kill the process:
def run_chrome(chrome, args, out, timeout=60, settle=3):
"""Chrome headless does not exit after writing output.
Size-stability is the reliable signal."""
proc = subprocess.Popen([chrome, *args],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL)
stable, last, deadline = 0, -1, time.time() + timeout
while time.time() < deadline:
if proc.poll() is not None: # exited on its own
break
size = os.path.getsize(out) if os.path.exists(out) else 0
stable = stable + 1 if size == last and size > 0 else 0
if stable >= settle: # steady across N polls
break
last = size
time.sleep(0.4)
proc.terminate()
try: proc.wait(timeout=5)
except subprocess.TimeoutExpired: proc.kill()
Three consecutive stable polls at 0.4 s is a good default: fast pages finish in about a second, and a slow one takes as long as it takes without a magic number in your build script.
Give every run its own profile directory. Without one, a second invocation finds the first Chrome's lock, attaches to that instance instead of starting a new one, and returns immediately — having rendered nothing. Your script reports success and the output file is whatever the previous run left behind:
--user-data-dir=$(mktemp -d)
Animations render mid-flight. A page with a CSS transition or a chart that animates in will be captured wherever it happened to be. Freeze it:
--virtual-time-budget=2000 --force-prefers-reduced-motion
Having fixed the truncation, I wanted to assert the page count in CI. The obvious approach
reads the first /Count in the PDF and compares it. Mine reported 8 pages for
an 11-page document, and I nearly "fixed" a document that was already correct.
PDF page trees can nest. The first /Count you encounter may describe a
subtree, not the document. Count the leaves instead:
pages = data.count(b'/Type /Page') - data.count(b'/Type /Pages')
The watchdog, the profile isolation, the motion freezing and exact-size PNG rendering are in a single-file tool with no dependencies beyond a Chrome you already have.
Render HTML to exact-size PNGs and PDFs from the command line. One Python file, MIT.