Join the waitlist

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


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.