May 17, 2026

HTML to PDF in Python: Jinja2 Template Patterns Guide

PythonJinja2PDFHTMLtemplates

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.

Powered by FUNBREW PDF