Running headers and a table of contents in Chrome PDFs with Paged.js and Playwright
Chrome's PDF output doesn't implement CSS paged media: @page margin boxes, counter(pages), string-set running headers, target-counter() for page references. Paged.js is a polyfill that does the pagination itself in the browser, so Chrome only has to print the result.
Here's how to wire it into a Playwright PDF pipeline.
1. Write the print CSS you actually wanted
@page {
size: A4;
margin: 20mm 15mm;
@top-right { content: string(chapter); font-size: 9pt; color: #666; }
@bottom-center { content: "Page " counter(page) " of " counter(pages); font-size: 9pt; }
}
@page :first {
@top-right { content: none; } /* no running header on the cover */
}
h1 { string-set: chapter content(text); break-before: page; }
/* Table of contents with real page numbers, right-aligned */
.toc a { display: flex; }
.toc a::after { content: target-counter(attr(href), page); margin-left: auto; }
You'll see leader(".") in the paged-media spec for dotted leaders. Paged.js 0.4.3 doesn't support it, and using it silently blanks the whole content value, page number included. Stick to the flex layout above.
<nav class="toc">
<a href="#intro">Introduction</a>
<a href="#results">Results</a>
</nav>
<h1 id="intro">Introduction</h1> ...
<h1 id="results">Results</h1> ...
2. Load Paged.js and know when it has finished
Paged.js lays out pages after load, so you have to wait for it before printing. Set a config object with an after hook before the polyfill script loads:
<script>
window.PagedConfig = { auto: true, after: () => { window.pagedDone = true; } };
</script>
<script src="https://unpkg.com/[email protected]/dist/paged.polyfill.js"></script>
3. Print with Playwright
import { chromium } from 'playwright';
const browser = await chromium.launch();
const page = await browser.newPage();
await page.setContent(html, { waitUntil: 'networkidle' });
await page.waitForFunction(() => window.pagedDone === true, null, { timeout: 30_000 });
await page.pdf({
path: 'book.pdf',
preferCSSPageSize: true, // let @page decide the size and margins
printBackground: true,
});
await browser.close();
Don't also set displayHeaderFooter. Paged.js already drew the margin boxes as part of the page.
The catches
- Speed. Paged.js does layout in JavaScript. Short documents are quick, but long ones take noticeably longer, so time it with your real documents and size the
waitForFunctiontimeout to match. - Pin the version. The example pins 0.4.3 in the URL. Better still, serve your own copy of the polyfill so a CDN outage or upgrade can't change your layout.
- Coverage. It supports a large part of the paged-media spec, not all of it. Test the features you rely on.
- Running Chrome is still your job. Memory, crashes, fonts and timeouts in production haven't gone anywhere.
I'm building Galley PDF, an HTML-to-PDF API with paged-media layout built in, so you send HTML and CSS and skip the browser plumbing. Join the waitlist.