Most HTML-to-PDF code gets page numbers wrong in the same few ways: the footer is invisible, overlaps the text, or shows about:blank. Chrome now supports the CSS way of doing it, so a few lines in your stylesheet give you "Page 3 of 12" and a running header in any Chromium-based renderer, and WeasyPrint reads the same CSS. Below: the CSS, tested Node and Python scripts, and a fixed Puppeteer headerTemplate for when you can't change the page.
We printed a 60-paragraph page with Chromium's displayHeaderFooter (the option behind Puppeteer's and Playwright's page.pdf()) and measured the text in the PDF:
| What you see | Cause and fix |
|---|---|
| Footer text is a thin line or invisible | Templates don't inherit the page's CSS. With no font size, our "Page 1 of 3" came out 0.87 pt tall. Fix: inline font-size:9px (and width, padding) in the template |
| Footer drawn on top of the text | Templates are drawn inside the page margin. With no margin, body text ran to 13 pt above the footer line. Fix: margin: { top: '25mm', bottom: '25mm' } |
Date, about:blank and "1/3" | displayHeaderFooter: true with no templates prints Chromium's defaults: date, title, URL, page count.Fix: pass both templates (an empty <span></span> hides one) |
Since Chrome 131 (late 2024), Chromium supports CSS page margin boxes: 16 named areas around each page (@top-left, @top-center, @bottom-right…) whose content can use counter(page) and counter(pages). The header and footer then live in your stylesheet, next to the content they belong to, and no renderer options are needed.
@page {
size: A4;
margin: 25mm 15mm; /* the header and footer live inside this margin */
@top-left {
content: "ACME Corp · Quarterly report";
font: 9pt Arial, sans-serif;
color: #666;
}
@bottom-right {
content: "Page " counter(page) " of " counter(pages);
font: 9pt Arial, sans-serif;
color: #666;
}
}
/* No header on the cover page */
@page :first {
@top-left { content: none; }
}
@page :first targets the cover page only. The same CSS works in a browser's Print dialog, Puppeteer, Playwright and our API, and WeasyPrint has supported it for years (we checked it on WeasyPrint 67: same "Page 1 of 3" output).
This builds a four-page invoice list, sends the HTML to POST /v1/pdf and saves report.pdf. Node 18+, no dependencies. Run it with SHOTMINT_KEY=... node report.mjs.
// report.mjs: a multi-page report with a running header and "Page X of Y" footer
import { writeFile } from 'node:fs/promises';
const rows = Array.from({ length: 120 }, (_, i) => `<tr><td>INV-${1001 + i}</td><td>€${(i * 37 % 900 + 100).toFixed(2)}</td></tr>`).join('');
const html = `<!doctype html><html><head><meta charset="utf-8"><style>
@page {
size: A4;
margin: 25mm 15mm;
@top-left { content: "ACME Corp · Quarterly report"; font: 9pt Arial, sans-serif; color: #666; }
@bottom-right { content: "Page " counter(page) " of " counter(pages); font: 9pt Arial, sans-serif; color: #666; }
}
@page :first { @top-left { content: none; } }
body { font: 11pt Arial, sans-serif; }
table { width: 100%; border-collapse: collapse; }
td { border-bottom: 1px solid #ddd; padding: 4px; }
</style></head><body>
<h1>Quarterly report</h1>
<table>${rows}</table>
</body></html>`;
const res = await fetch('https://shotmint.creasework.com/v1/pdf', {
method: 'POST',
headers: { 'X-API-Key': process.env.SHOTMINT_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({ html, format: 'A4' }),
});
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
await writeFile('report.pdf', Buffer.from(await res.arrayBuffer()));
console.log('saved report.pdf, credits left:', res.headers.get('x-credits-remaining'));
The same report with requests. Note the doubled braces: the CSS sits inside an f-string.
# report.py: same report from Python (pip install requests)
import os
import requests
rows = "".join(f"<tr><td>INV-{1001 + i}</td><td>€{i * 37 % 900 + 100:.2f}</td></tr>" for i in range(120))
html = f"""<!doctype html><html><head><meta charset="utf-8"><style>
@page {{
size: A4;
margin: 25mm 15mm;
@top-left {{ content: "ACME Corp · Quarterly report"; font: 9pt Arial, sans-serif; color: #666; }}
@bottom-right {{ content: "Page " counter(page) " of " counter(pages); font: 9pt Arial, sans-serif; color: #666; }}
}}
@page :first {{ @top-left {{ content: none; }} }}
body {{ font: 11pt Arial, sans-serif; }}
table {{ width: 100%; border-collapse: collapse; }}
td {{ border-bottom: 1px solid #ddd; padding: 4px; }}
</style></head><body>
<h1>Quarterly report</h1>
<table>{rows}</table>
</body></html>"""
r = requests.post(
"https://shotmint.creasework.com/v1/pdf",
headers={"X-API-Key": os.environ["SHOTMINT_KEY"]},
json={"html": html, "format": "A4"},
timeout=60,
)
r.raise_for_status()
with open("report.pdf", "wb") as f:
f.write(r.content)
print("saved report.pdf")
Both scripts produce the same 4-page A4 PDF: no header on page 1, "ACME Corp · Quarterly report" at the top of pages 2-4, and "Page 1 of 4" to "Page 4 of 4" at the bottom right.
When the stylesheet sets @page { margin: … }, that margin is used and the API's margin parameter is ignored (we rendered the same page with margin at its 10mm default and at 0mm: identical files). Leave the margin to CSS when you use margin boxes, and make it big enough for the text: 20-25mm top and bottom for 9pt text. Without a CSS margin, the API's margin applies to all four sides.
When you print someone else's page (a URL, not your own HTML), you can't add @page rules, so you need the templates. This version fixes all three problems above. The classes pageNumber, totalPages, title, url and date are filled in by Chromium.
// puppeteer-footer.mjs: headerTemplate/footerTemplate that actually show up
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto('https://en.wikipedia.org/wiki/PDF', { waitUntil: 'networkidle2' });
await page.pdf({
path: 'page.pdf',
format: 'A4',
printBackground: true,
displayHeaderFooter: true,
// 1. Templates ignore the page's CSS: set font-size, width and padding inline.
headerTemplate: `<div style="font-size:9px; width:100%; padding:0 15mm; color:#666;">
<span class="title"></span></div>`,
footerTemplate: `<div style="font-size:9px; width:100%; padding:0 15mm; color:#666; text-align:right;">
Page <span class="pageNumber"></span> of <span class="totalPages"></span></div>`,
// 2. They are drawn inside the page margin, so leave room for them.
margin: { top: '25mm', bottom: '25mm', left: '15mm', right: '15mm' },
});
await browser.close();
On Ubuntu 23.10+ and in most containers, launch with { args: ['--no-sandbox'] } or Chrome won't start. Images in a template must be inline (a data: URL): the template can't load files from the page.
POST /v1/pdf takes the same templates as header_template and footer_template, for a url or for your own html. It sends the empty side as a blank span for you, so Chromium's default date and title line never shows up. For a plain centred "1 / 3" footer, send "page_numbers": true instead.
curl -sS --fail-with-body -X POST https://shotmint.creasework.com/v1/pdf \
-H "X-API-Key: $SHOTMINT_KEY" -H "Content-Type: application/json" \
-o wikipedia-pdf.pdf --data @- <<'JSON'
{
"url": "https://en.wikipedia.org/wiki/PDF",
"format": "A4",
"margin": "20mm",
"header_template": "<div style='font-size:8px; width:100%; padding:0 20mm; color:#666'><span class='title'></span></div>",
"footer_template": "<div style='font-size:8px; width:100%; padding:0 20mm; color:#666; text-align:right'>Page <span class='pageNumber'></span> of <span class='totalPages'></span></div>"
}
JSON
The same 28-page Wikipedia article comes back with the title at the top of every page and "Page N of 28" at the bottom right. The rules from the Puppeteer section still apply: inline styles, a margin big enough for the text, no external images. If your HTML already uses @page margin boxes, you don't need either option.
We ran report.mjs (Node 22) and report.py (requests 2.32) against the live API: both returned a 4-page A4 PDF with the header missing on page 1 and correct "Page N of 4" footers, read back with pdftotext. The API curl above ran against the live API (28 pages, "Page 3 of 28" read back with pdftotext). The Puppeteer script ran on Puppeteer 25.14 with Chrome for Testing and printed a 28-page Wikipedia article with a 7.9 pt title header and "Page N of 28" footer on every page. The failure measurements come from Chromium 156 via Playwright, whose page.pdf() takes the same options.
Related: HTML to PDF invoices, PDFs with charts, HTML to PDF in Node.js, in Python. To number the pages of a PDF you already have, use the free add page numbers to PDF tool.
One PDF = one credit, however many pages it has. €5 buys 2,000 PDFs, credits never expire and there's no subscription.
No signup, no card. Get a key in one click, then buy credits only if you need more (from €5 for 2,000, never expire). From a script, an AI agent, Make or Zapier, POST /v1/keys/free (no body) returns a key as JSON (openapi.json, no-code recipes).
All guides: guides · integrations · comparisons