はじめに
「毎月、取引先ごとに請求書をExcelで作って、PDFに変換して、メールで送る」
件数が増えるほど時間を取られ、しかも金額の入力ミスが許されない作業です。これはPythonで自動化できます。
この記事では、HTMLとCSSでレイアウトを作り、データを差し込んでPDF化する方法を紹介します。
なぜHTML+CSS方式なのか
PythonのPDF生成には主に2つのアプローチがあります。
| 方式 | 代表ライブラリ | 特徴 |
|---|---|---|
| 座標指定 | ReportLab | 細かく制御できるが、レイアウト変更が大変 |
| HTML→PDF | WeasyPrint | CSSで書けて、修正が容易 |
帳票は「項目の位置を少しずらしたい」「ロゴを変えたい」といった調整が頻繁に発生します。座標をコードで指定する方式だと、そのたびに数値を触ることになります。
HTML+CSS方式なら、レイアウトはテンプレートファイルに分離できるため、デザイン調整とロジックが混ざりません。ブラウザで確認しながら作れるのも利点です。
準備
pip install weasyprint jinja2
WeasyPrintはOSによって追加ライブラリが必要です。
# Ubuntu / Debian
sudo apt install libpango-1.0-0 libpangoft2-1.0-0
# macOS
brew install pango
# Windows は公式ドキュメントの GTK インストール手順を参照
Step 1:テンプレートを作る
templates/invoice.html として保存します。{{ }} の部分に後からデータが入ります。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<style>
@page {
size: A4;
margin: 20mm 18mm;
}
body {
font-family: "Noto Sans JP", sans-serif;
font-size: 10pt;
color: #222;
}
h1 {
font-size: 20pt;
letter-spacing: 0.3em;
text-align: center;
margin-bottom: 8mm;
}
.meta { text-align: right; margin-bottom: 6mm; }
.to { font-size: 12pt; border-bottom: 1px solid #222; padding-bottom: 2mm; width: 60%; }
.total-box {
margin: 6mm 0;
padding: 4mm;
background: #f2f2f2;
font-size: 14pt;
font-weight: bold;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 4mm;
}
th {
background: #333;
color: #fff;
padding: 2mm;
font-size: 9pt;
}
td {
border-bottom: 1px solid #ddd;
padding: 2.5mm 2mm;
}
.num { text-align: right; }
.summary { width: 50%; margin-left: auto; margin-top: 4mm; }
.summary td { border: none; padding: 1.5mm 2mm; }
.summary .grand {
border-top: 2px solid #333;
font-weight: bold;
font-size: 12pt;
}
.footer { margin-top: 10mm; font-size: 9pt; color: #555; }
</style>
</head>
<body>
<h1>請 求 書</h1>
<div class="meta">
請求書番号: {{ invoice_no }}<br>
発行日: {{ issue_date }}
</div>
<div class="to">{{ client_name }} 御中</div>
<div class="total-box">
ご請求金額: ¥{{ "{:,}".format(total) }}(税込)
</div>
<p>下記の通りご請求申し上げます。</p>
<table>
<thead>
<tr>
<th>品目</th>
<th style="width:15%">数量</th>
<th style="width:20%">単価</th>
<th style="width:20%">金額</th>
</tr>
</thead>
<tbody>
{% for item in items %}
<tr>
<td>{{ item.name }}</td>
<td class="num">{{ item.qty }}</td>
<td class="num">¥{{ "{:,}".format(item.price) }}</td>
<td class="num">¥{{ "{:,}".format(item.qty * item.price) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<table class="summary">
<tr><td>小計</td><td class="num">¥{{ "{:,}".format(subtotal) }}</td></tr>
<tr><td>消費税(10%)</td><td class="num">¥{{ "{:,}".format(tax) }}</td></tr>
<tr class="grand"><td>合計</td><td class="num">¥{{ "{:,}".format(total) }}</td></tr>
</table>
<div class="footer">
<strong>お振込先</strong><br>
{{ bank_info }}<br><br>
お支払期限: {{ due_date }}<br><br>
{{ company_name }}<br>
{{ company_address }}<br>
{{ company_email }}
</div>
</body>
</html>
Step 2:データを差し込んでPDF化する
from datetime import date, timedelta
from jinja2 import Environment, FileSystemLoader
from weasyprint import HTML
TAX_RATE = 0.10
def build_invoice(data: dict, output_path: str):
"""請求書データからPDFを生成する"""
# 金額を計算
subtotal = sum(i["qty"] * i["price"] for i in data["items"])
tax = int(subtotal * TAX_RATE)
total = subtotal + tax
# テンプレートに差し込む
env = Environment(loader=FileSystemLoader("templates"))
template = env.get_template("invoice.html")
html = template.render(
**data,
subtotal=subtotal,
tax=tax,
total=total,
)
# PDFに変換
HTML(string=html, base_url=".").write_pdf(output_path)
print(f"作成しました: {output_path}(合計 ¥{total:,})")
# 実行例
invoice = {
"invoice_no": "INV-2026-0042",
"issue_date": date.today().strftime("%Y年%m月%d日"),
"due_date": (date.today() + timedelta(days=30)).strftime("%Y年%m月%d日"),
"client_name": "株式会社サンプル",
"items": [
{"name": "Webシステム開発", "qty": 1, "price": 300000},
{"name": "保守サポート(月額)", "qty": 3, "price": 20000},
],
"bank_info": "〇〇銀行 △△支店 普通 1234567 カ)サンプル",
"company_name": "株式会社サンプル商事",
"company_address": "東京都〇〇区〇〇 1-2-3",
"company_email": "info@example.com",
}
build_invoice(invoice, "invoice_2026-09.pdf")
Step 3:CSVから一括生成する
実務で本当に効くのはここです。取引先リストから全件を一度に生成します。
import csv
from collections import defaultdict
from datetime import date, timedelta
def bulk_generate(csv_path: str):
"""
CSV形式:
取引先,品目,数量,単価
株式会社A,システム開発,1,300000
株式会社A,保守サポート,3,20000
株式会社B,データ移行,1,150000
"""
# 取引先ごとに明細をまとめる
grouped = defaultdict(list)
with open(csv_path, encoding="utf-8-sig") as f:
for row in csv.DictReader(f):
grouped[row["取引先"]].append({
"name": row["品目"],
"qty": int(row["数量"]),
"price": int(row["単価"]),
})
ym = date.today().strftime("%Y%m")
for idx, (client, items) in enumerate(grouped.items(), start=1):
data = {
"invoice_no": f"INV-{ym}-{idx:03d}",
"issue_date": date.today().strftime("%Y年%m月%d日"),
"due_date": (date.today() + timedelta(days=30)).strftime("%Y年%m月%d日"),
"client_name": client,
"items": items,
"bank_info": "〇〇銀行 △△支店 普通 1234567 カ)サンプル",
"company_name": "株式会社サンプル商事",
"company_address": "東京都〇〇区〇〇 1-2-3",
"company_email": "info@example.com",
}
# ファイル名に使えない文字を除去
safe = "".join(c for c in client if c not in r'\/:*?"<>|')
build_invoice(data, f"output/請求書_{safe}_{ym}.pdf")
bulk_generate("invoices.csv")
取引先20社なら、実行1回で20ファイルが数秒で揃います。
実務で押さえるポイント
① 日本語フォントを埋め込む
サーバー環境で生成すると文字化けすることがあります。フォントを明示的に指定してください。
@font-face {
font-family: "NotoJP";
src: url("fonts/NotoSansJP-Regular.ttf");
}
body { font-family: "NotoJP", sans-serif; }
HTML側でローカルフォントを参照する場合、HTML(string=html, base_url=".") の base_url が相対パスの起点になります。
② ページ番号を入れる
複数ページになる帳票では必須です。
@page {
@bottom-center {
content: counter(page) " / " counter(pages);
font-size: 8pt;
color: #888;
}
}
③ 明細が多いとき、表のヘッダーを各ページに出す
thead { display: table-header-group; }
tr { page-break-inside: avoid; }
これで改ページしても見出しが繰り返され、行の途中で切れることもなくなります。
④ 金額計算は必ずPythonで行う
テンプレート内で計算式を書くと、検算もテストもできなくなります。計算はPython側、テンプレートは表示のみと役割を分けてください。
消費税の端数処理(切り捨て・四捨五入)は取引先との取り決めに従う必要があるため、int() で固定せず設定で切り替えられるようにしておくと安全です。
⑤ 生成後は必ず1件目を目視確認する
自動生成は便利ですが、金額の誤りは信用問題に直結します。 本番運用では、
- 生成件数と対象件数が一致しているか
- 合計金額の総和が元データと一致するか
をログに出して検証する仕組みを入れておくと安心です。
print(f"生成: {len(grouped)}件 / 総額: ¥{grand_total:,}")
メール送信まで自動化する
生成したPDFをそのまま送付する場合の例です。
import smtplib
from email.message import EmailMessage
from pathlib import Path
def send_invoice(to_addr: str, client: str, pdf_path: str):
msg = EmailMessage()
msg["Subject"] = f"【ご請求書】{date.today().strftime('%Y年%m月')}分"
msg["From"] = "billing@example.com"
msg["To"] = to_addr
msg.set_content(f"""{client} 御中
平素より大変お世話になっております。
{date.today().strftime('%Y年%m月')}分の請求書を添付にてお送りいたします。
ご確認のほど、よろしくお願い申し上げます。
""")
pdf = Path(pdf_path)
msg.add_attachment(
pdf.read_bytes(),
maintype="application",
subtype="pdf",
filename=pdf.name,
)
with smtplib.SMTP_SSL("smtp.example.com", 465) as smtp:
smtp.login("billing@example.com", "パスワード")
smtp.send_message(msg)
送信は誤爆が最も怖い処理です。最初は宛先を自分のアドレスに固定してテストし、内容を確認してから本番の宛先に切り替えてください。
まとめ
- 帳票PDFは HTML+CSS → WeasyPrint が修正しやすく実務向き
- レイアウトはテンプレートファイルに分離し、ロジックと混ぜない
- 計算はPython側、テンプレートは表示のみ
- CSVから一括生成すれば、月末の作業が数秒で終わる
- ページ番号・ヘッダー繰り返しは
@pageとdisplay: table-header-groupで対応 - 金額に関わる処理なので、件数・総額の検証ログは必ず入れる
一度テンプレートを作ってしまえば、翌月からは実行するだけです。請求書に限らず、納品書・見積書・報告書など決まった書式の帳票すべてに応用できます。
帳票・書類作成の自動化のご相談
「毎月の請求書発行を自動化したい」「基幹システムのデータから帳票を出力したい」などのご相談を受け付けています。