๐Ÿ–จ๏ธ Chapter 89: HTML & CSS for Print & Paged Media

Automated PDF Generation with Headless Chrome

Orchestrating Headless Chromium via Puppeteer and Playwright, mastering page.pdf() configurations, header/footer template injection, and production PDF microservices.

LEARNING OBJECTIVES โŒต
  • Automate high-fidelity PDF document rendering using Node.js with Puppeteer and Playwright.
  • Master critical page.pdf() configuration flags: preferCSSPageSize, printBackground, margin, and scale.
  • Inject dynamic running headers and footers using Chromium's native template classes (.pageNumber, .totalPages, .date, .title).
  • Solve race conditions in automated PDF pipelines by synchronizing webfont loading (document.fonts.ready) and network requests (networkidle0).
๐ŸŽฌ INTERACTIVE VISUAL PIPELINE Core Architecture Simulation
๐ŸŒ
1. Input
Directives & Tags
โš™๏ธ
2. Parse
Tokenizer & AST
๐ŸŒณ
3. Layout
Box Model & Flow
๐ŸŽจ
4. Render
GPU Paint & Composite
PHASE 1: INPUT & DIRECTIVES
Browser receives declarative markup stream, parsing tag tokens and initializing component state.

๐Ÿ“– The Mental Model & Story (Intuitive Foundation)

In the early days of server-side document generation, backend engineers had to build PDFs using low-level imperative drawing libraries (like FPDF, PDFKit, or iText). Every line, box, string, and table cell had to be plotted using raw X/Y Cartesian coordinates:

# The Dark Ages of PDF Generation
canvas.drawString(100, 750, "Invoice #1092")
canvas.line(100, 740, 500, 740)

If a customer's company name was two lines instead of one, the entire coordinate calculation broke, overlapping text and destroying table borders.

Headless Chrome revolutionized document automation. Instead of calculating X/Y coordinates manually, you write standard declarative HTML, CSS Grid, Flexbox, and typography. You then spin up a headless browser instance in the background, load the HTML, let Chromium's world-class Blink layout engine compute the layout and typography, and snap an exact vector PDF snapshot in milliseconds.

+------------------------------------------------------------------------------------+
|                       HEADLESS CHROMIUM PDF PIPELINE                               |
+------------------------------------------------------------------------------------+
  [HTML / CSS / JS Template]
             |
             v
  [Puppeteer / Playwright Engine] ----> Launches Headless Chromium
             |
             +---> Navigates to page / Injects HTML content
             |
             +---> Awaits 'networkidle0' & 'document.fonts.ready'
             |
             +---> Emulates '@media print'
             |
             +---> Injects Chromium Header/Footer Templates (.pageNumber, .totalPages)
             |
             v
  [Pixel-Perfect Vector PDF Output Stream] (Saved to disk or S3 / Streamed to HTTP)

Technical Deep Dive & Specifications

1. Puppeteer vs. Playwright page.pdf() Configuration Matrix

Both Puppeteer and Playwright provide the page.pdf() method, backed by the Chrome DevTools Protocol (CDP) Page.printToPDF command:

Parameter Type Default Critical Production Function
printBackground boolean false Must be true to preserve colored badges, table zebra striping, and CSS gradients.
preferCSSPageSize boolean false When true, gives precedence to @page { size: ... } defined in CSS over API parameters.
format string 'Letter' Paper size keyword ('A4', 'Letter', 'Legal', 'A3'). Ignored if preferCSSPageSize: true.
landscape boolean false Paper orientation boolean.
margin object none Object specifying { top, bottom, left, right } (e.g. '20mm', '0.75in').
displayHeaderFooter boolean false Enables Chromium's specialized template header/footer injection.
headerTemplate string "" HTML string defining running top header.
footerTemplate string "" HTML string defining running bottom footer.
scale number 1 Zoom scale factor of the webpage rendering ($0.1$ to $2.0$).

2. Chromium Header and Footer Template Injection

When displayHeaderFooter: true is enabled, Chromium instantiates an isolated secondary DOM context for headers and footers. Chromium automatically populates specific CSS class names with metadata:

