---
title: "How to Optimize HTML for PDF Printing: Page Breaks, Margins, and More"
description: "Build reliable US Letter PDFs from HTML with print CSS for margins, page breaks, repeating table headers, fonts, images, Puppeteer, and Playwright."
published: "2026-08-31"
author: "Jeffrey Needles"
canonical: "https://htmlcsstoimage.com/blog/optimize-html-for-pdf-printing"
---


A webpage can keep growing downward. Run out of room? Keep scrolling. A US Letter PDF gets exactly 8.5 by 11 inches at a time. Whenever content reaches the bottom of a page, Chromium has three choices: split it, move it or let it spill somewhere it shouldn't.

That's how perfectly good HTML turns into a PDF with a nearly empty page, clipped content, missing backgrounds or a heading stranded by itself at the bottom. Most of the work happens before the PDF is generated, in the HTML and **print styles** that control how content moves from one page to the next.

We'll build the print stylesheet first, then use those same rules from Chrome, Puppeteer, Playwright and a hosted API. The CSS will set the paper size and handle page breaks without changing the normal screen layout.

> [!NOTE]
> This tutorial targets Chromium, the engine behind Chrome, Puppeteer, Playwright and HTML/CSS to Image. Other HTML-to-PDF engines, including wkhtmltopdf, WeasyPrint and Prince, have their own paged-media behavior.
>
> The examples use US Letter paper, but the same techniques work for A4, Legal and custom page dimensions.

## Before You Start

