How to test print styles of a page
Emulate print media with Emulation.setEmulatedMedia, then read computed styles and diff them against the screen. On the invoice below that found three blocks hidden by the print stylesheet, including the total due, 55 of 60 line items clipped by a fixed-height container, and hero text at 3.58:1 contrast on paper.
Why check this
Run this whenever a document is meant to leave the screen: an invoice, a boarding pass, a packing list. It belongs in the release that adds the print stylesheet and in the regression pass after a layout refactor, because print rules are invisible to every other test you have.
The failure it prevents is a printed invoice with no total on it. A rule written as nav, aside, .no-print { display: none } looks harmless until someone moves the totals block into an aside. The screen keeps working, so the customer receives a page of line items and no amount due.
Prerequisites
- Chrome 152 and Node 22, with
npm i puppeteer-core. The driver uses the installed Chrome. - The figures below are one capture on one machine, taken on 2026-09-12. Chrome's print rendering changes between versions.
- The manual equivalent of step 2: press F12, open the Command menu with Ctrl-Shift-P, run Show Rendering, and set Emulate CSS media type to print. Chrome then applies print rules to the live page.
- Emulation.setEmulatedMedia is the protocol method behind that control.
- Save this as
print-server.mjs. It plants four print defects at once: a container with a fixed height, adisplay: nonerule that catches the totals, dark hero colours, and links with no printed destination. Port 8865 was free here.
// print-server.mjs run: node print-server.mjs stop: Ctrl-C
import http from 'node:http';
const lines = Array.from({ length: 60 }, (_, i) => `<p>Item ${i + 1}: part number PN-${4400 + i}, quantity 1</p>`).join('\n ');
const page = `<!doctype html><meta charset="utf-8">
<title>invoice 4417</title>
<style>
body{margin:0;font:16px/1.5 system-ui}
.hero{background:#101418;color:#cfd8e3;padding:24px}
.items{height:200px;overflow:auto;border:1px solid #ccc;padding:8px}
.totals{border-top:2px solid #333;padding:8px;font-weight:700}
nav{padding:8px;background:#eee}
@media print{
nav, aside, .no-print{display:none}
.items{border:0}
}
</style>
<div class="hero"><h1>Invoice 4417</h1><p>Issued 2026-09-11, due 2026-10-11</p></div>
<nav><a href="/">Home</a> <a href="/orders">Orders</a></nav>
<div class="items">
${lines}
</div>
<aside class="totals">Total due: 1240.00 EUR</aside>
<p class="no-print">Print this page for your records.</p>
<p>Terms: <a href="https://example.com/terms">our terms of sale</a> and
<a href="https://example.com/returns">returns policy</a>.</p>
`;
http.createServer((req, res) => {
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
res.end(page);
}).listen(8865, () => console.log('print probe on http://localhost:8865/'));
Steps
- Step 1.
Start the page under test. Stop it with Ctrl-C at the end.
node print-server.mjsprint probe on http://localhost:8865/ - Step 2.
Switch the page to print media and list what disappeared. Save this as
print-diff.mjs.// print-diff.mjs import { launch } from 'puppeteer-core'; const browser = await launch({ executablePath: 'C:/Program Files/Google/Chrome/Application/chrome.exe', headless: true }); const page = await browser.newPage(); const cdp = await page.createCDPSession(); const snap = () => page.evaluate(() => [...document.querySelectorAll('body *')].map((el) => ({ name: el.tagName.toLowerCase() + (typeof el.className === 'string' && el.className ? '.' + el.className.trim().split(/\s+/).join('.') : ''), display: getComputedStyle(el).display, text: (el.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 40), }))); await page.goto('http://localhost:8865/', { waitUntil: 'networkidle2' }); console.log('matchMedia("print").matches on screen: ' + await page.evaluate(() => matchMedia('print').matches)); const screen = await snap(); await cdp.send('Emulation.setEmulatedMedia', { media: 'print' }); console.log('matchMedia("print").matches after Emulation.setEmulatedMedia: ' + await page.evaluate(() => matchMedia('print').matches)); const print = await snap(); console.log('\nvisible on screen, display:none in print:'); screen.forEach((a, i) => { if (a.display !== 'none' && print[i].display === 'none') console.log(` ${print[i].name.padEnd(14)} "${print[i].text}"`); }); console.log('\nrules that only apply to print, from the CSSOM:'); console.log(await page.evaluate(() => [...document.styleSheets].flatMap((ss) => [...ss.cssRules]) .filter((r) => r.media && [...r.media].join().includes('print')) .flatMap((r) => [...r.cssRules].map((x) => ' ' + x.cssText)).join('\n'))); await browser.close();matchMedia("print").matches on screen: false matchMedia("print").matches after Emulation.setEmulatedMedia: true visible on screen, display:none in print: nav "Home Orders" aside.totals "Total due: 1240.00 EUR" p.no-print "Print this page for your records." rules that only apply to print, from the CSSOM: nav, aside, .no-print { display: none; } .items { border: 0px; }The middle line of the diff is the defect. The stylesheet hides
aside, and the amount due lives in anaside. Reading the rule text alone would not have caught it: the rule is fine, and the markup is what makes it wrong. - Step 3.
Find content that the paper cannot reach. A box that scrolls on screen has no scrollbar in print, so whatever is past its height is dropped. Save this as
print-clip.mjs.// print-clip.mjs import { launch } from 'puppeteer-core'; import { writeFileSync } from 'node:fs'; const browser = await launch({ executablePath: 'C:/Program Files/Google/Chrome/Application/chrome.exe', headless: true }); const page = await browser.newPage(); const cdp = await page.createCDPSession(); await page.goto('http://localhost:8865/', { waitUntil: 'networkidle2' }); await cdp.send('Emulation.setEmulatedMedia', { media: 'print' }); console.log(await page.evaluate(() => { const box = document.querySelector('.items'); const cs = getComputedStyle(box); const items = [...box.querySelectorAll('p')]; const clip = box.getBoundingClientRect().bottom; const lost = items.filter((p) => p.getBoundingClientRect().top >= clip); return [ `.items height ${cs.height} overflow-y ${cs.overflowY}`, ` clientHeight ${box.clientHeight} scrollHeight ${box.scrollHeight}`, ` items in the DOM ${items.length}, items below the clip ${lost.length}`, ` last item that prints: "${items[items.length - lost.length - 1].textContent.trim()}"`, ` first item that does not: "${lost[0].textContent.trim()}"`, ].join('\n'); })); const { data } = await cdp.send('Page.printToPDF', { printBackground: false }); const pdf = Buffer.from(data, 'base64'); writeFileSync('invoice.pdf', pdf); const count = /\/Count\s+(\d+)/.exec(pdf.toString('latin1')); console.log(`printToPDF: ${pdf.length} bytes, ${count ? count[1] : '?'} page(s)`); await browser.close();.items height 200px overflow-y auto clientHeight 216 scrollHeight 2432 items in the DOM 60, items below the clip 55 last item that prints: "Item 5: part number PN-4404, quantity 1" first item that does not: "Item 6: part number PN-4405, quantity 1" printToPDF: 24832 bytes, 1 page(s)scrollHeight2432 againstclientHeight216 means the box holds eleven times the content it shows. On screen the reader scrolls. On paper 55 of the 60 items are gone. The page count is the second signal: a 60 item invoice that fits on one sheet has lost something. - Step 4.
Read what the printer will actually put on the paper. Computed styles keep the dark background, so print two PDFs and compare the fill colours inside them. Save this as
print-ink.mjs.// print-ink.mjs import { launch } from 'puppeteer-core'; import { inflateSync } from 'node:zlib'; const lum = ([r, g, b]) => { const f = (v) => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4); return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b); }; const onWhite = (c) => (1.05 / (lum(c) + 0.05)).toFixed(2); const browser = await launch({ executablePath: 'C:/Program Files/Google/Chrome/Application/chrome.exe', headless: true }); const page = await browser.newPage(); const cdp = await page.createCDPSession(); await page.goto('http://localhost:8865/', { waitUntil: 'networkidle2' }); await cdp.send('Emulation.setEmulatedMedia', { media: 'print' }); console.log(await page.evaluate(() => { const cs = getComputedStyle(document.querySelector('.hero')); return `.hero in print media: color ${cs.color}, background-color ${cs.backgroundColor}, print-color-adjust ${cs.printColorAdjust}`; })); for (const printBackground of [false, true]) { const { data } = await cdp.send('Page.printToPDF', { printBackground }); const buf = Buffer.from(data, 'base64'); const text = buf.toString('latin1'); const fills = new Set(); for (const m of text.matchAll(/stream\r?\n/g)) { const start = m.index + m[0].length; try { const body = inflateSync(buf.subarray(start, text.indexOf('endstream', start))).toString('latin1'); for (const c of body.match(/[\d.]+ [\d.]+ [\d.]+ rg/g) ?? []) fills.add(c); } catch { /* not a deflate stream */ } } console.log(`\nprintToPDF printBackground:${printBackground} ${buf.length} bytes`); for (const f of fills) { const v = f.split(' ').slice(0, 3).map(Number); console.log(` ${f.padEnd(26)} rgb(${v.map((x) => Math.round(x * 255)).join(', ')}) on white paper ${onWhite(v)}:1`); } } await browser.close();.hero in print media: color rgb(207, 216, 227), background-color rgb(16, 20, 24), print-color-adjust economy printToPDF printBackground:false 24832 bytes 1 1 1 rg rgb(255, 255, 255) on white paper 1.00:1 .5098 .5333 .5608 rg rgb(130, 136, 143) on white paper 3.58:1 0 0 0 rg rgb(0, 0, 0) on white paper 21.00:1 0 0 .9333 rg rgb(0, 0, 238) on white paper 9.40:1 printToPDF printBackground:true 24841 bytes .0627 .0784 .0941 rg rgb(16, 20, 24) on white paper 18.50:1 .8118 .8471 .8902 rg rgb(207, 216, 227) on white paper 1.44:1 0 0 0 rg rgb(0, 0, 0) on white paper 21.00:1 0 0 .9333 rg rgb(0, 0, 238) on white paper 9.40:1Three facts in one block. The computed style still reports the dark background, so a check that reads computed values alone sees nothing wrong. With background graphics off, the default in Chrome's print dialog, the dark fill is gone and Chrome repainted the hero text from
rgb(207, 216, 227)torgb(130, 136, 143), which is 3.58:1 on white. With backgrounds on, the original colour survives at 1.44:1 against paper. - Step 5.
Check whether a printed link keeps its destination. Save this as
print-links.mjs.// print-links.mjs import { launch } from 'puppeteer-core'; const browser = await launch({ executablePath: 'C:/Program Files/Google/Chrome/Application/chrome.exe', headless: true }); const page = await browser.newPage(); const cdp = await page.createCDPSession(); const read = () => page.evaluate(() => [...document.querySelectorAll('a[href]')].map((a) => ` "${a.textContent.trim()}" -> ${a.getAttribute('href')} ::after content ${getComputedStyle(a, '::after').content}`).join('\n')); await page.goto('http://localhost:8865/', { waitUntil: 'networkidle2' }); await cdp.send('Emulation.setEmulatedMedia', { media: 'print' }); console.log('as served, print media:'); console.log(await read()); await page.addStyleTag({ content: '@media print { a[href^="http"]::after { content: " (" attr(href) ")" } }' }); console.log('after adding a[href^="http"]::after { content: " (" attr(href) ")" }:'); console.log(await read()); await browser.close();as served, print media: "Home" -> / ::after content none "Orders" -> /orders ::after content none "our terms of sale" -> https://example.com/terms ::after content none "returns policy" -> https://example.com/returns ::after content none after adding a[href^="http"]::after { content: " (" attr(href) ")" }: "Home" -> / ::after content none "Orders" -> /orders ::after content none "our terms of sale" -> https://example.com/terms ::after content " (https://example.com/terms)" "returns policy" -> https://example.com/returns ::after content " (https://example.com/returns)"A computed
contentofnonemeans the destination is not on the paper. The reader sees "our terms of sale" underlined in blue and has nothing to type into a browser. The second block showsattr(href)resolved to the real URL, which confirms the fix instead of assuming it.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| An element in the hidden list that carries data | A print rule hides content the reader needs | Name the element in the rule, or move the content out of the hidden container |
| scrollHeight well above clientHeight | A fixed height clips the rest on paper | Set height: auto and overflow: visible inside the print block |
| One PDF page for a long document | Content is being dropped, not paginated | Re-run step 3 and compare the item counts |
| Text below 4.5:1 on white in the PDF | The printed page is hard to read, or wastes ink | Set explicit color: #000 and background: none in the print block |
| ::after content none on external links | Destinations are lost in print | Add a[href^="http"]::after { content: " (" attr(href) ")" } |
Thresholds
Common mistakes
What to check next
- How to test prefers reduced motion: the same emulation call, with a media feature instead of a media type.
- How to check color contrast ratio: the method behind the 3.58:1 figure in step 4.
- How to find css breakpoints of a website: print is one more media query to find in the stylesheets.
- How to test responsive design: the wider sweep this check joins.
- How to find what causes horizontal scroll on mobile: content wider than the sheet is clipped in print the same way.
FAQ
How do I preview print CSS without printing?
Press F12, open the Command menu with Ctrl-Shift-P, run Show Rendering, and set Emulate CSS media type to print. The live page then uses the print rules, and every DevTools panel keeps working.
How do I debug a print stylesheet?
Emulate print media, then diff computed values against the screen snapshot, as step 2 does. Read the rules from the CSSOM rather than the source file, so imported and injected rules are included.
Why does my printed page have a white background when the design is dark?
The computed value for print-color-adjust is economy, so the browser may drop background painting, and Chrome's print dialog ships with background graphics off. Chrome also darkens light text when it does that, measured here from rgb(207, 216, 227) to rgb(130, 136, 143).
Can I test print output in CI?
Yes. Page.printToPDF returns the PDF bytes, and the page count plus the fill colours inside it are enough to assert on. Steps 3 and 4 read both with no print dialog.
Verified
Verified by Maks VernyChrome 152.0.7977.76node 22.23.2puppeteer-core 25.10.0
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
intermediate10 minpublished updated Maks Verny