<!-- Footer Template Example -->
<div style="font-size: 8px; font-family: sans-serif; width: 100%; display: flex; justify-content: space-between; padding: 0 20mm; color: #64748b;">
  <span>Document Generated: <span class="date"></span></span>
  <span>Page <span class="pageNumber"></span> of <span class="totalPages"></span></span>
</div>

Standard Chromium Template Magic Classes

  • <span class="pageNumber"></span>: Injects the current page number.
  • <span class="totalPages"></span>: Injects the total page count.
  • <span class="date"></span>: Injects the formatted system print date.
  • <span class="title"></span>: Injects the document <title>.
  • <span class="url"></span>: Injects the document URL.

[!WARNING] In Chromium header/footer templates:

  1. You must declare an explicit font-size (e.g., font-size: 9px;) inline, otherwise Chromium defaults to 0px and renders invisible text!
  2. You must ensure @page or API margin-top / margin-bottom has enough room (e.g., 25mm), otherwise the main body content will overlap the header/footer templates.

3. Eliminating Timing and Font Glitches

The #1 bug in serverless PDF generation is premature rendering: snapping the PDF before external webfonts (Google Fonts, custom WOFF2) or dynamic charts (Chart.js, D3) finish loading.

To eliminate race conditions, always synchronize:

  1. waitUntil: 'networkidle0': Ensures zero active HTTP requests for at least 500ms.
  2. document.fonts.ready: Ensures all webfont glyphs are decoded in memory before rasterization.
await page.goto('https://internal.service/invoice/8942', {
  waitUntil: 'networkidle0'
});

// Await full webfont resolution
await page.evaluateHandle('document.fonts.ready');

SYS: ACTIVE
HULL: 98%
CORE: STABLE
NET: ONLINE
HTML STARSHIP CODE TERMINAL example.html
LIVE RENDER & DIAGNOSTICS CORE TEMP: 45ยฐC
INSPECTING DOM: VALID
TAGS: SCANNING...

๐Ÿ’ป Interactive Code Playground

Below is a complete, production-verified Node.js script using Puppeteer to generate enterprise A4 PDFs with background preservation, font synchronization, and template headers/footers.

Production Node.js Automation Script (generate-pdf.mjs)

Line-by-Line Code Breakdown

  • Lines 8โ€“11 (puppeteer.launch): Launches Chromium with --font-render-hinting=none to maximize vector typographic smoothness during print rendering.
  • Lines 85โ€“86 (page.setContent & waitUntil: 'networkidle0'): Injects dynamic HTML string into the tab and waits until Google Fonts and CSS files are completely loaded.
  • Line 89 (page.evaluateHandle('document.fonts.ready')): Explicitly blocks execution until all WOFF2 font faces are loaded into memory.
  • Lines 93โ€“95 (format: 'A4', printBackground: true): Forces A4 dimensions and prevents Chromium from stripping the green status badge and gray table header backgrounds.
  • Lines 96โ€“107 (headerTemplate, footerTemplate): Injects isolated HTML running headers and footers with explicit font-size: 8px; and native .pageNumber, .totalPages, and .date template spans.
  • Lines 108โ€“113 (margin): Sets $25\text{mm}$ top/bottom margins so the invoice content never collides with the running header/footer templates.

Expected Browser Render Output

  • Generates an A4 PDF document (invoice.pdf).
  • Top margin has ACME CLOUD CORP โ€ข OFFICIAL INVOICE and CONFIDENTIAL.
  • The green PAID IN FULL pill badge and light-gray table headers render with rich vector fidelity.
  • Bottom margin displays Generated: 10/24/2026 on the left and Page 1 of 1 on the right.

import puppeteer from 'puppeteer';
import fs from 'node:fs';
import path from 'node:path';

