wkhtmltopdf @page CSS Guide: size, margin, and Layout
wkhtmltopdf's @page CSS support is one of its most common pain points. Developers run @page { size: A4; margin: 0; } expecting zero margins, only to find the engine ignores the declaration entirely. This guide explains exactly which @page properties wkhtmltopdf honors, which it ignores, and how to work around the gaps.
If you've already decided to migrate, skip to the migration section — the workarounds here will buy time, but the underlying limitations are architectural.
Why wkhtmltopdf Handles @page Differently
wkhtmltopdf is built on an old version of WebKit (Qt WebKit, frozen around 2012). The CSS @page rule is part of the CSS Paged Media specification — a spec that WebKit never fully implemented. As a result, wkhtmltopdf reads only a subset of @page properties and ignores the rest without any error or warning.
The practical consequence: you cannot rely on @page alone to control page layout. You have to split CSS declarations between the stylesheet and CLI flags.
@page Properties: What Works and What Doesn't
| Property | wkhtmltopdf support | Notes |
|---|---|---|
size: A4 |
Partial | Ignored if conflicting CLI --page-size is set |
size: A4 landscape |
Partial | Use --orientation Landscape CLI flag instead |
margin: 0 |
Ignored | Must use --margin-* CLI flags |
margin: 20mm |
Ignored | Must use --margin-* CLI flags |
@top-center { content: ... } |
Not supported | Use --header-html or --footer-html |
@bottom-right { content: counter(page) } |
Not supported | Use --footer-right "[page]" |
marks: crop cross |
Not supported | — |
orphans / widows |
Partial | Inconsistent results |
Summary: For margins, always use CLI flags. For headers/footers, always use the dedicated HTML options. @page size works as a hint, but defer to --page-size for reliable behavior.
Setting Page Size
A4 (the common case)
/* CSS hint — may work in some wkhtmltopdf versions */
@page {
size: A4;
}
CLI flag (reliable):
wkhtmltopdf --page-size A4 input.html output.pdf
If both are set and conflict, the CLI flag wins.
Custom size
# 210mm x 297mm (A4 exact)
wkhtmltopdf --page-width 210mm --page-height 297mm input.html output.pdf
# Letter (US)
wkhtmltopdf --page-size Letter input.html output.pdf
Landscape
/* Ignored by most wkhtmltopdf versions */
@page { size: A4 landscape; }
# Reliable
wkhtmltopdf --orientation Landscape --page-size A4 input.html output.pdf
Setting Margins
@page { margin: 0; } does not work in wkhtmltopdf. The engine uses its own default margins (~10mm) regardless of what the CSS says.
Zero margins
wkhtmltopdf \
--margin-top 0 \
--margin-right 0 \
--margin-bottom 0 \
--margin-left 0 \
input.html output.pdf
Standard document margins
wkhtmltopdf \
--margin-top 20mm \
--margin-right 15mm \
--margin-bottom 20mm \
--margin-left 15mm \
input.html output.pdf
Extra bottom margin for footer
If you're using a footer, add extra bottom margin so content doesn't overlap:
wkhtmltopdf \
--margin-bottom 25mm \
--footer-html footer.html \
--footer-spacing 5 \
input.html output.pdf
Page Numbers
wkhtmltopdf provides page numbers through CLI substitution variables, not CSS counters.
Simple page number in footer
wkhtmltopdf \
--footer-right "Page [page] of [topage]" \
--footer-font-size 9 \
input.html output.pdf
Available variables: [page], [topage], [date], [time], [title], [doctitle], [sitepage], [sitepages].
HTML footer with page numbers
For styled footers, use a separate HTML file with JavaScript:
<!-- footer.html -->
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body {
margin: 0;
padding: 4mm 15mm;
font-family: 'Helvetica Neue', sans-serif;
font-size: 8pt;
color: #6b7280;
}
.footer-inner {
display: flex;
justify-content: space-between;
border-top: 1px solid #e5e7eb;
padding-top: 3mm;
}
</style>
</head>
<body>
<div class="footer-inner">
<span>Confidential</span>
<span id="page-num"></span>
</div>
<script>
// wkhtmltopdf injects these variables into the footer URL
var vars = {};
var parts = window.location.search.substr(1).split('&');
for (var i = 0; i < parts.length; i++) {
var p = parts[i].split('=', 2);
if (p.length == 2) vars[decodeURIComponent(p[0])] = decodeURIComponent(p[1]);
}
document.getElementById('page-num').textContent =
'Page ' + (vars['page'] || '') + ' of ' + (vars['topage'] || '');
</script>
</body>
</html>
wkhtmltopdf \
--footer-html footer.html \
--margin-bottom 20mm \
--footer-spacing 3 \
input.html output.pdf
The --enable-javascript and --javascript-delay flags may be needed if the script doesn't execute reliably:
wkhtmltopdf \
--enable-javascript \
--javascript-delay 200 \
--footer-html footer.html \
input.html output.pdf
Headers
Headers work the same way as footers:
wkhtmltopdf \
--header-html header.html \
--margin-top 25mm \
--header-spacing 3 \
input.html output.pdf
Or use the simple text options:
wkhtmltopdf \
--header-left "[title]" \
--header-right "[date]" \
--header-line \
--header-font-size 9 \
input.html output.pdf
@page CSS That Does Work
A few @page properties do work reliably:
@page {
/* Size hint (defer to CLI for reliability) */
size: A4;
/* These have no effect — use CLI instead */
/* margin: 20mm; */
/* @top-center { ... } */
}
/* break-before / break-after on elements (not inside @page) */
.page-break {
page-break-before: always;
break-before: page;
}
/* Prevent breaks inside elements */
.keep-together {
page-break-inside: avoid;
break-inside: avoid;
}
Page break properties (page-break-before, page-break-inside) work well in wkhtmltopdf and are the main CSS tool you have for controlling page layout. Use them liberally.
Complete Working Example
A production wkhtmltopdf command with margins, footer, and page size:
wkhtmltopdf \
--page-size A4 \
--margin-top 20mm \
--margin-right 15mm \
--margin-bottom 25mm \
--margin-left 15mm \
--footer-html footer.html \
--footer-spacing 5 \
--enable-javascript \
--javascript-delay 100 \
--print-media-type \
--encoding utf-8 \
--disable-smart-shrinking \
input.html \
output.pdf
Key flags explained:
| Flag | Purpose |
|---|---|
--print-media-type |
Apply @media print CSS rules |
--disable-smart-shrinking |
Prevent content from being scaled down to fit |
--encoding utf-8 |
Required for non-ASCII characters (Japanese, etc.) |
--javascript-delay 100 |
Wait for JS to execute before rendering |
Generating from HTML Strings (Subprocess)
When generating from application code, wkhtmltopdf is typically invoked as a subprocess with stdin:
# Pipe HTML via stdin
echo "<html>...</html>" | wkhtmltopdf \
--page-size A4 \
--margin-top 20mm \
--footer-right "Page [page] of [topage]" \
- output.pdf
import subprocess
html = "<html><body><h1>Invoice</h1></body></html>"
result = subprocess.run(
[
"wkhtmltopdf",
"--page-size", "A4",
"--margin-top", "20mm",
"--margin-right", "15mm",
"--margin-bottom", "20mm",
"--margin-left", "15mm",
"--footer-right", "Page [page] of [topage]",
"--quiet",
"-", # stdin
"-", # stdout
],
input=html.encode("utf-8"),
capture_output=True,
)
pdf_bytes = result.stdout
const { execFile } = require("child_process");
const { promisify } = require("util");
const execFileAsync = promisify(execFile);
async function htmlToPdf(html) {
const args = [
"--page-size", "A4",
"--margin-top", "20mm",
"--footer-right", "Page [page] of [topage]",
"--quiet",
"-", // stdin
"-", // stdout
];
const { stdout } = await execFileAsync("wkhtmltopdf", args, {
input: html,
encoding: "buffer",
maxBuffer: 50 * 1024 * 1024,
});
return stdout; // Buffer containing PDF bytes
}
Known Limitations
Beyond @page CSS, wkhtmltopdf has other architectural limitations worth knowing:
- No CSS Grid or Flexbox (partial): Grid is unsupported; Flexbox works partially. Complex layouts often need table-based fallbacks.
- JavaScript support is limited: Some modern JS APIs don't exist in the embedded WebKit.
- Font loading: Web fonts from
@font-facesometimes fail to load; embed base64 or use system fonts. - thead repetition:
display: table-header-groupworks inconsistently — some table structures fail to repeat headers across pages. - No active maintenance: The last release was 0.12.6 in 2020. Security vulnerabilities go unpatched.
For a full feature comparison, see wkhtmltopdf vs Chromium PDF engine.
Migrating to a Modern PDF API
If wkhtmltopdf's limitations are causing production problems, migrating to a Chromium-based API resolves most of them with minimal code changes.
With FUNBREW PDF, the same HTML that wkhtmltopdf struggles with works correctly because the underlying engine is Chromium — the same rendering engine as Chrome. The @page CSS spec is fully implemented: margin: 0 works, @top-center margin boxes work, and thead { display: table-header-group; } repeats reliably.
Before (wkhtmltopdf subprocess)
result = subprocess.run(
["wkhtmltopdf", "--margin-top", "20mm", "--margin-bottom", "20mm",
"--footer-right", "Page [page]", "-", "-"],
input=html.encode("utf-8"),
capture_output=True,
)
pdf_bytes = result.stdout
After (FUNBREW PDF API)
import requests
response = requests.post(
"https://pdf.funbrew.cloud/api/pdf/generate",
headers={"Authorization": "Bearer sk-your-api-key"},
json={
"html": html,
"options": {
"engine": "quality", # Chromium — full CSS @page support
"format": "A4",
"margin": {
"top": "20mm",
"right": "15mm",
"bottom": "20mm",
"left": "15mm",
},
"displayHeaderFooter": True,
"footerTemplate": "<div style='font-size:9pt; color:#6b7280; width:100%; text-align:right; padding-right:15mm;'>Page <span class='pageNumber'></span> of <span class='totalPages'></span></div>",
},
},
)
download_url = response.json()["data"]["download_url"]
Key differences: no subprocess, no CLI flag juggling, margin works directly in the JSON options, and Chromium's @page CSS support means your stylesheet controls layout without workarounds.
For a step-by-step walkthrough of the migration, see the wkhtmltopdf migration guide.
Quick Reference: wkhtmltopdf CLI Flags
| Goal | CSS (does it work?) | CLI flag |
|---|---|---|
| A4 page size | @page { size: A4 } (partial) |
--page-size A4 |
| Zero margins | @page { margin: 0 } (no) |
--margin-top 0 --margin-right 0 --margin-bottom 0 --margin-left 0 |
| 20mm top margin | @page { margin-top: 20mm } (no) |
--margin-top 20mm |
| Landscape | @page { size: landscape } (no) |
--orientation Landscape |
| Page numbers | @page { @bottom-right { ... } } (no) |
--footer-right "Page [page] of [topage]" |
| Page break | page-break-before: always (yes) |
— |
| Avoid row split | break-inside: avoid (yes) |
— |
| Print media CSS | — | --print-media-type |
Debugging Tips
- Add
--debug-javascriptto see JS errors in the footer/header HTML - Test with
--dump-outline outline.xmlto inspect the page structure - Use
--quietin production to suppress non-error output to stderr - Try Playground for instant HTML-to-PDF feedback without CLI setup — paste your HTML and see the output immediately in your browser
Summary
For wkhtmltopdf @page CSS:
- Margins: Always use CLI flags (
--margin-*), not@page { margin }— the CSS is ignored - Page size: CLI
--page-sizeis authoritative;@page { size }is a fallback hint at best - Landscape: Use
--orientation Landscape, not@page { size: landscape } - Page numbers / headers / footers: Use
--footer-right,--header-html,--footer-htmloptions - Page breaks:
page-break-before: alwaysandbreak-inside: avoidwork well — use them freely - Background colors: Require
--print-media-typeand-webkit-print-color-adjust: exact
If you're hitting walls with these workarounds, a Chromium-based API like FUNBREW PDF removes the CLI entirely and gives you full @page CSS support. You can try it in the Playground without signing up.
Related Links
- wkhtmltopdf Migration Guide — Step-by-step migration from wkhtmltopdf to FUNBREW PDF
- wkhtmltopdf vs Chromium PDF Engine — Feature comparison and benchmark results
- HTML to PDF Table Layout Guide — Fix page break issues in tables including thead repetition
- CSS Tips for HTML to PDF — Page breaks, fonts, and print CSS for Chromium-based engines
- API Reference — Full FUNBREW PDF endpoint documentation
- Playground — Test HTML to PDF in your browser instantly