DEV Community

Cover image for Generate PDF invoices from HTML in Python — without a headless browser
Hani Amro
Hani Amro

Posted on

Generate PDF invoices from HTML in Python — without a headless browser

Almost every app eventually has to turn HTML into a PDF: invoices, receipts, reports, tickets, certificates. The reflex is "spin up Puppeteer / headless Chrome." Then you inherit:

  • a ~300 MB Chromium in every container,
  • --no-sandbox and font headaches,
  • Chrome crashing under load and memory leaks,
  • slow cold starts that make serverless painful.

You wanted a PDF. You got a browser to babysit. For HTML + CSS — which is what invoices are — you don't need a browser. Here's the lighter way.

from HTML to PDF without browzer

Contents

WeasyPrint turns HTML and CSS into PDF without a browser

pip install weasyprint
# system libs (Debian/Ubuntu): apt install libpango-1.0-0 libpangocairo-1.0-0
Enter fullscreen mode Exit fullscreen mode
from weasyprint import HTML

HTML(string="<h1>Invoice #1042</h1><p>Total: $99</p>").write_pdf("invoice.pdf")
Enter fullscreen mode Exit fullscreen mode

That's the whole dependency story. No Chromium, no sandbox flags.

A real, styled invoice

from weasyprint import HTML

INVOICE = """
<!doctype html><html><head><meta charset="utf-8"><style>
  body { font-family: Arial, sans-serif; color:#1a1a1a; }
  .head { display:flex; justify-content:space-between; }
  h1 { color:#2563eb; margin:0; }
  table { width:100%; border-collapse:collapse; margin-top:24px; }
  th,td { padding:10px 8px; border-bottom:1px solid #e5e7eb; text-align:left; }
  th { background:#f8fafc; }
  .right { text-align:right; }
  .total { font-size:1.2em; font-weight:700; }
</style></head><body>
  <div class="head">
    <div><h1>Invoice</h1><div>#1042 · 23 Jun 2026</div></div>
    <div class="right"><strong>Acme Corp</strong><br>billing@acme.com</div>
  </div>
  <table>
    <tr><th>Item</th><th class="right">Qty</th><th class="right">Price</th></tr>
    <tr><td>API plan — Pro</td><td class="right">1</td><td class="right">$12.00</td></tr>
    <tr><td>Overage (320 req)</td><td class="right">320</td><td class="right">$0.96</td></tr>
    <tr><td class="right total" colspan="2">Total</td><td class="right total">$12.96</td></tr>
  </table>
</body></html>
"""
HTML(string=INVOICE).write_pdf("invoice.pdf")
Enter fullscreen mode Exit fullscreen mode

You control every pixel with normal CSS — no template-engine lock-in.

The gotcha everyone hits with margins and sizing

Two complaints come up constantly with HTML→PDF:

  • Huge side margins → that's the page margin, not your HTML.
  • Content looks shrunk/tiny → the converter is doing "shrink to fit" because your layout is wider than the page.

Both are fixed with a CSS @page rule — the bit most online converters silently ignore:

@page {
  size: A4;          /* or Letter, A5, or "210mm 297mm" */
  margin: 12mm;      /* exactly what you want; margin: 0 for edge-to-edge */
}
Enter fullscreen mode Exit fullscreen mode

For the shrinking problem, make sure your content width fits the page (use mm/relative units, not a fixed 1200px container) — then nothing gets scaled down.

Multi-page reports with page numbers and repeating headers

For anything longer than a page, @page gives you running page numbers in pure CSS — no JS, no manual pagination:

@page {
  size: A4;
  margin: 20mm 15mm;
  @bottom-center { content: "Page " counter(page) " of " counter(pages); }
}
thead { display: table-header-group; }  /* repeat the header row on every page */
Enter fullscreen mode Exit fullscreen mode

Already have a Word or Excel template?

Don't rebuild it in HTML — convert it with headless LibreOffice:

soffice --headless --convert-to pdf --outdir out/ invoice.docx
Enter fullscreen mode Exit fullscreen mode

Call it via subprocess; give each call its own -env:UserInstallation=file:///tmp/lo_xyz dir so concurrent runs don't collide.

When you DO still need a real browser


Honest caveat: WeasyPrint doesn't run JavaScript. If your document only exists after client-side JS draws it (a heavy charting lib, a SPA view), you still need headless Chrome for that one case. For static HTML + CSS — 95% of invoices, reports, and receipts — WeasyPrint is lighter, faster, and safer.

Prefer not to host it at all?

Running WeasyPrint means installing Pango/Cairo and keeping it patched. If it's one feature among many, offload it to an HTTP call. I maintain a flat-priced PDF API where html-to-pdf is one endpoint (SSRF-hardened — internal URLs are blocked — and the same key also does merge, OCR, Office→PDF, etc.). Free tier is 1,000 requests/month, no card. Disclosure: I built it.

import requests

requests.post(
    "https://pdf-tools-api2.p.rapidapi.com/html-to-pdf",
    headers={"X-RapidAPI-Key": "YOUR_KEY",
             "X-RapidAPI-Host": "pdf-tools-api2.p.rapidapi.com"},
    data={"html": INVOICE, "page_size": "A4"},
)
Enter fullscreen mode Exit fullscreen mode

Wrap-up

You don't need to ship a browser to make a PDF. For HTML + CSS, WeasyPrint renders invoices and reports in one call, @page gives you exact margins and page numbers, and LibreOffice covers Word/Excel templates. Save headless Chrome for the rare JS-rendered case.

Your turn: what are you generating — invoices, reports, labels? And what bit you: margins, fonts, or page breaks? 👇

Try the PDF API free — 1,000 requests/month, no card

*Built and maintained by a solo developer (based in Syria) who actually answers — questions welcome in the comments!

Top comments (1)

Collapse
 
h_amro_13de6b93cc1ce profile image
Hani Amro

Author here 👋

It's a little absurd when you say it out loud: "I need to make a PDF" somehow became "so I'll ship a 300 MB browser to production." We all just… accepted that.

What finally sold me on WeasyPrint wasn't speed — it was the 3am test: nothing to crash, no --no-sandbox, no cold start on the first invoice of the day. Boring, in the best possible way.

Two traps that didn't make the post:

  • Fonts fail silently. WeasyPrint uses the system fonts, so if the box is missing the font you referenced, it falls back without a word and your invoice quietly looks wrong. Ship the fonts in your image.
  • break-inside: avoid; on a row or card stops it splitting across a page boundary — roughly the difference between an invoice and a ransom note.

Genuinely curious: what's still keeping you on a browser — a charting lib, a legacy template, or just inertia? Drop it below and I'll happily think through the WeasyPrint version with you. 👇