async function generateEnterprisePDF() {
  console.log('๐Ÿš€ Launching Headless Chromium...');
  
  const browser = await puppeteer.launch({
    headless: 'new',
    args: ['--no-sandbox', '--disable-setuid-sandbox', '--font-render-hinting=none']
  });

  const page = await browser.newPage();

  // 1. Sample HTML content for invoice
  const htmlContent = `
  <!DOCTYPE html>
  <html lang="en">
  <head>
    <meta charset="UTF-8">
    <title>Enterprise Invoice #INV-2026-902</title>
    <link rel="preconnect" href="https://fonts.googleapis.com">
    <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
    <style>
      body {
        font-family: 'Inter', sans-serif;
        color: #0f172a;
        margin: 0;
        padding: 20px;
        -webkit-print-color-adjust: exact;
        print-color-adjust: exact;
      }
      .header-row {
        display: flex;
        justify-content: space-between;
        align-items: center;
        border-bottom: 2px solid #2563eb;
        padding-bottom: 15px;
        margin-bottom: 25px;
      }
      .logo {
        font-size: 20pt;
        font-weight: 700;
        color: #2563eb;
      }
      .badge-paid {
        background-color: #dcfce7;
        color: #15803d;
        border: 1px solid #86efac;
        padding: 4px 12px;
        border-radius: 9999px;
        font-weight: 600;
        font-size: 10pt;
      }
      table {
        width: 100%;
        border-collapse: collapse;
        margin: 25px 0;
      }
      th {
        background-color: #f1f5f9;
        color: #475569;
        text-align: left;
        padding: 10px;
        font-size: 10pt;
      }
      td {
        border-bottom: 1px solid #e2e8f0;
        padding: 12px 10px;
        font-size: 10pt;
      }
      .total-card {
        margin-left: auto;
        width: 250px;
        background: #f8fafc;
        border: 1px solid #cbd5e1;
        border-radius: 6px;
        padding: 15px;
      }
    </style>
  </head>
  <body>
    <div class="header-row">
      <div class="logo">ACME CLOUD CORP</div>
      <span class="badge-paid">PAID IN FULL</span>
    </div>

    <p><strong>Billed To:</strong> Global Logistics Partner Ltd.<br>
       <strong>Invoice Date:</strong> October 24, 2026 &bull; <strong>Due Date:</strong> Immediate</p>

    <table>
      <thead>
        <tr>
          <th>Description</th>
          <th>Units</th>
          <th>Rate</th>
          <th style="text-align: right;">Amount</th>
        </tr>
      </thead>
      <tbody>
        <tr>
          <td>Dedicated High-Memory Kubernetes Cluster (Month of October)</td>
          <td>744 hrs</td>
          <td>$2.50</td>
          <td style="text-align: right;">$1,860.00</td>
        </tr>
        <tr>
          <td>Multi-Region High Availability Storage (NVMe Tier)</td>
          <td>12 TB</td>
          <td>$25.00</td>
          <td style="text-align: right;">$300.00</td>
        </tr>
      </tbody>
    </table>

    <div class="total-card">
      <div style="display: flex; justify-content: space-between; margin-bottom: 8px;">
        <span>Subtotal:</span><strong>$2,160.00</strong>
      </div>
      <div style="display: flex; justify-content: space-between; margin-bottom: 8px;">
        <span>Tax (8%):</span><strong>$172.80</strong>
      </div>
      <div style="display: flex; justify-content: space-between; border-top: 1px solid #cbd5e1; padding-top: 8px; font-size: 12pt; color: #2563eb;">
        <span>Total:</span><strong>$2,332.80</strong>
      </div>
    </div>
  </body>
  </html>
  `;

  // 2. Set HTML content
  await page.setContent(htmlContent, { waitUntil: 'networkidle0' });

  // 3. Guarantee font rendering synchronization
  await page.evaluateHandle('document.fonts.ready');

  // 4. Generate the PDF with Running Headers/Footers
  console.log('๐Ÿ“„ Exporting PDF with templates...');
  const pdfBuffer = await page.pdf({
    format: 'A4',
    printBackground: true,
    displayHeaderFooter: true,
    headerTemplate: `
      <div style="font-size: 8px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; width: 100%; display: flex; justify-content: space-between; padding: 0 20mm; color: #94a3b8;">
        <span>ACME CLOUD CORP &bull; OFFICIAL INVOICE</span>
        <span>CONFIDENTIAL</span>
      </div>
    `,
    footerTemplate: `
      <div style="font-size: 8px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; width: 100%; display: flex; justify-content: space-between; padding: 0 20mm; color: #94a3b8;">
        <span>Generated: <span class="date"></span></span>
        <span>Page <span class="pageNumber"></span> of <span class="totalPages"></span></span>
      </div>
    `,
    margin: {
      top: '25mm',
      bottom: '25mm',
      left: '15mm',
      right: '15mm'
    }
  });

  await browser.close();
  console.log(`โœ… PDF Generated Successfully! Size: ${pdfBuffer.length} bytes`);
}

