·6 min read

HTML-to-PDF done right: page breaks, headers, footers, and margins

Chromium's headless PDF renderer respects break-inside, break-before, and break-after for page breaks, @page (or the API's margin option) for page geometry, and a separate headerTemplate/footerTemplate mechanism — not CSS @page margin boxes — for running headers and footers. Getting clean output means using all three deliberately rather than relying on browser print defaults.

Chromium's print pipeline is the same one Chrome uses for Ctrl+P, so it respects standard CSS print rules — but the defaults are tuned for printing a webpage, not for generating a clean document. Getting page breaks, headers, footers, and margins right means overriding those defaults explicitly with @page rules, break-control properties, and the render engine's own margin options.

Margins: CSS @page vs. the API's margin option

Docweave exposes margin as a render option so you don't have to hardcode it into every template. When you call POST /api/v1/pdf, the options object accepts a margin block (top/right/bottom/left, in CSS units like "1in" or "20mm") and a format (e.g. "A4", "Letter").

curl https://docweave.dev/api/v1/pdf \
  -H "Authorization: Bearer dw_live_your_key" \
  -H "Content-Type: application/json" \
  -H "Accept: application/pdf" \
  -d '{
    "source": { "type": "html", "html": "<h1>Invoice</h1><p>...</p>" },
    "options": {
      "format": "A4",
      "margin": { "top": "1in", "bottom": "1in", "left": "0.75in", "right": "0.75in" }
    }
  }' \
  --output invoice.pdf

If you also declare @page { margin: ... } inside the HTML, Chromium's print engine gives the CSS @page rule priority for the content flow area — the API's margin option is the simpler lever for the common case, but @page is where you go for anything asymmetric (like a wider left margin for binding) or per-page-type control:

@page {
  size: A4;
  margin: 25mm 18mm 20mm 18mm; /* top right bottom left */
}

@page :first {
  margin-top: 40mm; /* extra space for a cover page */
}

Keep both in sync in your head: whichever value ends up applying, it defines the usable content box that headers, footers, and running content all measure against.

Page breaks: stop content from splitting badly

The most common visual bug in generated PDFs is a table row, card, or heading getting sliced across a page boundary. Chromium supports the standard fragmentation properties — use them liberally on anything that looks bad split in half:

.invoice-line-item, .card, table tr {
  break-inside: avoid;
}

h2, h3 {
  break-after: avoid; /* never leave a heading orphaned at page bottom */
}

.section {
  break-before: page; /* force a fresh page per section */
}

A few gotchas worth flagging explicitly:

  • page-break-inside: avoid is the older syntax; break-inside: avoid is the modern equivalent and both work in Chromium, but pick one and be consistent.
  • Flexbox and grid containers do not fragment predictably — a display: flex row can still get cut mid-box even with break-inside: avoid set on it. If a layout keeps splitting badly, switch that specific block back to block/table layout.
  • Very tall single elements (a huge image, a giant unbroken table) simply cannot be prevented from spanning pages — break-inside: avoid only works if the element could fit on one page to begin with.

For long tables specifically, repeat the header row per page with standard HTML table semantics — Chromium will reprint a <thead> at the top of each page a <table> spans, which is usually what you want for reports or line-item invoices.

Running headers and footers

This is the part people get wrong most often: CSS has no native way to put live content (like a page number or a document title) into the margin box of every page. @page margin boxes (@top-center, @bottom-right, etc.) exist in the print spec, but Chromium's headless PDF engine does not implement them — it only supports its own displayHeaderFooter mechanism, which is separate from your document's HTML entirely and rendered from its own small header/footer templates with limited CSS.

The practical workaround, and what most PDF-generation APIs (Docweave included) support: pass explicit headerTemplate / footerTemplate HTML strings in options, with placeholder classes Chromium fills in automatically (pageNumber, totalPages, date, title, url):

{
  "source": { "type": "url", "url": "https://example.com/report" },
  "options": {
    "format": "Letter",
    "margin": { "top": "0.9in", "bottom": "0.9in" },
    "displayHeaderFooter": true,
    "headerTemplate": "<div style='font-size:9px;width:100%;text-align:center;color:#888;'>Q3 Report</div>",
    "footerTemplate": "<div style='font-size:9px;width:100%;text-align:center;color:#888;'>Page <span class=\"pageNumber\"></span> of <span class=\"totalPages\"></span></div>"
  }
}

Two things trip people up here: the header/footer templates are rendered in a sandboxed context with no access to your page's own stylesheet, so all styling must be inline; and your top/bottom margin needs enough room to fit the header/footer content, or it will visually overlap your body text. Start with margins around 0.8–1in when using headers/footers and adjust from there.

Putting it together

A reliable print-quality template combines all three: @page (or the API's margin option) for the base page geometry, break-inside/break-before/break-after on your content blocks so nothing splits awkwardly, and headerTemplate/footerTemplate for anything that needs to repeat on every page. Test with real content lengths, not just a one-page sample — pagination bugs almost always show up on page 2 or later, once content actually needs to flow across a boundary.

If you're generating from a URL rather than raw HTML, remember the source page's own CSS is what gets evaluated — so these rules need to live in that page's stylesheet (or be injected), not just in your request body. See the HTML-to-PDF API glossary entry for how source type affects what CSS is actually applied, and the docs for the full options schema, including format and scale settings not covered here.

For repeated document types — contracts, certificates, quotes — it's usually worth building the print CSS once into a stored template rather than re-solving pagination per request.

FAQ

Why does my table row get cut in half across a page break?

By default, Chromium's print engine will fragment any block-level element that doesn't fit in the remaining space on a page, including table rows. Add break-inside: avoid to the row (or its container) to keep it whole — it'll move the entire row to the next page instead of splitting it.

Can I put a page number directly in my HTML with CSS?

Not with plain CSS in Chromium's headless PDF renderer — the CSS spec's @page margin boxes (@top-center, etc.) aren't implemented there. Instead, pass a footerTemplate string in the render options; Chromium injects <span class="pageNumber"> and <span class="totalPages"> placeholders into it automatically.

Does the margin option or the CSS @page rule win?

The CSS @page rule generally takes priority for the content area once it's declared, since it's evaluated as part of page layout. Use the API's margin option when you don't need per-template CSS control, and @page when you need finer-grained or per-page-type margins (like a different first page).

Why does my flexbox layout still split across pages even with break-inside: avoid?

Flex and grid containers don't fragment reliably in Chromium's print engine — break-inside: avoid on a flex row is often ignored. If a specific block keeps splitting badly, switch that block to block or table layout for print, or wrap its contents in a plain div.

Related

Generate your first PDF in minutes.

Get an API key