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 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']
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"
}
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 · 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>£{{ line.price }}</td></tr>
{% endfor %}
</table>
<p class="total">Total due: £{{ 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))
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},
)
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 .
What are you using for PDF generation in Python at the moment? Share your setup in the comments below.
SOCIAL SHARE CARD GENERATOR