There isn't much setup. You can work through the CSS in any modern browser. The automation examples need a recent version of Node.js or .NET and your choice of Puppeteer, PuppeteerSharp or Playwright. On a fresh machine, make sure the library's compatible Chromium build is installed too. The [Playwright](https://playwright.dev/docs/browsers){target=_blank} and [PuppeteerSharp](https://github.com/hardkoded/puppeteer-sharp#puppeteer-sharp){target=_blank} docs cover that setup.

Start with semantic HTML if you can. Real headings, paragraphs, tables, lists and figures give the browser logical places to break. A bunch of absolutely positioned elements does not.

You also need ugly test data: long titles, multi-line addresses, tables that cross a page boundary and a document that runs just a few lines onto a new page. Just because your PDF looks alright when your invoice has 4 items doesn't mean it'll flow nicely with 40.

## How HTML Becomes a Paginated PDF

A normal browser window uses the `screen` media type. Chrome's print preview and the PDF methods in Puppeteer and Playwright use `print`. That switch is what makes `@media print` useful: you can simplify a page for paper without touching the layout people use on screen. The [MDN printing guide](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Media_queries/Printing){target=_blank} covers the browser-facing pieces.

The [`@page` rule](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/%40page){target=_blank} describes the paper: its size and printable margins. The browser pours the document into those page boxes, then uses rules such as `break-before`, `break-after` and `break-inside` when it reaches an edge.

Decide who owns the paper settings:

1. CSS can define them with `@page`.
2. A renderer such as Puppeteer or Playwright can supply a format, width, height and margins.

Pick one, or make sure the two match. Conflicting sizes and margins are a common source of mysterious scaling and off-by-one page counts. In the examples below, CSS owns the geometry for manual printing and browser automation. The API example puts the same values in renderer options.

## Step 1: Add Print-Specific Styles

Start by removing what does not belong in the document. Hide the controls, then strip out the widths, margins and shadows that only made sense inside the application shell.

```css
@media print {
  .site-header, .site-footer, .navigation, .print-button, .screen-only {
    display: none !important;
  }

  body {
    background: #fff;
  }

  .document {
    width: auto;
    max-width: none;
    margin: 0;
    padding: 0;
    border: 0;
    box-shadow: none;
  }
}
```

Keep print rules after the main styles so the cascade works in your favor. For a larger application, a dedicated print-only file can be easier to reason about:

```html
<link rel="stylesheet" href="/styles/site.css">
<link rel="stylesheet" href="/styles/print.css" media="print">
```

I prefer an `@media print` block when the printable component and its regular styles live together. A dedicated file makes more sense once print rules cut across a lot of the application.

One easy mistake is hiding anything clickable. An order number may be a link on screen, but the number is still useful in the PDF. Hide the interaction, not the information.

## Step 2: Set the Page Size and Margins

CSS rules that begin with `@` are called **at-rules**. `@media print` is a media query: it applies ordinary styles only when the document is being printed. `@page` is a different at-rule. It configures the printed page itself, including its size and margins.

Use it like this:

```css
@page {
  size: letter portrait;
  margin: 0.75in;
}
```

One value applies the same three-quarter-inch margin on every side. With four values, the order is top, right, bottom, left.

Common page sizes include:

| Paper | CSS | Dimensions |
|:------|:----|:-----------|
| US Letter | `size: letter` | 8.5 in × 11 in |
| A4 | `size: A4` | 210 mm × 297 mm |
| US Legal | `size: legal` | 8.5 in × 14 in |
| A5 | `size: A5` | 148 mm × 210 mm |

Named sizes are readable, but explicit dimensions work too:

```css
@page {
  size: 8.5in 11in;
  margin: 0.75in;
}
```

CSS accepts absolute units such as `mm`, `cm`, `in` and `pt` for paper geometry. Puppeteer and Playwright document `px`, `in`, `cm` and `mm` for PDF option strings, while HTML/CSS to Image's [`pdf_options`](https://docs.htmlcsstoimage.com/parameters/pdf_options/){target=_blank} also accepts `pt`. Pixels work, but nobody should have to reverse-engineer whether `816px` was meant to be US Letter.

Page margins are not the same as `body` padding. An `@page` margin repeats on every generated page. Padding belongs to one HTML element, even when that element happens to be split across several pages.

Double margins usually mean they were defined twice. If your renderer accepts margins, either let `@page` own them or remove the CSS margin and configure it in the renderer. Don't guess which one wins.

> [!TIP]
> **Need page numbers?** Chromium 131 and newer can place content directly in the page margins. This is a better fit for page numbers, document titles and other repeating page furniture than pretending they are table headers.

```css
@page {
  size: letter portrait;
  margin: 0.75in;

  @bottom-center {
    content: "Page " counter(page) " of " counter(pages);
    color: #667085;
    font-size: 9pt;
  }
}
```

`counter(page)` is the current page and `counter(pages)` is the total. Leave enough margin for the content, and turn off Chrome's own **Headers and footers** print setting if it creates a duplicate. Older Chromium builds can use the renderer's header and footer templates instead. Chrome's [page-margin-box guide](https://developer.chrome.com/blog/print-margins){target=_blank} shows the other available positions.

## Step 3: Don't Build Fake Pages

It's tempting to make a stack of HTML elements that are each exactly `11in` high. It feels controlled, and the first sample often looks great. Then an address wraps onto a second line and every page after it shifts.

Ordinary document flow holds up much better:

```css
* {
  box-sizing: border-box;
}

html, body {
  margin: 0;
  padding: 0;
}

body {
  font-family: Inter, Arial, sans-serif;
  font-size: 10.5pt;
  line-height: 1.5;
  color: #182230;
}

img, svg {
  display: block;
  max-width: 100%;
  height: auto;
}
```

Avoid fixed heights around variable content. Use `min-height` when a design really needs a minimum area, perhaps on the first page of a certificate, but still test what happens when the content outgrows it.

Scroll containers with automatic overflow handling are another screen pattern that doesn't translate to paper:

```css
.table-wrapper {
  max-height: 500px;
  overflow: auto;
}
```

That wrapper is useful in your web app to keep things interactive. On the printed PDF, it can print only the visible piece of the table. Undo it for print:

```css
@media print {
  .table-wrapper {
    max-height: none;
    overflow: visible;
  }
}
```

## Step 4: Control Page Breaks

Now add breaks (sparingly). Modern print CSS gives us three related [fragmentation properties](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/break-inside){target=_blank}:

- `break-before` controls a boundary before an element.
- `break-after` controls a boundary after an element.
- `break-inside` controls whether the browser should split an element.

For an actual document boundary, such as a new appendix or invoice section, force a new page with a reusable class:

```css
.new-page {
  break-before: page;
  page-break-before: always;
}
```

The second declaration is the old spelling. Current browsers alias the common `page-break-*` behavior, but keeping both costs almost nothing and helps if the HTML may reach an older engine.

Keep a heading with at least some of the content that follows it:

```css
h2, h3 {
  break-after: avoid-page;
  page-break-after: avoid;
}
```

Self-contained pieces can ask to stay together:

```css
.card, .summary, .signature, figure, blockquote {
  break-inside: avoid;
  page-break-inside: avoid;
}
```

The word **avoid** is pretty important. It is "please try and keep this content together" not "never break this up." If a card is taller than the printable area, the browser still has to split or overflow it. Applying `break-inside: avoid` to the wrapper around an entire report usually makes pagination worse, not better.

Paragraph fragmentation can also be improved with `orphans` and `widows`:

```css
p, li {
  orphans: 3;
  widows: 3;
}
```

These CSS properties ask for at least three lines on either side of a split paragraph. Browser behavior varies, so think of them as a nice-to-have polish. They won't rescue a layout built around fixed heights and overflowing containers.

## Step 5: Make Long Tables Printable

Tables are where print CSS usually earns its keep. A four-row sample behaves; a 60-row invoice finds every weak spot in its alignment, wrapping and page breaks.

Start with real table markup and leave its display behavior alone:

```html
<table>
  <thead>
    <tr>
      <th scope="col">Description</th>
      <th scope="col">Quantity</th>
      <th scope="col">Amount</th>
    </tr>
  </thead>
  <tbody>
    <!-- Variable number of rows -->
  </tbody>
</table>

<div class="table-total">
  <strong>Total</strong>
  <span>$1,248.00</span>
</div>
```

Then give the browser a little help:

```css
table {
  display: table;
  width: 100%;
  border-collapse: collapse;
  table-layout: fixed;
}

thead {
  display: table-header-group;
  break-inside: avoid;
  page-break-inside: avoid;
}

tbody {
  display: table-row-group;
}

tr {
  display: table-row;
  break-inside: avoid;
  page-break-inside: avoid;
}

th, td {
  padding: 2.5mm 2mm;
  border-bottom: 0.2mm solid #d7dde5;
  text-align: left;
  vertical-align: top;
  overflow-wrap: anywhere;
}
```

When Chromium paginates content using the CSS table layout, it can draw the header group again when the table continues onto another page. A real `<thead>` already has that layout role unless another style overrides it. Test this with a long table. An unusually tall header or a layout that cannot fragment cleanly can prevent the repetition.

This depends on the table elements keeping their table display roles. Responsive styles sometimes turn a table into stacked cards by setting `table`, `thead`, `tbody` or `tr` to `block`, `grid` or `flex`. That may be useful on a narrow screen, but `<thead>` is no longer a table header group, so Chromium has nothing to repeat.

An `overflow: auto` wrapper causes a different problem. It creates a scrolling box on screen. In print, that box may clip the table or prevent the browser from paginating the rows normally. Reset both kinds of screen behavior in the print stylesheet:

```css
@media print {
  .table-wrapper {
    max-height: none;
    overflow: visible;
  }

  table {
    display: table;
  }

  thead {
    display: table-header-group;
  }

  tbody {
    display: table-row-group;
  }

  tr {
    display: table-row;
  }
}
```

These are the default display values for semantic table elements. Writing them explicitly in `@media print` is useful when application or framework styles override those defaults at smaller viewport sizes.

### Can a Non-Table Element Repeat as a Table Header?

Sometimes. The HTML tag itself is not what makes the repetition work. Chromium can repeat non-table elements when CSS gives them a complete [table formatting structure](https://www.w3.org/TR/css-tables-3/){target=_blank}:

```html
<div class="report-table">
  <div class="report-header">
    <div class="report-row">
      <div class="report-cell">Description</div>
      <div class="report-cell">Amount</div>
    </div>
  </div>

  <div class="report-body">
    <div class="report-row">
      <div class="report-cell">Annual plan</div>
      <div class="report-cell">$240.00</div>
    </div>
    <!-- More rows -->
  </div>
</div>
```

```css
@media print {
  .report-table {
    display: table;
    width: 100%;
  }

  .report-header {
    display: table-header-group;
    break-inside: avoid;
  }

  .report-body {
    display: table-row-group;
  }

  .report-row {
    display: table-row;
  }

  .report-cell {
    display: table-cell;
  }
}
```

The structure has to be complete: table, header group, rows and cells. Applying `display: table-header-group` to one unrelated block is not enough. The browser may invent anonymous table boxes to fill in missing levels. That can make a simple example appear to work while producing unpredictable pagination later.

This changes layout, not meaning. A `<div>` remains a `<div>` to screen readers and other tools that consume the document. Use actual table markup when the content is tabular. Repeated document titles, logos and page numbers belong in the `@page` margin boxes described above, or in the renderer's header and footer feature on older Chromium builds.

Watch out for `<tfoot>`: browsers may repeat it on every page too. That's useful for a repeated note and very wrong for an invoice grand total. Put a final-only total after the table and keep that smaller block together:

```css
.table-total {
  display: flex;
  justify-content: flex-end;
  gap: 12mm;
  margin-top: 4mm;
  break-inside: avoid;
  page-break-inside: avoid;
}
```

Don't put `break-inside: avoid` on the whole table. It asks the browser to keep every row together and can leave a huge blank area. Apply it to rows. A single row taller than the page is still going to split; CSS can't make it smaller.

For a table that is too wide:

- Shorten or wrap long identifiers.
- Assign sensible column widths.
- Reduce cell padding before shrinking all typography.
- Move secondary fields below the primary cell content.
- Use a landscape page only when the content genuinely needs it.

Shrinking a desktop table until it technically fits is not much of a solution if nobody can read the result.

## Step 6: Preserve Colors, Backgrounds and Images

Chrome tries to be conservative about your ink/toner. It may adjust colors and skip background graphics unless the user says otherwise. Mark the parts whose meaning or design depends on their colors:

```css
.document, .status-badge, .chart, thead {
  print-color-adjust: exact;
  -webkit-print-color-adjust: exact;
}
```

CSS alone does not turn on background printing. Set `printBackground: true` in Puppeteer or Playwright, or [`pdf_options.print_background`](https://docs.htmlcsstoimage.com/parameters/pdf_options/){target=_blank} in HTML/CSS to Image. `print-color-adjust: exact` asks Chrome not to simplify those colors for printing. Use both when the exact appearance matters. A person using the print dialog can still override the choice.

A PDF can preserve a blurry image with perfect fidelity. Use SVG for logos and diagrams where possible, and make sure photographs and other raster assets are large enough for their printed size.

Give remote images stable dimensions so a late arrival doesn't move every page break after it. For an `<img>`, include its real `width` and `height` attributes so the browser knows the aspect ratio before the file arrives:

```html
<img class="logo" src="/images/logo.png" width="680" height="180" alt="Example Corp">
```

```css
.logo {
  width: 34mm;
  height: auto;
}

.chart {
  width: 100%;
  aspect-ratio: 16 / 9;
  object-fit: contain;
  break-inside: avoid;
}
```

## Step 7: Wait for Fonts and Images

Fonts are layout, not decoration. A late font swap changes word widths, line wrapping and every page break after it. Images do the same thing vertically. The initial HTML response arriving does not mean the document is ready.

Puppeteer's [`waitForFonts`](https://pptr.dev/api/puppeteer.pdfoptions#waitforfonts){target=_blank} option defaults to `true`. The helper below also waits up to 10 seconds for the `<img>` elements currently in the document and gives Playwright the same check. It does not know whether a canvas chart, CSS background, iframe or later JavaScript update is finished.

::: tabs
@tab Puppeteer (JS)

```js
async function waitForCurrentAssets(page) {
  await page.evaluate(async () => {
    const assetsReady = (async () => {
      await document.fonts.ready;

      await Promise.all(
        Array.from(document.images).map(async (image) => {
          if (image.complete) {
            await image.decode().catch(() => {});
            return;
          }

          await new Promise((resolve) => {
            image.addEventListener("load", resolve, { once: true });
            image.addEventListener("error", resolve, { once: true });
          });
        })
      );
    })();

    await Promise.race([
      assetsReady,
      new Promise((resolve) => setTimeout(resolve, 10_000)),
    ]);
  });
}
```

@tab PuppeteerSharp (.NET)

```csharp
static async Task WaitForCurrentAssetsAsync(PuppeteerSharp.IPage page)
{
    await page.EvaluateFunctionAsync(
        """
        async () => {
          const assetsReady = (async () => {
            await document.fonts.ready;

            await Promise.all(
              Array.from(document.images).map(async (image) => {
                if (image.complete) {
                  await image.decode().catch(() => {});
                  return;
                }

                await new Promise((resolve) => {
                  image.addEventListener("load", resolve, { once: true });
                  image.addEventListener("error", resolve, { once: true });
                });
              })
            );
          })();

          await Promise.race([
            assetsReady,
            new Promise((resolve) => setTimeout(resolve, 10_000)),
          ]);
        }
        """);
}
```

@tab Playwright (JS)

```js
async function waitForCurrentAssets(page) {
  await page.evaluate(async () => {
    const assetsReady = (async () => {
      await document.fonts.ready;

      await Promise.all(
        Array.from(document.images).map(async (image) => {
          if (image.complete) {
            await image.decode().catch(() => {});
            return;
          }

          await new Promise((resolve) => {
            image.addEventListener("load", resolve, { once: true });
            image.addEventListener("error", resolve, { once: true });
          });
        })
      );
    })();

    await Promise.race([
      assetsReady,
      new Promise((resolve) => setTimeout(resolve, 10_000)),
    ]);
  });
}
```

@tab Playwright (.NET)

```csharp
static async Task WaitForCurrentAssetsAsync(Microsoft.Playwright.IPage page)
{
    await page.EvaluateAsync(
        """
        async () => {
          const assetsReady = (async () => {
            await document.fonts.ready;

            await Promise.all(
              Array.from(document.images).map(async (image) => {
                if (image.complete) {
                  await image.decode().catch(() => {});
                  return;
                }

                await new Promise((resolve) => {
                  image.addEventListener("load", resolve, { once: true });
                  image.addEventListener("error", resolve, { once: true });
                });
              })
            );
          })();

          await Promise.race([
            assetsReady,
            new Promise((resolve) => setTimeout(resolve, 10_000)),
          ]);
        }
        """);
}
```
:::

The 10-second deadline keeps one sick image server from holding a PDF worker forever. This helper treats a broken image or the deadline as “done waiting,” so use it for layout stability rather than validation. If a missing logo or chart should fail the job, check that asset explicitly before creating the PDF.

Pages that continuously poll or stream data may never become network-idle, so the automation examples below do not wait for it. Have the application set an element, attribute or JavaScript flag when the report is ready. Keep an overall timeout around the render too.

If you load a real URL, the PDF worker needs access to every asset URL too. With `page.setContent()`, relative URLs have no useful application base unless you add one, for example with `<base href="https://example.com/">`, or turn them into absolute URLs first.

## Step 8: Test the Print Layout in Chrome

Open Chrome's print preview early, not after the stylesheet feels finished. Pick the same paper size, margins and background setting that production will use.

Don't test with eight tidy rows and call it done. Use documents that are deliberately annoying:

- An almost empty invoice or report
- One document that ends just before a page boundary and another that spills a few lines past it
- A table long enough to cross at least three pages
- The longest real titles, addresses and table-cell values you expect to receive
- Missing optional sections, missing images and an image URL that is intentionally slow or broken
- Every paper size customers can choose, rather than assuming a layout that works on Letter also works on A4

The [account statement example](https://htmlcsstoimage.com/examples/account-statement){target=_blank} is a useful document to pull apart. It fetches a generated 60-day ledger, fills the transaction table with JavaScript and renders multiple US Letter pages. The page includes the HTML, CSS, request settings and finished PDF, so you can change the data or break rules and inspect the result.

Check the first, middle and final pages, not just the thumbnails. The last page exposes forced breaks and fake page heights. A middle page tells you whether repeated table headers and “keep together” rules survive a real document.

When print preview looks right, send the same ugly test document through the production renderer and put the two PDFs side by side. The page count should not be “close.” If it changes, compare the browser version, page dimensions, margins, scale, fonts and background settings before touching the CSS again.

If paper is the real destination, print one. Type that looks fine on a bright display can feel tiny on paper, and a light gray border can disappear completely.

## Step 9: Generate the PDF Programmatically

Now the same browser pipeline can run in code.

### Generate a PDF with Puppeteer or Playwright

[`page.pdf()` in Puppeteer](https://pptr.dev/api/puppeteer.page.pdf){target=_blank} and [`page.pdf()` in Playwright](https://playwright.dev/docs/api/class-page#page-pdf){target=_blank} use print media automatically. Each example loads the same report and waits for a marker set by the application. It then runs the asset helper from Step 7. The CSS `@page` rule owns the size and margins.

The sample assumes your application adds `data-pdf-ready="true"` to an element when its data and charts are finished. Replace that selector with whatever “ready” means in your application.

::: tabs
@tab Puppeteer (JS)

```js
import puppeteer from "puppeteer";

const browser = await puppeteer.launch({ headless: true });

try {
  const page = await browser.newPage();

  await page.goto("https://example.com/reports/quarterly", {
    waitUntil: "domcontentloaded",
  });

  await page.waitForSelector("[data-pdf-ready='true']");
  await waitForCurrentAssets(page);

  await page.pdf({
    path: "quarterly-report.pdf",
    preferCSSPageSize: true,
    printBackground: true,
    waitForFonts: true,
  });
} finally {
  await browser.close();
}
```

@tab PuppeteerSharp (.NET)

```csharp
using PuppeteerSharp;
using PuppeteerSharp.Media;

await using var browser = await Puppeteer.LaunchAsync(
    new LaunchOptions { Headless = true });
await using var page = await browser.NewPageAsync();

await page.GoToAsync(
    "https://example.com/reports/quarterly",
    new NavigationOptions
    {
        WaitUntil = new[] { WaitUntilNavigation.DOMContentLoaded }
    });

await page.WaitForSelectorAsync("[data-pdf-ready='true']");
await WaitForCurrentAssetsAsync(page);

await page.PdfAsync(
    "quarterly-report.pdf",
    new PdfOptions
    {
        PreferCSSPageSize = true,
        PrintBackground = true,
        WaitForFonts = true
    });
```

@tab Playwright (JS)

```js
import { chromium } from "playwright";

const browser = await chromium.launch({ headless: true });

try {
  const page = await browser.newPage();

  await page.goto("https://example.com/reports/quarterly", {
    waitUntil: "domcontentloaded",
  });

  await page.locator("[data-pdf-ready='true']").waitFor({
    state: "attached",
  });
  await waitForCurrentAssets(page);

  await page.pdf({
    path: "quarterly-report.pdf",
    preferCSSPageSize: true,
    printBackground: true,
  });
} finally {
  await browser.close();
}
```

@tab Playwright (.NET)

```csharp
using Microsoft.Playwright;

using var playwright = await Playwright.CreateAsync();
await using var browser = await playwright.Chromium.LaunchAsync(
    new BrowserTypeLaunchOptions { Headless = true });
var page = await browser.NewPageAsync();

await page.GotoAsync(
    "https://example.com/reports/quarterly",
    new PageGotoOptions { WaitUntil = WaitUntilState.DOMContentLoaded });

await page.Locator("[data-pdf-ready='true']").WaitForAsync(
    new LocatorWaitForOptions { State = WaitForSelectorState.Attached });
await WaitForCurrentAssetsAsync(page);

await page.PdfAsync(
    new PagePdfOptions
    {
        Path = "quarterly-report.pdf",
        PreferCSSPageSize = true,
        PrintBackground = true
    });
```
:::

`preferCSSPageSize` gives `@page` priority in each library. If the browser code should own the paper geometry instead, remove `size` and `margin` from `@page` and put them in the PDF options:

::: tabs
@tab Puppeteer (JS)

```js
await page.pdf({
  path: "quarterly-report.pdf",
  format: "letter",
  margin: {
    top: "0.75in",
    right: "0.75in",
    bottom: "0.75in",
    left: "0.75in",
  },
  printBackground: true,
});
```

@tab PuppeteerSharp (.NET)

```csharp
await page.PdfAsync(
    "quarterly-report.pdf",
    new PdfOptions
    {
        Format = PaperFormat.Letter,
        MarginOptions = new MarginOptions
        {
            Top = "0.75in",
            Right = "0.75in",
            Bottom = "0.75in",
            Left = "0.75in"
        },
        PrintBackground = true
    });
```

@tab Playwright (JS)

```js
await page.pdf({
  path: "quarterly-report.pdf",
  format: "Letter",
  margin: {
    top: "0.75in",
    right: "0.75in",
    bottom: "0.75in",
    left: "0.75in",
  },
  printBackground: true,
});
```

@tab Playwright (.NET)

```csharp
await page.PdfAsync(
    new PagePdfOptions
    {
        Path = "quarterly-report.pdf",
        Format = "Letter",
        Margin = new Margin
        {
            Top = "0.75in",
            Right = "0.75in",
            Bottom = "0.75in",
            Left = "0.75in"
        },
        PrintBackground = true
    });
```
:::

Pick whichever model fits the application. Just make the ownership obvious in the code.

### Generate a PDF with an API

Puppeteer or Playwright makes sense if browser automation is already part of the application. Otherwise you are signing up to maintain Chrome installations, worker processes, memory limits, timeouts and scaling. An API handles that infrastructure when all you need is the file.

For an [HTML/CSS to Image PDF render](https://docs.htmlcsstoimage.com/parameters/pdf_options/){target=_blank}, move the dimensions and margins out of `@page` and into `pdf_options`. Keep the media query, table rules and break behavior. Set [`media_type` to `print`](https://docs.htmlcsstoimage.com/parameters/media_type/){target=_blank} so those rules are active:

```bash
curl -X POST https://hcti.io/v1/image \
  -u 'UserID:APIKey' \
  -H 'Content-Type: application/json' \
  -d '{
    "html": "<!doctype html><html><head>...</head><body>...</body></html>",
    "format": "pdf",
    "media_type": "print",
    "pdf_options": {
      "page_width": "8.5in",
      "page_height": "11in",
      "margins": ["0.75in", "0.75in", "0.75in", "0.75in"],
      "print_background": true
    }
  }'
```

The response URL ends in `.pdf`. `media_type` selects the CSS environment, and `pdf_options` supplies the paper settings. The API still uses Chromium's print pipeline, so you still need the stylesheet. The service runs the browser workers for you.

If JavaScript still needs to fetch data or draw charts, set [`render_when_ready: true`](https://docs.htmlcsstoimage.com/parameters/render_when_ready/){target=_blank} and call `ScreenshotReady()` after the document is complete. The account statement linked in Step 8 uses that pattern before rendering its multi-page PDF.

## Complete Print Stylesheet

Here is the same stylesheet condensed into one block. All normal style rules are inside `@media print`, so it can live next to the screen styles without changing them. The `@page` rule only affects paged output. If the renderer should own the geometry, remove `size` and `margin` from `@page`.

```css
@page {
  size: letter portrait;
  margin: 0.75in;
}

@media print {
  * {
    box-sizing: border-box;
  }

  html, body {
    margin: 0;
    padding: 0;
  }

  body {
    color: #182230;
    background: #fff;
    font-family: Inter, Arial, sans-serif;
    font-size: 10.5pt;
    line-height: 1.5;
  }

  .site-header, .site-footer, .navigation, .print-button, .screen-only {
    display: none !important;
  }

  .document {
    width: auto;
    max-width: none;
    margin: 0;
    padding: 0;
    border: 0;
    box-shadow: none;
    print-color-adjust: exact;
    -webkit-print-color-adjust: exact;
  }

  h1, h2, h3 {
    line-height: 1.2;
  }

  h2, h3 {
    break-after: avoid-page;
    page-break-after: avoid;
  }

  p, li {
    orphans: 3;
    widows: 3;
  }

  img, svg {
    display: block;
    max-width: 100%;
    height: auto;
  }

  figure, .card, .summary, .signature {
    break-inside: avoid;
    page-break-inside: avoid;
  }

  .new-page {
    break-before: page;
    page-break-before: always;
  }

  .table-wrapper {
    max-height: none;
    overflow: visible;
  }

  table {
    display: table;
    width: 100%;
    border-collapse: collapse;
    table-layout: fixed;
  }

  thead {
    display: table-header-group;
    break-inside: avoid;
    page-break-inside: avoid;
  }

  tbody {
    display: table-row-group;
  }

  tr {
    display: table-row;
    break-inside: avoid;
    page-break-inside: avoid;
  }

  th, td {
    padding: 2.5mm 2mm;
    border-bottom: 0.2mm solid #d7dde5;
    text-align: left;
    vertical-align: top;
    overflow-wrap: anywhere;
  }

  thead th {
    color: #fff;
    background: #263b5e;
    print-color-adjust: exact;
    -webkit-print-color-adjust: exact;
  }

  .table-total {
    display: flex;
    justify-content: flex-end;
    gap: 12mm;
    margin-top: 4mm;
    break-inside: avoid;
    page-break-inside: avoid;
  }
}
```

## Common HTML-to-PDF Problems

### The PDF has an extra blank page

The usual culprits are a forced break on the final element, a fixed-height page wrapper or an element that is a hair wider than the printable area. `100vh`, `11in` containers and full-page borders are especially good at pushing an otherwise empty fragment onto a new page.

Remove fixed page heights first. If the blank page survives, outline everything and find the box crossing the boundary:

```css
@media print {
  * {
    outline: 0.2mm solid rgb(255 0 0 / 15%);
  }
}
```

It is ugly and surprisingly effective. Remove it when you are done.

### Content is clipped on the right

Look for fixed pixel widths, `min-width`, non-wrapping text and overflow containers. Desktop table styles are frequent offenders. Reset those constraints for print and make sure `box-sizing: border-box` is in place.

For long URLs and identifiers, try `overflow-wrap: anywhere`. If the problem is a wide table, fix the table layout instead of shrinking the entire document.

### Background colors are missing

Enable background printing in the renderer. In Puppeteer and Playwright that is `printBackground: true`. In HTML/CSS to Image it is [`pdf_options.print_background: true`](https://docs.htmlcsstoimage.com/parameters/pdf_options/){target=_blank}. If Chrome is also muting or adjusting the colors, add `print-color-adjust: exact` and its WebKit-prefixed form to the relevant elements.

### A page-break rule is ignored

Check the basic layout conditions first:

- The rule is active under the `print` media type.
- The target creates a box in normal layout.
- The element is not absolutely or fixed positioned.
- An ancestor is not clipping it with `overflow`.
- The element is not inside a complicated Flexbox or Grid fragmentation context.
- The content you asked to keep together is not taller than the page.

If the element lives in a complicated Grid or Flexbox layout, switching that section back to block flow for print is often more reliable than piling on more break rules:

```css
@media print {
  .dashboard-grid {
    display: block;
  }

  .dashboard-grid > * + * {
    margin-top: 8mm;
  }
}
```

### Table headers do not repeat

Inspect the computed styles for `table`, `thead`, `tbody` and `tr`. A CSS framework may have quietly changed one of them. Restore `table`, `table-header-group`, `table-row-group` and `table-row`, then remove scrolling or clipping from the table's ancestors.

### The PDF uses a fallback font

Check the unglamorous details: Is the URL absolute? Can the worker reach it? Does the server allow the cross-origin request? Did you actually provide the weight used by the stylesheet? Then wait for `document.fonts.ready` before generating the PDF.

A fallback font changes character widths, which can change line wrapping and the final page count.

### Chrome and production generate different page counts

Compare the actual inputs: browser version, paper dimensions, margins, scale, font files, media type and background settings. Check again for competing CSS and renderer margins. Dates, timezones and late-loading data can also make two supposedly identical renders different.

## Frequently Asked Questions

#### What does `@media print` do?

It applies CSS only while the browser is printing, which includes browser-generated PDFs. Use it to hide navigation, remove app chrome and turn a responsive screen layout into something that fits a fixed page.

#### Which CSS property should I use for page breaks?

Use `break-before`, `break-after` and `break-inside`. Add the corresponding `page-break-*` declarations if the document might pass through an older rendering engine.

#### Should margins be set in CSS or the browser renderer?

Either works. Use CSS when people also print from the browser. Use renderer options when API calls need different formats. Do not define margins in both places.

#### Why does `break-inside: avoid` sometimes fail?

Usually because the element is taller than the printable area, or because an overflow, positioning, Flexbox or Grid rule has taken it out of a simple fragmentable flow. `avoid` is a preference, not a guarantee.

#### How do I repeat a table header on every PDF page?

Use a real `<thead>` and preserve `display: table-header-group` in print CSS. Remove scrolling and clipping from the table's ancestors, and undo framework styles that turn table elements into blocks. Test the result with the Chromium version and table data you ship.

#### How do I add page numbers to a PDF?

In Chromium 131 and newer, use an `@page` margin box with `counter(page)` and `counter(pages)`. For older Chromium versions, use the header and footer options provided by the PDF renderer.

#### Do I need Puppeteer or Playwright to convert HTML to PDF?

No. Chrome's print dialog handles manual exports. Puppeteer and Playwright automate the same sort of browser work. A managed HTML-to-PDF API is another option when you want automation but not the browser processes behind it.

## Start with the Print Stylesheet

Fix pagination in the print stylesheet first. Verify it in Chrome with awkward real-world content, then automate the same render.

If the application already has a Puppeteer or Playwright worker, use it. Otherwise, our [HTML-to-PDF API](https://htmlcsstoimage.com/features/html-to-pdf){target=_blank} can return the PDF URL without adding another browser worker to the stack.
