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

// 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

  1. Step 1.

    Start the page under test. Stop it with Ctrl-C at the end.

    node print-server.mjs
    
    print probe on http://localhost:8865/
  2. 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 an aside. Reading the rule text alone would not have caught it: the rule is fine, and the markup is what makes it wrong.

  3. 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)

    scrollHeight 2432 against clientHeight 216 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.

  4. 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:1

    Three 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) to rgb(130, 136, 143), which is 3.58:1 on white. With backgrounds on, the original colour survives at 1.44:1 against paper.

  5. 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 content of none means 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 shows attr(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

4.5:1 minimum contrast for normal text, which the printed hero text missed at 3.58:1 Source: https://www.w3.org/TR/WCAG22/#contrast-minimum
Chrome dropped a dark background and repainted rgb(207, 216, 227) text as rgb(130, 136, 143), measured from two printToPDF runs on 2026-09-12 Source: See the Verified block

Common mistakes

Sign: The print preview looks right, so the print stylesheet is signed off.Cause: The preview renders the paper, not the DOM, so nothing tells you what is missing from it. The diff in step 2 named the totals block by its text, and step 3 counted 55 line items that no preview can show, because they are not there to look at.
Sign: A container scrolls happily on screen and loses content on paper.Cause: Printing has no scrollbar. Any element with a fixed height and overflow auto or hidden prints only its first screenful. Here .items held 2432 px of content in a 216 px box and printed five of sixty items.
Sign: Computed styles say the print colours are fine.Cause: Computed style keeps background-color as the author wrote it, and print-color-adjust: economy lets the browser drop it at paint time. The two printToPDF runs disagreed about every colour on the page. Read the paint, not the cascade.
Sign: A print stylesheet is tested by resizing the window or by pressing Ctrl-P once.Cause: Neither is repeatable. Emulate the media type and assert on values, as steps 2 to 5 do, so a rule added later that hides one more block fails the check instead of reaching a customer.

What to check next

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.

intermediate10 minpublished updated Maks Verny