I wanted page.pdf() to produce a report with a page-number footer, a coloured banner and A4 pages. Four things behaved differently from what I expected, so I measured each one. I read the PDFs back with pdf.js, which gives text size, position, fill colours and page size, so nothing below was judged by eye.
The short version:
- Without
printBackground: truethe background colour is dropped. White text on it stays, on a white page. - A header or footer template without a
font-sizeis drawn at about 0.7 pt. The text is in the PDF and nobody can read it. - A CSS
@page { size }is ignored unlesspreferCSSPageSize: true, but its orientation is not. A landscape@pageturned myformat: 'A4'into a landscape page. -
break-inside: avoidstops cards from splitting across pages. A<thead>repeats on every page, a plain<tr>header does not.
Setup
Playwright 1.63.0 (Chromium 153.0.8010.12), pdfjs-dist 6.5.438, Node v24.22.2, Linux, 2026-11-06. All pages come from page.setContent(), so no network is involved.
npm i playwright@1.63.0 pdfjs-dist First a helper that reads a PDF back: page size, text items with font size and y position, and the fill colours the page uses.
// inspect.mjs import { getDocument, OPS } from 'pdfjs-dist/legacy/build/pdf.mjs'; // Per page: size in pt, text items with font size and y, and the fill colours used. export async function inspect(buf) { const doc = await getDocument({ data: new Uint8Array(buf) }).promise; const pages = []; for (let n = 1; n <= doc.numPages; n++) { const page = await doc.getPage(n); const [x0, y0, x1, y1] = page.view; const items = (await page.getTextContent()).items .filter((t) => t.str.trim()) .map((t) => ({ str: t.str.trim(), size: +t.transform[0].toFixed(1), y: Math.round(t.transform[5]) })); const ops = await page.getOperatorList(); const fills = new Set(); ops.fnArray.forEach((fn, k) => { if (fn === OPS.setFillRGBColor) fills.add(ops.argsArray[k][0]); }); pages.push({ size: `${Math.round(x1 - x0)}x${Math.round(y1 - y0)}`, text: items.map((i) => i.str).join(' '), items, fills: [...fills], }); } return pages; } Then one script that runs every experiment. I will go through the output section by section.
// run.mjs import { chromium } from 'playwright'; import { inspect } from './inspect.mjs'; const browser = await chromium.launch(); const page = await browser.newPage(); console.log('chromium', browser.version()); // 1. Backgrounds await page.setContent(`<style>.bar{background:#c00;color:#fff;padding:8px}</style> <div class="bar">Banner</div><p>Body</p>`); for (const printBackground of [false, true]) { const [p] = await inspect(await page.pdf({ printBackground })); console.log('printBackground', printBackground, '-> fills', p.fills.join(' ')); } // 2. Header and footer await page.setContent('<p>Body text</p>'); const m = { top: '20mm', bottom: '20mm' }; const cases = { 'no size, margins': { headerTemplate: '<div>MYHEADER</div>', margin: m }, 'no size, no margin': { headerTemplate: '<div>MYHEADER</div>' }, 'font-size 9px, margins': { headerTemplate: '<div style="font-size:9px">MYHEADER</div>', margin: m }, 'footer with page numbers': { headerTemplate: '<div></div>', footerTemplate: '<div style="font-size:9px">Page <span class="pageNumber"></span> of <span class="totalPages"></span></div>', margin: m, }, }; for (const [name, opts] of Object.entries(cases)) { const [p] = await inspect(await page.pdf({ displayHeaderFooter: true, footerTemplate: '<div></div>', ...opts })); console.log(name.padEnd(25), p.items.map((i) => `${i.str} (${i.size}pt, y=${i.y})`).join(' | ')); } // 3. Page size for (const css of ['size:A4 portrait', 'size:A4 landscape', 'size:A5', 'size:100mm 150mm']) { await page.setContent(`<style>@page{${css}}</style><p>Hi</p>`); const size = async (o) => (await inspect(await page.pdf(o)))[0].size; console.log(css.padEnd(18), 'default', await size({}), '| preferCSSPageSize', await size({ preferCSSPageSize: true }), '| format A4', await size({ format: 'A4' })); } // 4. Page breaks const card = (i) => `<div class="card"><b>Card ${i}</b><br>line a ${i}<br>line b ${i}<br>line c ${i}</div>`; const cards = Array.from({ length: 30 }, (_, i) => card(i + 1)).join(''); for (const [name, extra] of [['plain', ''], ['break-inside: avoid', '.card{break-inside:avoid}']]) { await page.setContent(`<style>body{font:14px sans-serif}.card{border:1px solid #333;margin:0 0 10px;padding:6px;line-height:20px}${extra}</style>${cards}`); const pages = await inspect(await page.pdf({ margin: { top: '20mm', bottom: '20mm' } })); const seen = {}; pages.forEach((p, i) => { for (const [, id] of p.text.matchAll(/(?:Card |line [abc] )(\d+)/g)) (seen[id] ??= new Set()).add(i + 1); }); const split = Object.keys(seen).filter((id) => seen[id].size > 1); console.log(name.padEnd(20), `${pages.length} pages, split cards: [${split}]`); } const rows = Array.from({ length: 70 }, (_, i) => `<tr><td>row ${i + 1}</td><td>x</td></tr>`).join(''); for (const [name, head] of [['thead', '<thead><tr><th>HEADCELL</th></tr></thead>'], ['plain tr', '<tr><th>HEADCELL</th></tr>']]) { await page.setContent(`<table>${head}<tbody>${rows}</tbody></table>`); const pages = await inspect(await page.pdf()); const on = pages.flatMap((p, i) => (p.text.includes('HEADCELL') ? [i + 1] : [])); console.log(name.padEnd(20), `${pages.length} pages, HEADCELL on pages [${on}]`); } await page.setContent('<style>.s{break-after:page}</style><div class="s">One</div><div class="s">Two</div><div class="s">Three</div>'); console.log('break-after last ', (await inspect(await page.pdf())).length, 'pages for 3 sections'); await browser.close(); Output of that run:
chromium 153.0.8010.12 printBackground false -> fills #ffffff #ababab #000000 printBackground true -> fills #cc0000 #ffffff #000000 no size, margins Body text (12pt, y=719) | MY H E A D E R (0.7pt, y=776) no size, no margin Body text (12pt, y=776) | MY H E A D E R (0.7pt, y=776) font-size 9px, margins Body text (12pt, y=719) | MYHEADER (6.7pt, y=770) footer with page numbers Body text (12pt, y=719) | Page 1 of 1 (6.7pt, y=17) size:A4 portrait default 612x792 | preferCSSPageSize 595x842 | format A4 596x843 size:A4 landscape default 792x612 | preferCSSPageSize 842x595 | format A4 843x596 size:A5 default 612x792 | preferCSSPageSize 420x595 | format A4 596x843 size:100mm 150mm default 612x792 | preferCSSPageSize 283x425 | format A4 596x843 plain 4 pages, split cards: [9,26] break-inside: avoid 4 pages, split cards: [] thead 2 pages, HEADCELL on pages [1,2] plain tr 2 pages, HEADCELL on pages [1] break-after last 3 pages for 3 sections Backgrounds are off by default
The banner in the fixture has background:#c00 and color:#fff. With the default options the PDF contains no #cc0000 fill. The text is still white. The result is white text on white paper. With printBackground: true the red fill appears. The option docs list printBackground as defaulting to false, so this is documented, just easy to miss when your screen preview looks fine.
The default run also lists a grey fill, #ababab, that I did not track down. It is not the banner.
A header template without font-size is 0.7 pt
I passed <div>MYHEADER</div> as headerTemplate. The header is in the PDF, but pdf.js reports it at 0.7 pt, and the letters come out spaced (MY H E A D E R). With font-size:9px on the div it is 6.7 pt, which is 9 px converted to points. That is the only difference between the two cases.
The second finding is about margins. With no top margin, the header and the first line of the body are both at y=776 on a 792 pt page. They overlap. With margin: { top: '20mm' } the body moves down to y=719 and the header has its own space.
The docs say that script tags in templates are not evaluated and page styles are not visible inside them. They do not mention the font size, so set it inline in every template. The page-number classes from the same docs work once the size is set: the footer with page numbers row shows Page 1 of 1 at 6.7 pt, near the bottom edge (y=17).
@page size: dimensions ignored, orientation not
Four @page rules, each rendered three ways:
- Default options: the page is always Letter, 612x792 pt. For
A4 landscapeit is 792x612, so the orientation arrived and the size did not. -
preferCSSPageSize: true: the page matches the CSS (595x842 for A4, 283x425 for 100x150 mm). -
format: 'A4': 596x843 for portrait rules, and 843x596 for landscape ones. The@pagerule changed the orientation of a page whose size I set in code.
The docs say that preferCSSPageSize gives the CSS size priority over width, height and format. They do not say that orientation still passes through when it is off. The script only tries A4 in both orientations, A5 and 100x150 mm portrait, so I do not know how every size keyword behaves. If your stylesheet is shared between screen and print, either set preferCSSPageSize: true and let CSS own the size, or keep size out of @page and use only margins there.
Page breaks
30 cards of four lines each, 20 mm top and bottom margins:
- Plain: 4 pages, and cards 9 and 26 are cut in two.
-
.card { break-inside: avoid }: still 4 pages, no card is split.
A table with 70 rows: with <thead> the header text is on pages 1 and 2, with a normal <tr> of <th> cells it is only on page 1. If a report has long tables, <thead> is all it takes.
I also tried break-after: page on every section, including the last one, because I remembered it leaving a blank page at the end. With three sections I got three pages. I could not reproduce a blank trailing page in this Chromium version. I did not try break-before on a nested element or a table row.
Limits
- This is one Chromium build and one Playwright version. Run the script on yours before relying on a number.
- pdf.js tells me what is in the file, not how it looks. "Unreadable" for the 0.7 pt header is a conclusion from the size, I did not rasterise the page.
- I did not test web fonts, right-to-left text or CJK. Font embedding is a separate problem.
Written with AI assistance. Every code sample was run on 2026-11-06 with Playwright 1.63.0 (Chromium 153.0.8010.12) and pdfjs-dist 6.5.438; the output above is from that run.