generateEnterprisePDF();

๐Ÿ‹๏ธ Hands-On Exercise

๐ŸŽฏ The Challenge: Build a Playwright PDF Automation Service

Scenario: You are tasked with writing a Node.js microservice script using @playwright/test / playwright that converts dynamic HTML files into US-Letter PDF reports.

Instructions:

  1. Write an async function renderReport(htmlFilePath, outputPdfPath).
  2. Launch Playwright Chromium in headless mode.
  3. Enable printBackground: true and set format: 'Letter'.
  4. Inject a footer template that outputs the page number in the format: Sheet [pageNumber] / [totalPages].
  5. Set top: '20mm' and bottom: '20mm' margins.

๐Ÿ Starter Code Sandbox

โš ๏ธ Common Pitfalls

  1. Omitting printBackground: true in page.pdf(): If you forget this flag, all CSS background colors, colored badges, and table shading will vanish, rendering an all-white background regardless of what CSS says.
  2. Invisible Footer Text Due to Missing Font Size: If you write footerTemplate: '<div><span class="pageNumber"></span></div>', Chromium's default stylesheet for templates sets font-size: 0; by default. You must specify an inline style like style="font-size: 10px;".
  3. Memory Leaks from Unclosed Browser Instances: In production serverless functions or Express microservices, launching a new browser per request without calling await browser.close() will rapidly exhaust container RAM. Use persistent browser pools (like generic-pool) and create lightweight browser.newContext() instances per request.

๐Ÿ’ก Pro Tips

  1. Use preferCSSPageSize: true for Mixed Orientations: When generating documents containing both portrait text and landscape spreadsheets (using named pages from Lesson 89.3), pass preferCSSPageSize: true. This tells Chromium to honor @page orientation switches instead of locking the entire PDF to a single global API format.
  2. Accelerate CI/CD Docker Builds: In Docker containers (Alpine/Debian), pass --disable-gpu, --disable-dev-shm-usage, and --no-sandbox to prevent memory crashes when generating 100+ page PDFs.
  3. Emulate Print Media Explicitly: Before calling page.pdf(), invoke await page.emulateMediaType('print') to force JavaScript runtime code and CSS media queries to evaluate in print mode ahead of the PDF snapshot.

๐Ÿ“Œ Key Takeaways

  • Headless Chromium via Puppeteer or Playwright provides modern HTML/CSS rendering for automated PDF generation pipelines.
  • Always enable printBackground: true in page.pdf() options to preserve background colors and table shading.
  • Chromium template headers and footers inject dynamic metadata via .pageNumber, .totalPages, and .date classes.
  • Always declare explicit inline font-size on header/footer template wrappers to avoid 0px invisible text.
  • Await networkidle0 and document.fonts.ready before rasterization to eliminate race conditions and font layout shifts.
  • --
โญ LEARN: HTML ๐ŸŒŸ โš”๏ธ QUIZ BATTLE ARENA // ACTIVE
3x
STREAK!
BONUS ACTIVE
COMBO
? Question 1 / 3

Why does text inside a Puppeteer footerTemplate often render as completely invisible if no styles are provided?

Question 1 / 3 Topic: HTML Fundamentals
00:45 REMAINING
XP REWARD
+250 XP
3x
STREAK!
BONUS ACTIVE
COMBO
? Question 2 / 3

Which JavaScript expression guarantees that custom @font-face webfonts are fully loaded in memory before capturing a PDF?

Question 2 / 3 Topic: HTML Fundamentals
00:45 REMAINING
XP REWARD
+250 XP
3x
STREAK!
BONUS ACTIVE
COMBO
? Question 3 / 3

When should you set preferCSSPageSize: true in page.pdf() options?

Question 3 / 3 Topic: HTML Fundamentals
00:45 REMAINING
XP REWARD
+250 XP