DEV Community

Cover image for WeasyPrint Alternative: HTML to PDF in Python (When You Need JavaScript)
Accreditly
Accreditly

Posted on • Originally published at html2img.com

WeasyPrint Alternative: HTML to PDF in Python (When You Need JavaScript)

You build a report template in HTML. It looks right in the browser: the layout holds, the webfont loads, the Chart.js graph draws. Then you run it through WeasyPrint and the PDF comes back with a blank space where the chart should be. No exception, no warning, just a hole in the page.

That is not a bug you can fix. WeasyPrint does not execute JavaScript, so anything drawn or injected client-side simply does not exist as far as its renderer is concerned. This post walks through where the Python PDF stack falls short and the API route around it; it originally appeared on the HTML to Image blog as WeasyPrint Alternative: HTML to PDF in Python.

Where WeasyPrint stops

WeasyPrint has earned its popularity. It is pure Python, it takes CSS paged media seriously and for static, document-shaped HTML it produces clean output. If your templates are plain markup with print styles, it does the job.

The problems start when your HTML behaves like the modern web:

  • No JavaScript at all. Chart.js, Alpine, anything rendered client-side, even a small <script> that swaps a date into the footer: none of it runs.
  • A layout engine that trails the browser. WeasyPrint implements its own CSS engine, so features like flexbox and grid arrived years behind Chrome and still carry edge cases. You end up maintaining a second, simpler stylesheet just for PDFs.
  • Native dependencies. WeasyPrint leans on Pango and HarfBuzz. On a full Ubuntu image that is an apt-get away. On Alpine, distroless or AWS Lambda it means custom layers, missing shared libraries and builds that break when the base image changes.

None of that is WeasyPrint doing anything wrong. It is the cost of reimplementing a rendering engine outside a browser.

The other Python options have the same shape

pdfkit wraps wkhtmltopdf, and wkhtmltopdf is archived and unmaintained, frozen on a Qt WebKit build that predates most of modern CSS. Flexbox is unreliable and grid does not exist. New projects should not adopt it in 2026.

xhtml2pdf builds on ReportLab and supports a small CSS subset. Fine for simple tables, out of its depth the moment a designer touches the template.

Running headless Chrome yourself, through Playwright or pyppeteer, renders correctly because it is a real browser. Now you operate a browser: hundreds of megabytes of Chromium in every image, cold starts measured in seconds, memory spikes, font packages and a patching schedule.

So the choice looks like this: correct rendering with heavy infrastructure, or light infrastructure with a rendering engine stuck years behind the browser.

Render the PDF with a browser engine you don't have to run

The HTML to PDF API gives you the first option without the operations. You POST your HTML with format: "pdf" and get back a URL to a finished document, rendered by a real browser engine:

import os

import requests

API_KEY = os.getenv('HTML2IMG_API_KEY')


def html_to_pdf(html: str, css: str = '') -> str:
    """Render HTML to a PDF and return the hosted URL."""
    response = requests.post(
        'https://app.html2img.com/api/html',
        json={'html': html, 'css': css, 'format': 'pdf'},
        headers={'X-API-Key': API_KEY},
        timeout=60,
    )
    response.raise_for_status()
    result = response.json()
    if not result.get('success'):
        raise RuntimeError(result.get('message', 'Render failed'))
    return result['url']
Enter fullscreen mode Exit fullscreen mode

The response is small enough to read in full:

{
    "success": true,
    "id": "9d5f9b52-6b32-4a1c-a9c5-1f0b2a9e4c11",
    "credits_remaining": 499,
    "url": "https://i.html2img.com/image-1784019129398-416501.pdf"
}
Enter fullscreen mode Exit fullscreen mode

The url points at a real vector PDF served as application/pdf. Text stays selectable and searchable, fonts are embedded (webfonts included) and background colours and gradients come through. Content lays out on A4 portrait pages and paginates automatically, so a three page report is no more work than a one page invoice.

