Why Your Generated PDF Looks Different From the Browser: Print CSS Gotchas

Your invoice looks perfect in Chrome. Bold header, branded background, clean table. You pipe the same HTML through page.pdf() β€” or your API of choice β€” and the…

Your invoice looks perfect in Chrome. Bold header, branded background, clean table. You pipe the same HTML through page.pdf() β€” or your API of choice β€” and the PDF comes back washed-out, the wrong size, and missing half its colors. You didn't break your CSS. You asked for a print document, and the browser gave you exactly what print documents look like by default. The gap between your screen and your PDF isn't a rendering bug; it's a media switch you didn't know you triggered. Here's how to diagnose each visual discrepancy and fix it β€” first in raw Puppeteer, then in a single explicit API call that names every default you were fighting.

Backgrounds Vanish Because You Asked for Paper, Not a Screenshot

page.pdf() re-renders the page under print media against a physical page size; it does not rasterize what you see on screen. That's the first shock: your beautiful screen layout gets reflowed for paper, and paper has different rules. The browser strips backgrounds in print mode to save ink, so printBackground defaults to false and your colors disappear. Per PDF4.dev, print-color-adjust: exact (or -webkit-print-color-adjust: exact for Safari) forces backgrounds, gradients, and images to render as on screen. Chromium dropped the -webkit- prefix in version 98, but Safari still requires it.

In MarkupGo, printBackground defaults to false too [1], so you must explicitly enable it. And here's a trap that catches even experienced developers: header and footer templates independently need -webkit-print-color-adjust: exact even when the main document has it [1]. The template CSS is isolated from the page CSS, so your global print-color-adjust declaration doesn't reach there.

Your Page Size Is Letter, Your Margins Are Fighting, and Your Media Queries Just Woke Up

Chrome's default PDF page size is US Letter, not A4. If you're outside North America, your margins are wrong unless you pass format: 'A4' or preferCSSPageSize: true per HTML to Image's Puppeteer guide. When an API accepts margin parameters, those values override @page { margin } β€” pick one source of truth, API margins or CSS, or they fight each other. Per FUNBREW PDF, CSS custom properties (var()) do not work inside @page rules, so hard-code your page size and margin values.

MarkupGo exposes preferCssPageSize as a named property so your CSS @page rule can win over API defaults [1]. And emulatedMediaType lets you choose 'screen' or 'print' [1] β€” if your framework has @media print rules that break the layout, emulate screen to restore the on-screen look.

Page Breaks Land Wrong Because Print Rules Suddenly Fire

Any @media print rules in your stylesheet apply during page.pdf() rendering and can reflow your layout unexpectedly. But the deeper issue is how browsers handle pagination itself. break-inside: avoid is ignored if the element is taller than a full page; the browser breaks it anyway and CSS alone cannot prevent this. Per FUNBREW PDF, break-inside: avoid on flex or grid children is often ignored; wrap each child in a block div. The legacy page-break-before/after/inside properties still work as aliases for modern break-* properties, so include both for safety. Chromium repeats thead on every printed page automatically, but adding display: table-header-group explicitly is recommended for safety. Setting widows and orphans to 3 produces noticeably better typographic results in long documents.

Headers, Footers, and Page Numbers Are a Separate Mini-Document

Chromium's header/footer templates are a separate mini-document: page CSS does not reach them, and without an explicit font-size the footer text can render at 0.75pt [2]. Templates reserve no space, so page margins are the only thing keeping content clear; with zero margins body text runs through both header and footer [2]. If you pass only footerTemplate, Chrome fills the header with its default date and document title; pass an empty element like <span></span> to show nothing [2]. Chrome provides five special classes β€” pageNumber, totalPages, title, date, url β€” and url prints about:blank when rendering via setContent(); images must be data URIs and template backgrounds need -webkit-print-color-adjust: exact even with printBackground: true [2].

MarkupGo header/footer templates must be complete HTML documents and support the same five special classes for injecting print values [1]. In MarkupGo templates, CSS is independent of the main document, images must be base64-encoded, and background/color properties require -webkit-print-color-adjust: exact [1].

Let's Get Hands-On: One API Call With Every Fix Named

Here's how to render the same HTML through MarkupGo with every trap explicitly closed:

fetch('/api/v1/pdf', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'x-api-key': 'YOUR_API_KEY'
  },
  body: JSON.stringify({
    source: {
      type: 'html',
      data: '<h1>Your Invoice HTML Here</h1>'
    },
    options: {
      properties: {
        printBackground: true,
        preferCssPageSize: true,
        margins: {
          top: 20,
          bottom: 20,
          left: 15,
          right: 15
        }
      },
      emulatedMediaType: 'screen',
      header: '<html><head><style>body { font-size: 12px; margin: auto 20px; -webkit-print-color-adjust: exact; }</style></head><body><span></span></body></html>',
      footer: '<html><head><style>body { font-size: 12px; margin: auto 20px; -webkit-print-color-adjust: exact; }</style></head><body><p><span class="pageNumber"></span> of <span class="totalPages"></span></p></body></html>'
    }
  })
})

The fixes, named:

  • printBackground: true restores backgrounds [1]
  • preferCssPageSize: true lets your @page rule win [1]
  • emulatedMediaType: 'screen' bypasses framework @media print rules [1]
  • header and footer are complete HTML documents with explicit font-size, base64 images, -webkit-print-color-adjust: exact, and the pageNumber/totalPages classes [1]
  • Passing <span></span> in the header suppresses Chrome's default date/title header [2]

The same source HTML works with URL, HTML, template, or markdown input, with optional expiration-based auto-deletion between 60 seconds and 90 days [1]. Start with the perpetual free trial of 100 credits/month with no credit card required [3], or scale through Lite ($29/2,000 credits), Plus ($49/10,000 credits), or Pro ($99/20,000 credits with unlimited templates) [3].

The Print-Ready CSS Checklist

Before you ship your next PDF, run through these:

  • Enable printBackground (or set print-color-adjust: exact in CSS) β€” backgrounds are stripped by default [1]
  • Pick one source of truth for page size and margins: API properties or @page rules, not both
  • Add -webkit-print-color-adjust: exact to any element that must keep its background or gradient in print
  • Include legacy page-break-* fallbacks alongside modern break-* properties for cross-engine safety
  • Wrap flex/grid children in block containers before applying break-inside: avoid
  • Set thead to display: table-header-group explicitly for safe repetition across pages
  • Set widows and orphans to 3 for better typography in long documents
  • Hard-code values in @page rules β€” CSS custom properties do not work there
  • Treat header/footer templates as independent documents with their own CSS, explicit font-size, and base64 images [2]
  • Use emulatedMediaType: 'screen' when @media print rules from your framework break the layout [1]

The PDF that looks wrong isn't broken. It's faithfully following print defaults you never opted into. Name the defaults explicitly, and your generated PDF finally matches what you designed.

Sources

  1. markupgo.com
  2. Puppeteer PDF headers and footers with page numbers: 7 traps (dev.to)
  3. markupgo.com