HTML to PDF in Python: Jinja2 Template Patterns Guide
There are two broad approaches to generating PDFs in Python: use a PDF library directly (ReportLab, WeasyPrint) or render an HTML template first and then convert to PDF via an API. For a full comparison of libraries and approaches, see the Python PDF generation guide.
This article focuses entirely on the second approach: rendering Jinja2 templates into HTML, then converting to PDF with FUNBREW PDF API. We cover template inheritance, data binding with Pydantic, multilingual output, custom filters, and master layouts — all with working code you can use in production.
Why Jinja2 + PDF API?
Comparison with direct PDF libraries
| Consideration | Jinja2 + PDF API | ReportLab |
|---|---|---|
| Design changes | Edit CSS/HTML only | Modify Python code with PDF coordinates |
| Complex layouts | Tables, charts, images freely arranged | Coordinate-based positioning required |
| Japanese/Unicode fonts | CSS font-family (Google Fonts etc.) | Manual font registration |
| Frontend sharing | HTML templates reusable for web views | PDF-only code |
| Learning curve | Jinja2 knowledge is enough | Must learn Platypus/canvas API |
The HTML-first approach lets designers own the template layer while developers handle data binding and API integration — a clean separation of concerns.
Setup
pip install jinja2 requests python-dotenv
# pdf_client.py
import os
import requests
from dotenv import load_dotenv
load_dotenv()
FUNBREW_API_KEY = os.environ["FUNBREW_PDF_API_KEY"]
API_URL = "https://pdf.funbrew.cloud/api/v1/pdf/from-html"
def html_to_pdf(html: str, format: str = "A4", engine: str = "quality") -> bytes:
"""Convert HTML to PDF via FUNBREW PDF API and return binary content."""
response = requests.post(
API_URL,
json={"html": html, "format": format, "engine": engine},
headers={
"Authorization": f"Bearer {FUNBREW_API_KEY}",
"Content-Type": "application/json",
},
timeout=60,
)
response.raise_for_status()
return response.content
Pattern 1: Simple Template Rendering
The most straightforward approach — define a template string, render it with data, and convert to PDF.
# simple_pdf.py
from jinja2 import Environment
from pdf_client import html_to_pdf
INVOICE_TEMPLATE = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<style>
body { font-family: Arial, sans-serif; padding: 40px; color: #1a1a1a; }
h1 { font-size: 24px; border-bottom: 2px solid #1a56db; padding-bottom: 8px; }
table { width: 100%; border-collapse: collapse; margin-top: 24px; }
th, td { padding: 10px 12px; text-align: left; border-bottom: 1px solid #e5e7eb; }
th { background: #f9fafb; font-weight: 600; }
.amount { text-align: right; }
.total { text-align: right; font-size: 20px; font-weight: 700; margin-top: 16px; }
</style>
</head>
<body>
<h1>Invoice #{{ invoice_number }}</h1>
<p>Bill To: {{ customer_name }}</p>
<table>
<thead>
<tr><th>Item</th><th>Qty</th><th>Unit Price</th><th class="amount">Amount</th></tr>
</thead>
<tbody>
{% for item in items %}
<tr>
<td>{{ item.name }}</td>
<td>{{ item.quantity }}</td>
<td>${{ "{:,.2f}".format(item.unit_price) }}</td>
<td class="amount">${{ "{:,.2f}".format(item.quantity * item.unit_price) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="total">Total: ${{ "{:,.2f}".format(total) }}</div>
</body>
</html>"""
def generate_invoice_pdf(invoice_data: dict) -> bytes:
env = Environment()
template = env.from_string(INVOICE_TEMPLATE)
html = template.render(**invoice_data)
return html_to_pdf(html)
# Usage
invoice = {
"invoice_number": "2026-0517-001",
"customer_name": "Acme Corp",
"items": [
{"name": "PDF API Pro Plan", "quantity": 1, "unit_price": 49.80},
{"name": "Extra API Calls (500)", "quantity": 2, "unit_price": 10.00},
],
"total": 69.80,
}
pdf_bytes = generate_invoice_pdf(invoice)
with open("invoice.pdf", "wb") as f:
f.write(pdf_bytes)
Pattern 2: File-Based Templates with Inheritance
Managing templates as files scales better when you have multiple PDF types (invoices, certificates, reports). Jinja2 template inheritance lets you share a master layout.
Project structure
project/
├── templates/
│ ├── base.html # Master layout
│ ├── invoice.html # Invoice template
│ ├── certificate.html # Certificate template
│ └── report.html # Report template
├── pdf_client.py
├── pdf_generator.py
└── main.py
Master layout (base.html)
<!-- templates/base.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<style>
/* ===== Reset & base ===== */
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: Arial, sans-serif;
color: #1a1a1a;
font-size: 10pt;
line-height: 1.6;
}
/* ===== Shared header ===== */
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 3px solid #1a56db;
padding: 16px 40px;
margin-bottom: 32px;
}
.brand { font-size: 18px; font-weight: 700; color: #1a56db; }
/* ===== Main content ===== */
.content { padding: 0 40px 40px; }
/* ===== Shared footer ===== */
.page-footer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 8px 40px;
border-top: 1px solid #e5e7eb;
font-size: 8pt;
color: #6b7280;
display: flex;
justify-content: space-between;
}
/* ===== Page settings ===== */
@page {
size: A4;
margin: 20mm 15mm 25mm;
}
{% block extra_styles %}{% endblock %}
</style>
</head>
<body>
<div class="page-header">
<span class="brand">FUNBREW</span>
<span>{{ document_date }}</span>
</div>
<div class="content">
{% block content %}{% endblock %}
</div>
<div class="page-footer">
<span>{{ company_name }}</span>
<span>{{ footer_text | default('') }}</span>
</div>
</body>
</html>
Invoice template (invoice.html)
<!-- templates/invoice.html -->
{% extends "base.html" %}
{% block extra_styles %}
table { width: 100%; border-collapse: collapse; margin: 24px 0; }
th, td { padding: 10px 12px; text-align: left; border-bottom: 1px solid #e5e7eb; }
th { background: #f9fafb; font-weight: 600; }
.amount { text-align: right; }
.total-row { font-size: 18px; font-weight: 700; text-align: right; color: #1a56db; margin-top: 16px; }
{% endblock %}
{% block content %}
<h1 style="font-size:22px; margin-bottom:24px;">
Invoice <span style="color:#6b7280; font-size:16px;">#{{ invoice_number }}</span>
</h1>
<div style="display:flex; justify-content:space-between; margin-bottom:32px;">
<div>
<p style="font-weight:600;">Bill To</p>
<p>{{ customer_name }}</p>
<p>{{ customer_address }}</p>
</div>
<div style="text-align:right;">
<p>Issue Date: {{ issue_date }}</p>
<p>Due Date: {{ due_date }}</p>
</div>
</div>
<table>
<thead>
<tr>
<th>Item</th>
<th class="amount">Qty</th>
<th class="amount">Unit Price</th>
<th class="amount">Amount</th>
</tr>
</thead>
<tbody>
{% for item in items %}
<tr>
<td>{{ item.name }}</td>
<td class="amount">{{ item.quantity }}</td>
<td class="amount">${{ "{:,.2f}".format(item.unit_price) }}</td>
<td class="amount">${{ "{:,.2f}".format(item.quantity * item.unit_price) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<div style="text-align:right;">
<p>Subtotal: ${{ "{:,.2f}".format(subtotal) }}</p>
<p>Tax ({{ tax_rate }}%): ${{ "{:,.2f}".format(tax_amount) }}</p>
<p class="total-row">Total: ${{ "{:,.2f}".format(total) }}</p>
</div>
{% if notes %}
<div style="margin-top:32px; padding:16px; background:#f9fafb; border-radius:6px;">
<p style="font-weight:600; margin-bottom:8px;">Notes</p>
<p>{{ notes }}</p>
</div>
{% endif %}
{% endblock %}
PDF generator class
# pdf_generator.py
from pathlib import Path
from jinja2 import Environment, FileSystemLoader
from pdf_client import html_to_pdf
class PDFGenerator:
"""Generate PDFs from Jinja2 templates."""
def __init__(self, templates_dir: str = "templates"):
self.env = Environment(
loader=FileSystemLoader(templates_dir),
autoescape=True, # XSS protection
)
def render(self, template_name: str, context: dict) -> str:
"""Render a template with the given context and return HTML."""
template = self.env.get_template(template_name)
return template.render(**context)
def generate(
self,
template_name: str,
context: dict,
output_path: str | None = None,
format: str = "A4",
) -> bytes:
"""Generate a PDF from a template. Saves to file if output_path is given."""
html = self.render(template_name, context)
pdf_bytes = html_to_pdf(html, format=format)
if output_path:
Path(output_path).write_bytes(pdf_bytes)
return pdf_bytes
# Usage
generator = PDFGenerator()
invoice_data = {
"company_name": "FUNBREW Inc.",
"document_date": "May 17, 2026",
"invoice_number": "2026-0517-001",
"customer_name": "Acme Corp",
"customer_address": "123 Main St, New York, NY 10001",
"issue_date": "May 17, 2026",
"due_date": "June 17, 2026",
"items": [
{"name": "PDF API Pro Plan", "quantity": 1, "unit_price": 49.80},
{"name": "Extra API Calls", "quantity": 2, "unit_price": 10.00},
],
"subtotal": 69.80,
"tax_rate": 10,
"tax_amount": 6.98,
"total": 76.78,
"notes": "Please include invoice number with payment.",
}
pdf_bytes = generator.generate("invoice.html", invoice_data, "invoice.pdf")
print(f"Generated: {len(pdf_bytes):,} bytes")
Pattern 3: Type-Safe Data Binding with Pydantic
Pydantic models validate input data automatically and produce clean context objects for templates.
# models.py
from pydantic import BaseModel
from datetime import date
from typing import Optional
class LineItem(BaseModel):
name: str
quantity: int
unit_price: float # USD
@property
def subtotal(self) -> float:
return self.quantity * self.unit_price
class InvoiceData(BaseModel):
invoice_number: str
customer_name: str
customer_address: str
issue_date: date
due_date: date
items: list[LineItem]
tax_rate: float = 10.0
notes: Optional[str] = None
@property
def subtotal(self) -> float:
return sum(item.subtotal for item in self.items)
@property
def tax_amount(self) -> float:
return round(self.subtotal * self.tax_rate / 100, 2)
@property
def total(self) -> float:
return round(self.subtotal + self.tax_amount, 2)
def to_template_context(self) -> dict:
"""Convert to a dict safe to pass directly to a Jinja2 template."""
return {
"invoice_number": self.invoice_number,
"customer_name": self.customer_name,
"customer_address": self.customer_address,
"issue_date": self.issue_date.strftime("%B %d, %Y"),
"due_date": self.due_date.strftime("%B %d, %Y"),
"items": [
{
"name": item.name,
"quantity": item.quantity,
"unit_price": item.unit_price,
}
for item in self.items
],
"subtotal": self.subtotal,
"tax_rate": self.tax_rate,
"tax_amount": self.tax_amount,
"total": self.total,
"notes": self.notes,
}
# typed_invoice.py
from datetime import date
from models import InvoiceData, LineItem
from pdf_generator import PDFGenerator
invoice = InvoiceData(
invoice_number="2026-0517-001",
customer_name="Acme Corp",
customer_address="123 Main St, New York, NY 10001",
issue_date=date(2026, 5, 17),
due_date=date(2026, 6, 17),
items=[
LineItem(name="PDF API Pro Plan", quantity=1, unit_price=49.80),
LineItem(name="Extra API Calls", quantity=2, unit_price=10.00),
],
)
generator = PDFGenerator()
context = {
**invoice.to_template_context(),
"company_name": "FUNBREW Inc.",
"document_date": "May 17, 2026",
}
generator.generate("invoice.html", context, "typed-invoice.pdf")
Pattern 4: Multilingual PDF Output
Generate PDFs in multiple languages from the same template structure using a labels dictionary.
# i18n_pdf.py
LABELS = {
"en": {
"invoice": "Invoice",
"bill_to": "Bill To",
"item": "Item",
"quantity": "Qty",
"unit_price": "Unit Price",
"amount": "Amount",
"subtotal": "Subtotal",
"tax": "Tax",
"total": "Total",
"due_date": "Due Date",
"notes": "Notes",
"currency_symbol": "$",
},
"ja": {
"invoice": "請求書",
"bill_to": "請求先",
"item": "項目",
"quantity": "数量",
"unit_price": "単価",
"amount": "金額",
"subtotal": "小計",
"tax": "消費税",
"total": "合計",
"due_date": "支払期限",
"notes": "備考",
"currency_symbol": "¥",
},
"de": {
"invoice": "Rechnung",
"bill_to": "Rechnungsempfänger",
"item": "Artikel",
"quantity": "Menge",
"unit_price": "Einzelpreis",
"amount": "Betrag",
"subtotal": "Zwischensumme",
"tax": "MwSt.",
"total": "Gesamt",
"due_date": "Fälligkeitsdatum",
"notes": "Anmerkungen",
"currency_symbol": "€",
},
}
class MultilingualPDFGenerator:
def __init__(self, templates_dir: str = "templates"):
from jinja2 import Environment, FileSystemLoader
self.env = Environment(loader=FileSystemLoader(templates_dir), autoescape=True)
def generate(self, template_name: str, data: dict, locale: str = "en") -> bytes:
"""Generate a PDF with labels in the specified locale."""
from pdf_client import html_to_pdf
labels = LABELS.get(locale, LABELS["en"])
context = {**data, "labels": labels, "locale": locale}
template = self.env.get_template(template_name)
html = template.render(**context)
return html_to_pdf(html)
Template reference for multilingual labels:
<!-- templates/invoice_i18n.html -->
{% extends "base.html" %}
{% block content %}
<h1>{{ labels.invoice }} #{{ invoice_number }}</h1>
<p>{{ labels.bill_to }}: {{ customer_name }}</p>
<table>
<thead>
<tr>
<th>{{ labels.item }}</th>
<th>{{ labels.quantity }}</th>
<th>{{ labels.unit_price }}</th>
<th>{{ labels.amount }}</th>
</tr>
</thead>
<tbody>
{% for item in items %}
<tr>
<td>{{ item.name }}</td>
<td>{{ item.quantity }}</td>
<td>{{ labels.currency_symbol }}{{ "{:,.2f}".format(item.unit_price) }}</td>
<td>{{ labels.currency_symbol }}{{ "{:,.2f}".format(item.quantity * item.unit_price) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<p>{{ labels.total }}: {{ labels.currency_symbol }}{{ "{:,.2f}".format(total) }}</p>
{% endblock %}
Pattern 5: Custom Filters for Consistent Formatting
Custom Jinja2 filters centralize formatting logic so templates stay clean.
# pdf_generator_with_filters.py
from jinja2 import Environment, FileSystemLoader
from datetime import date
def usd_format(value: float) -> str:
"""Currency filter: 12345.6 → $12,345.60"""
return f"${value:,.2f}"
def date_long(value: date) -> str:
"""Date filter: date(2026,5,17) → May 17, 2026"""
return value.strftime("%B %d, %Y")
def setup_environment(templates_dir: str) -> Environment:
env = Environment(loader=FileSystemLoader(templates_dir), autoescape=True)
env.filters["usd"] = usd_format
env.filters["date_long"] = date_long
return env
Template usage with custom filters:
<!-- Clean template with custom filters -->
<td>{{ item.unit_price | usd }}</td>
<p>Due: {{ due_date | date_long }}</p>
Preview in the Playground
Before finalizing your HTML template design, paste the rendered HTML into the Playground and see a live PDF preview. Strip out the Jinja2 tags, hard-code some sample data, and iterate on the CSS until the layout looks right. Then move the validated HTML back into your Jinja2 template.
For certificate PDF patterns, see the certificate generator guide. To call these patterns from Django, FastAPI, or Flask, see the Django/FastAPI/Flask PDF integration guide.
Summary
The Jinja2 + FUNBREW PDF API stack gives you a clean HTML-first workflow for PDF generation in Python.
| Pattern | Best for |
|---|---|
| Simple template string | Single PDF type, quick implementation |
| File-based + inheritance | Multiple PDF types, shared master layout |
| Pydantic type-safe | Large apps requiring data validation |
| Multilingual | SaaS products targeting global markets |
| Custom filters | Centralized, consistent formatting |
For library selection guidance, see the Python PDF generation guide. For the full API reference, see the documentation.