2026/05/17

Jinja2テンプレートからPDF生成するPython実装パターン完全ガイド

PythonJinja2PDF生成HTMLテンプレート

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の詳細はドキュメントを参照してください。

Powered by FUNBREW PDF