Jinja2テンプレートからPDF生成するPython実装パターン完全ガイド
PythonでHTMLをPDFに変換する実装パターンは大きく2つに分かれます。「PDFライブラリを直接使う」か「HTMLテンプレートをレンダリングしてからPDF APIで変換する」かです。
各ライブラリの選定基準と概要はPython PDF生成完全ガイドで詳しく解説しています。この記事では「HTML→PDF変換」アプローチの実装パターンに絞って掘り下げます。具体的には、Jinja2でHTMLをレンダリングし、FUNBREW PDF APIでPDF変換するパターンです。
テンプレート継承・データバインディング・マスターレイアウト・多言語対応など、実プロジェクトで使えるパターンを動くコード付きで解説します。
なぜ「Jinja2 + PDF API」なのか
ReportLabや直接PDFライブラリとの比較
| 観点 | Jinja2 + PDF API | ReportLab |
|---|---|---|
| デザイン変更 | CSS/HTMLを編集するだけ | PythonコードをPDF座標ベースで修正 |
| 複雑なレイアウト | 表・グラフ・画像を自由に配置 | プログラムで座標指定が必要 |
| 日本語フォント | CSSで指定(Google Fonts等) | フォント登録が必要 |
| フロントエンド共有 | HTMLテンプレートをWeb表示にも流用可 | PDF専用コード |
| 学習コスト | Jinja2の知識があればOK | Platypus/canvas APIを学ぶ必要あり |
Jinja2 + PDF APIはデザインをHTMLで管理できるため、デザイナーとの分業が容易で、Web表示用テンプレートとPDF生成テンプレートを共有できます。
環境セットアップ
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:
"""HTMLをFUNBREW PDF APIでPDFに変換してバイナリを返す"""
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
パターン1: シンプルなテンプレートレンダリング
最もシンプルな使い方です。テンプレート文字列にデータを差し込んでHTMLを生成し、APIでPDF化します。
# simple_pdf.py
from jinja2 import Environment
from pdf_client import html_to_pdf
# テンプレートを文字列で定義
INVOICE_TEMPLATE = """<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<style>
body { font-family: 'Noto Sans JP', 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; }
.total { text-align: right; font-size: 20px; font-weight: 700; margin-top: 16px; }
</style>
</head>
<body>
<h1>請求書 #{{ invoice_number }}</h1>
<p>請求先: {{ customer_name }} 様</p>
<table>
<thead>
<tr><th>項目</th><th>数量</th><th>単価</th><th>金額</th></tr>
</thead>
<tbody>
{% for item in items %}
<tr>
<td>{{ item.name }}</td>
<td>{{ item.quantity }}</td>
<td>¥{{ "{:,}".format(item.unit_price) }}</td>
<td>¥{{ "{:,}".format(item.quantity * item.unit_price) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="total">合計: ¥{{ "{:,}".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)
# 使用例
invoice = {
"invoice_number": "2026-0517-001",
"customer_name": "株式会社サンプル",
"items": [
{"name": "PDF API Proプラン", "quantity": 1, "unit_price": 4980},
{"name": "追加API呼び出し 500件", "quantity": 2, "unit_price": 1000},
],
"total": 6980,
}
pdf_bytes = generate_invoice_pdf(invoice)
with open("invoice.pdf", "wb") as f:
f.write(pdf_bytes)
パターン2: ファイルベースのテンプレート管理
テンプレートをファイルで管理すると、複数のPDFタイプ(請求書・証明書・レポート)を整理しやすくなります。
プロジェクト構成
project/
├── templates/
│ ├── base.html # マスターレイアウト
│ ├── invoice.html # 請求書テンプレート
│ ├── certificate.html # 証明書テンプレート
│ └── report.html # レポートテンプレート
├── pdf_client.py
├── pdf_generator.py
└── main.py
マスターレイアウト(base.html)
<!-- templates/base.html -->
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@400;700&display=swap" rel="stylesheet">
<style>
/* ===== リセットとベース ===== */
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Noto Sans JP', sans-serif;
color: #1a1a1a;
font-size: 10pt;
line-height: 1.6;
}
/* ===== 共通ヘッダー ===== */
.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; }
/* ===== メインコンテンツ ===== */
.content { padding: 0 40px 40px; }
/* ===== 共通フッター ===== */
.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 設定 ===== */
@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.html)
テンプレート継承で base.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;
margin-top: 16px;
color: #1a56db;
}
{% endblock %}
{% block content %}
<h1 style="font-size:22px; margin-bottom:24px;">
請求書 <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;">請求先</p>
<p>{{ customer_name }} 御中</p>
<p>{{ customer_address }}</p>
</div>
<div style="text-align:right;">
<p>発行日: {{ issue_date }}</p>
<p>支払期限: {{ due_date }}</p>
</div>
</div>
<table>
<thead>
<tr>
<th>項目</th>
<th class="amount">数量</th>
<th class="amount">単価</th>
<th class="amount">金額</th>
</tr>
</thead>
<tbody>
{% for item in items %}
<tr>
<td>{{ item.name }}</td>
<td class="amount">{{ item.quantity }}</td>
<td class="amount">¥{{ "{:,}".format(item.unit_price) }}</td>
<td class="amount">¥{{ "{:,}".format(item.quantity * item.unit_price) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<div style="text-align:right;">
<p>小計: ¥{{ "{:,}".format(subtotal) }}</p>
<p>消費税 ({{ tax_rate }}%): ¥{{ "{:,}".format(tax_amount) }}</p>
<p class="total-row">合計: ¥{{ "{:,}".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;">備考</p>
<p>{{ notes }}</p>
</div>
{% endif %}
{% endblock %}
PDFジェネレーター(pdf_generator.py)
# pdf_generator.py
from pathlib import Path
from jinja2 import Environment, FileSystemLoader
from pdf_client import html_to_pdf
class PDFGenerator:
"""Jinja2テンプレートを使ったPDF生成クラス"""
def __init__(self, templates_dir: str = "templates"):
self.env = Environment(
loader=FileSystemLoader(templates_dir),
autoescape=True, # XSS対策
)
def render(self, template_name: str, context: dict) -> str:
"""テンプレートにデータを差し込んで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:
"""テンプレートからPDFを生成する。output_pathを指定するとファイルに保存する"""
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
# 使用例
generator = PDFGenerator()
invoice_data = {
"company_name": "株式会社FUNBREW",
"document_date": "2026年5月17日",
"invoice_number": "2026-0517-001",
"customer_name": "株式会社サンプル",
"customer_address": "東京都渋谷区...",
"issue_date": "2026年5月17日",
"due_date": "2026年6月17日",
"items": [
{"name": "PDF API Proプラン", "quantity": 1, "unit_price": 4980},
{"name": "追加API呼び出し 1000件", "quantity": 2, "unit_price": 1000},
],
"subtotal": 6980,
"tax_rate": 10,
"tax_amount": 698,
"total": 7678,
"notes": "お振込の際は請求書番号をご記入ください。",
}
pdf_bytes = generator.generate("invoice.html", invoice_data, "invoice.pdf")
print(f"PDF生成完了: {len(pdf_bytes):,} bytes")
パターン3: データバインディングと型安全
Pydanticを使うとデータの型チェックとバリデーションが自動化され、テンプレートに渡すデータのミスを防げます。
# models.py
from pydantic import BaseModel, field_validator
from datetime import date
from typing import Optional
class LineItem(BaseModel):
name: str
quantity: int
unit_price: int # 円
@property
def subtotal(self) -> int:
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: int = 10
notes: Optional[str] = None
@property
def subtotal(self) -> int:
return sum(item.subtotal for item in self.items)
@property
def tax_amount(self) -> int:
return int(self.subtotal * self.tax_rate / 100)
@property
def total(self) -> int:
return self.subtotal + self.tax_amount
def to_template_context(self) -> dict:
"""テンプレートに渡すコンテキストに変換する"""
return {
"invoice_number": self.invoice_number,
"customer_name": self.customer_name,
"customer_address": self.customer_address,
"issue_date": self.issue_date.strftime("%Y年%m月%d日"),
"due_date": self.due_date.strftime("%Y年%m月%d日"),
"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="株式会社サンプル",
customer_address="東京都渋谷区xxx",
issue_date=date(2026, 5, 17),
due_date=date(2026, 6, 17),
items=[
LineItem(name="PDF API Proプラン", quantity=1, unit_price=4980),
LineItem(name="追加API呼び出し", quantity=2, unit_price=1000),
],
)
generator = PDFGenerator()
context = {**invoice.to_template_context(), "company_name": "株式会社FUNBREW", "document_date": "2026年5月17日"}
generator.generate("invoice.html", context, "typed-invoice.pdf")
パターン4: 多言語対応(日英切り替え)
同じテンプレート構造で日本語・英語のPDFを生成するパターンです。i18n対応が必要なSaaS製品で役立ちます。
# i18n_pdf.py
from jinja2 import Environment, FileSystemLoader
# 言語ごとのラベル定義
LABELS = {
"ja": {
"invoice": "請求書",
"bill_to": "請求先",
"item": "項目",
"quantity": "数量",
"unit_price": "単価",
"amount": "金額",
"subtotal": "小計",
"tax": "消費税",
"total": "合計",
"due_date": "支払期限",
"notes": "備考",
"currency_symbol": "¥",
},
"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": "$",
},
}
class MultilingualPDFGenerator:
def __init__(self, templates_dir: str = "templates"):
self.env = Environment(loader=FileSystemLoader(templates_dir), autoescape=True)
def generate(self, template_name: str, data: dict, locale: str = "ja") -> bytes:
"""指定言語のラベルでPDFを生成する"""
from pdf_client import html_to_pdf
labels = LABELS.get(locale, LABELS["ja"])
context = {**data, "labels": labels, "locale": locale}
template = self.env.get_template(template_name)
html = template.render(**context)
return html_to_pdf(html)
# 多言語対応テンプレートでは {{ labels.invoice }} のように参照する
多言語テンプレートの例(invoice_i18n.html の一部):
<!-- 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 }}{{ "{:,}".format(item.unit_price) }}</td>
<td>{{ labels.currency_symbol }}{{ "{:,}".format(item.quantity * item.unit_price) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<p>{{ labels.total }}: {{ labels.currency_symbol }}{{ "{:,}".format(total) }}</p>
{% endblock %}
パターン5: カスタムフィルターで書式統一
Jinja2のカスタムフィルターを使うと、テンプレート内の書式処理を統一できます。
# pdf_generator_with_filters.py
from jinja2 import Environment, FileSystemLoader
from datetime import date
def jpy_format(value: int) -> str:
"""円表示フィルター: 12345 → ¥12,345"""
return f"¥{value:,}"
def date_ja(value: date) -> str:
"""日付フィルター: date(2026,5,17) → 2026年5月17日"""
return value.strftime("%Y年%m月%d日")
def setup_environment(templates_dir: str) -> Environment:
env = Environment(loader=FileSystemLoader(templates_dir), autoescape=True)
env.filters["jpy"] = jpy_format
env.filters["date_ja"] = date_ja
return env
テンプレート内での使い方:
<!-- カスタムフィルター使用例 -->
<td>{{ item.unit_price | jpy }}</td>
<p>支払期限: {{ due_date | date_ja }}</p>
Playgroundで確認してから実装する
テンプレートのHTMLデザインを確認したい場合は、PlaygroundにHTMLを貼り付けてリアルタイムにPDFプレビューできます。Jinja2タグを除いたHTML部分で動作確認してから、実装に組み込むと効率的です。
証明書のPDF生成パターンは証明書PDF生成ガイドも参考にしてください。Django・FastAPI・Flaskからこのパターンを呼び出す方法はDjango・FastAPI・Flask PDF統合ガイドで解説しています。
まとめ
Jinja2 + FUNBREW PDF APIの組み合わせは、実プロジェクトで使えるHTML→PDF変換のベストプラクティスです。
| パターン | 適したケース |
|---|---|
| シンプルなテンプレート | 単一PDFタイプ、素早い実装 |
| ファイルベース + 継承 | 複数PDFタイプ、マスターレイアウトを共有 |
| Pydantic型安全 | 大規模アプリ、データバリデーションが必要 |
| 多言語対応 | SaaS・グローバル展開 |
| カスタムフィルター | 書式処理を一元管理したい |
ライブラリ選定から始めたい場合はPython PDF生成完全ガイドを、APIの詳細はドキュメントを参照してください。