One detail that saves you a refactor: the PDF renders with your normal screen CSS. You do not need @media print rules or a parallel print stylesheet. The markup that looks right in the browser is the markup that ships.

A worked example: an invoice from a Jinja2 template

Invoices are the classic job. Here is the whole flow, template to hosted PDF:

from jinja2 import Template

INVOICE = Template("""
<div class="invoice">
  <h1>Invoice #{{ number }}</h1>
  <p class="meta">Northgate Coffee Ltd &middot; Due {{ due }}</p>
  <table>
    <tr><th>Item</th><th>Qty</th><th>Price</th></tr>
    {% for line in lines %}
    <tr><td>{{ line.item }}</td><td>{{ line.qty }}</td><td>&pound;{{ line.price }}</td></tr>
    {% endfor %}
  </table>
  <p class="total">Total due: &pound;{{ total }}</p>
</div>
""")

CSS = """
body { font-family: Helvetica, Arial, sans-serif; color: #0e1521; }
.invoice { padding: 24px; }
table { width: 100%; border-collapse: collapse; margin-top: 16px; }
th, td { text-align: left; padding: 8px 4px; border-bottom: 1px solid #e2e6ec; }
.total { font-weight: 700; margin-top: 16px; }
"""

html = INVOICE.render(
    number=1042,
    due='20 August 2026',
    lines=[
        {'item': 'Espresso machine service', 'qty': 1, 'price': '180.00'},
        {'item': 'Filter papers (case)', 'qty': 4, 'price': '12.50'},
    ],
    total='230.00',
)

print(html_to_pdf(html, CSS))
Enter fullscreen mode Exit fullscreen mode

In Flask you would build html with render_template, in Django with render_to_string. The API does not care which framework produced the markup.

Your charts render, because JavaScript runs

Back to that blank hole from the opening. JavaScript executes in the renderer, so Chart.js draws exactly as it does in the browser. Disable the entry animation so the canvas paints immediately, and give the page a short ms_delay so the script settles before capture:

chart_html = """
<canvas id="sales" width="640" height="280"></canvas>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
new Chart(document.getElementById('sales'), {
  type: 'bar',
  data: {
    labels: ['Mar', 'Apr', 'May', 'Jun'],
    datasets: [{ label: 'Orders', data: [412, 468, 530, 597] }]
  },
  options: { animation: false }
});
</script>
"""

response = requests.post(
    'https://app.html2img.com/api/html',
    json={'html': chart_html, 'format': 'pdf', 'ms_delay': 500},
    headers={'X-API-Key': API_KEY},
)
Enter fullscreen mode Exit fullscreen mode

Turning a chart you already render in the browser into a document is one parameter.

What changes in your deployment

Your PDF dependency list becomes requests. No Pango, no HarfBuzz, no Chromium layer, no font packages. The same code runs identically on Alpine, in a distroless container and on Lambda, because the rendering happens on the API's browsers rather than your box. For async stacks there is an httpx version in the Python guide that fits FastAPI, and for big batch runs you can pass a webhook_url and collect the PDFs as they finish instead of holding connections open.

Two behaviours to know before you ship. Pages are A4 portrait and content reflows to the page width, so width, height, dpi, fullpage and selector are ignored in PDF mode; write markup that flows like a document, or pass scale_to_fit to scale a fixed-width design onto the page. And a PDF costs the same single credit as an image, so the pricing maths does not change.

When WeasyPrint is still the right call

If you need fine control over CSS paged media (running headers, counter(page) footers), if your PDFs must render fully offline, or if policy rules out external services, WeasyPrint remains the best pure Python option. For everything else the trade is simple: one HTTP call buys you a browser engine's rendering, working JavaScript and a deployment that no longer knows what Pango is.

In this post you've seen where WeasyPrint, pdfkit and xhtml2pdf stop, and how to get browser-accurate PDFs from Python with nothing installed beyond requests. The full guide, including the format parameter reference, lives on the HTML to Image blog.

What are you using for PDF generation in Python at the moment? Share your setup in the comments below.

Top comments (0)