先に結論 — pip さえ通せるなら、外部フォントの調達も変換ソフトの導入も無しで日本語の A4 帳票 PDF が出せます。決めるべきはフォントです。社内で回す帳票なら同梱の CID フォント、社外に出す提出物なら静的 TTF を埋め込む——同じ帳票(グラフ画像なし・表だけの版)が CID で 3,963 バイト、メイリオ埋め込みで 35,892 バイトになり、CID のほうは PDF にフォントが埋め込まれません(6 章)。

python -m pip install reportlab matplotlib

3 章の最小コードは reportlab だけで動きます。5 章以降の完成コードはグラフを描くので matplotlib も必要です(Pillow は reportlab の依存として一緒に入ります)。検証機のバージョンは 11 章に載せました。紙面の仕上がりは 図 3(8.3)を見てください。

この記事が扱わないこと: PDF/A 準拠と電子署名(デジタル署名・タイムスタンプ)は範囲外です。 長期保存や改ざん検知が要件に入っている提出物は、本記事のコードだけでは要件を満たしません(11 章に一覧)。

この記事の目次

1. はじめに — 成績書だけが手作業で残る

測定機の出力は CSV で取れている。集計もグラフも Python に移した。それでも月末になると、成績書のひな形を開いてロット番号を打ち直し、測定値を貼り付け、判定欄に「OK」と入れ、印刷プレビューで 1 行はみ出しているのを直す——この工程だけが手元に残ります。ロットごとに 1 枚必要なら、そこだけで半日が溶けます。

この記事がやるのは、測定値のテーブルを入力として A4 の PDF を直接組み立てることです。 判定ロジックも Python 側に持たせるので、人の目で規格値と実測値を見比べる工程が消え、転記ミスと判定ミスが同時に減ります。グラフを作るところまでは Python で製造データを可視化する — pandas と Matplotlib で品質管理グラフを作る で扱っているので、本記事はその出口を担当します。

なお、本記事の品名・ロット番号・規格値・測定値はすべて記事用の架空データで、帳票の様式も実在する企業の様式を再現したものではありません。

2. 手段の選び方 — 現場 PC に何を入れられるか

現場で効く選定軸は、機能の多寡よりもその PC に何をインストールできるかです。次の表は 2026 年 9 月 16 日に PyPI の JSON API と各公式ドキュメントから取得した一次情報で、実際に動かしたのは ReportLab だけ。他の 4 つは公式メタデータに基づく机上の比較として読んでください。

手段バージョンライセンス必要な Pythonpip 以外に要るもの
ReportLab
(本記事で使用)
5.0.1 BSD License >=3.9,<4 なし(PDF を直接組み立てる)
fpdf2 2.8.8 LGPL-3.0-only >=3.10 なし
openpyxl 3.1.5 MIT >=3.8 なし。ただし出力は .xlsx で PDF 化は別工程
WeasyPrint 70.0 BSD License
(PyPI 分類子の表記。条項の版はリポジトリの LICENSE を参照)
>=3.10 Windows では Pango の別途導入(公式は MSYS2 経由を案内)
LibreOffice headless — — — LibreOffice 本体のインストール
  • ライセンスは配布形態で効いてきます。 自分の PC で動かすだけなら気にする場面は少ないですが、9 章のように exe にして配る場合は別です。fpdf2 の LGPL-3.0-only のような条件付きライセンスを社内配布物へ組み込むときは、条文を確認したうえで法務・知財の担当部門に判断を仰いでください。
  • WeasyPrint は pip だけで完結しません。 公式の First steps には「When Python is installed, you have to install Pango and its dependencies. The easiest way to install these libraries is to use MSYS2.」とあり、pacman -S mingw-w64-ucrt-x86_64-pango が案内されています。管理者権限に制約のある PC では、この 1 行が壁になり得ます。
  • openpyxl は「既存の Excel 様式を壊さない」別解です。ただし PDF 化には Excel 本体(COM 経由)か変換器がもう 1 段要ります(LibreOffice なら soffice --headless --convert-to pdf <file> --outdir <dir> が公式ヘルプ記載のコマンド)。

分かれ道はここです。既存の Excel 様式を維持しなければならないなら openpyxl + 変換器、レイアウトをこちらで決めてよいなら ReportLab。本記事は後者で、COM 経由の道筋は pywin32 で Excel を自動化する記事(2026 年 12 月公開)で扱います。

2.1 ReportLab を選ぶ現場的な理由

ReportLab 5.0.1 の wheel は py3-none-any、つまり純 Python のパッケージです。検証機では高速化用の C 拡張 _rl_accel が含まれず、import _rl_accel は ModuleNotFoundError になって純 Python 実装(rl_accel.py)で動いていました。コンパイル済みバイナリを含まないぶん、実行ファイルを制限する設定に引っかかる余地が小さいということです。

検証機は Smart App Control が On でしたが、同時に入れた Pillow・numpy・matplotlib も import に成功し、ブロックは起きませんでした(Smart App Control の挙動一般は検証していません)。なお ReportLab には商用の PLUS 版もありますが、その機能詳細と価格は確認していません。

3. まず動かす — ReportLab で 20 行の最小 PDF 帳票

設計の話の前に、一度 PDF を出してしまうのが早道です。次のコードは空行・コメント・docstring を除いて 23 行。日本語は ReportLab 同梱の CID フォントで出すので、フォントファイルの用意は要りません。この章は reportlab だけで動きます(matplotlib は 5 章から必要です)。

# -*- coding: utf-8 -*-
"""最小の帳票(記事の「まず20行」用)"""
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import mm
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.cidfonts import UnicodeCIDFont
from reportlab.platypus import SimpleDocTemplate, Paragraph, Table, TableStyle
from reportlab.lib.styles import getSampleStyleSheet
from reportlab.lib import colors

pdfmetrics.registerFont(UnicodeCIDFont("HeiseiKakuGo-W5"))
style = getSampleStyleSheet()["Normal"]
style.fontName = "HeiseiKakuGo-W5"

doc = SimpleDocTemplate("minimal.pdf", pagesize=A4,
                        leftMargin=20 * mm, rightMargin=20 * mm,
                        topMargin=20 * mm, bottomMargin=20 * mm)
rows = [["検査項目", "規格値", "実測値", "判定"],
        ["外径 (mm)", "10.00 ± 0.05", "10.02", "OK"],
        ["板厚 (mm)", "2.00 ± 0.10", "2.13", "NG"]]
table = Table(rows, colWidths=[45 * mm, 45 * mm, 40 * mm, 20 * mm])
table.setStyle(TableStyle([
    ("FONTNAME", (0, 0), (-1, -1), "HeiseiKakuGo-W5"),
    ("GRID", (0, 0), (-1, -1), 0.5, colors.black),
    ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#E8E8E8")),
]))
doc.build([Paragraph("検査成績書(サンプル品A)", style), table])

実行結果です。

2752 bytes  minimal.pdf

2,752 バイトの PDF に、日本語のタイトルと罫線付きの表が載ります。押さえる要素は 3 つです。

  • registerFont(UnicodeCIDFont(...)) が日本語を出す 1 行(6 章)。SimpleDocTemplate は用紙サイズと余白を決め、部品を上から流し込む器です。内部単位はポイント(pt)なので、長さは 20 * mm のように掛けて渡します。
  • Table が中身、TableStyle が見た目(罫線・背景・文字色)。(0, 0), (-1, -1) は「左上から右下まで」=全セルの意味です。
  • doc.build([...]) に渡す「上から順に流し込まれる部品」を Flowable、その仕組み全体を Platypus と呼びます。完成コードは、ここにヘッダー・フッター・判定ロジック・グラフを足したものです。

4. 検査成績書に必要な要素を整理する

コードを足す前に、載せる情報を決めます。記載項目は一般に次のように説明されます(確認したのは二次情報で、業種・取引先によって要求は異なります)。

  • だれが発行したか — 発行者情報、発行日、文書番号
  • 何を検査したか — 製品名称、型番・品番、ロット番号、数量
  • どう検査したか — 検査項目、検査方法、規格値・基準値
  • 結果はどうだったか — 実測値、合否判定、総合判定
  • だれが確認したか — 検査担当者・承認者、検査日、備考

注意したいのは、「検査成績書」と一口に言っても書類の格が区別される場合があることです。鋼および鋼製品の分野には、検査文書の種類を区分する考え方を定めた規格として JIS G 0415 があります。本記事では条文を引用しません(規格本文は購入が必要で、確認できたのは要約・解説の範囲です)。提出先から「3.1 相当で」といった指定を受けたら、規格原本を JSA(日本規格協会)で確認し、品質保証部門の判断を仰いでください。Python で PDF を作る話と、その PDF がどの区分の文書として通用するかは別の問題です。

そのうえで、サンプル様式には次を載せます。承認欄・押印欄は入れていません(必要なら表をもう 1 つ足すだけです)。

  • 全ページ共通のヘッダー罫線・文書番号と、フッター罫線・ページ番号
  • タイトル、品名・ロット番号・検査日・検査員の 2 列表
  • No.・検査項目・規格値・下限・上限・実測値・判定の 7 列表(NG 行は色を変える)と総合判定
  • 2 ページ目に測定値の余裕度を示す参考図

4.1 承認欄・押印欄を足すなら

様式に承認欄が要るなら、空セルの表をもう 1 つ流し込むだけです。押印スペースは行の高さ(rowHeights)で確保します。FONT・Table・TableStyle は 5 章の完成コードのものです。

def approval_table():
    """検査/照査/承認の 3 欄。中身は空で、印を押す or 署名する前提。"""
    data = [["検査", "照査", "承認"], ["", "", ""]]
    t = Table(data, colWidths=[30 * mm] * 3, rowHeights=[6 * mm, 18 * mm],
              hAlign="RIGHT")
    t.setStyle(TableStyle([
        ("FONTNAME", (0, 0), (-1, -1), FONT), ("FONTSIZE", (0, 0), (-1, -1), 8.5),
        ("GRID", (0, 0), (-1, -1), 0.4, colors.HexColor("#888888")),
        ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#F0F0F0")),
        ("ALIGN", (0, 0), (-1, -1), "CENTER"),
        ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
    ]))
    return t

ただし「欄がある」ことと「承認された文書である」ことは別です。 電子的な承認の記録が要件なら、押印欄の画像ではなく電子署名(本記事の範囲外・11 章)か、承認履歴を残すワークフロー側の問題になります。様式を変えるときは品質保証部門の承認を得てください(11.1)。本記事のサンプル様式はこの欄を持たない構成のままで、上の追加例は 11 章の実測セット(2026 年 9 月 16 日)に含みません。

5. 完成コード — 138 行の成績書ジェネレータ

測定データがどこから来て、どの形を経て PDF になるかの全体像です。

CSV または SQLite の測定データが Python の判定処理を経て Platypus の Flowable の列になり、SimpleDocTemplate.build で PDF になるまでのデータフロー図 左端に測定データの入力として CSV ファイルと SQLite データベースが並ぶ。次に Python 側の処理として、ロットごとに行をまとめる読み込み、規格の下限と上限で OK か NG かを決める判定、規格幅に対する割合への正規化、matplotlib での PNG 生成の 4 つが並ぶ。その結果が Platypus の Flowable の列となり、タイトルの Paragraph、メタ情報の Table、検査結果の Table、総合判定の Paragraph、改ページの PageBreak、グラフの Image の順に並ぶ。最後に SimpleDocTemplate の build に渡されて 2 ページの PDF になる。build には onFirstPage と onLaterPages のコールバックが渡され、全ページに共通のヘッダー罫線とフッターのページ番号が canvas へ直接描かれる。 測定データが A4 の PDF になるまで ① 測定データ measurements.csv 37,724 bytes measurements.db SQLite / 57,344 bytes ② Python の処理 load_csv / load_sqlite ロット単位に行をまとめる judge(value, lower, upper) OK / NG を決める margin_pct(row) 規格幅に対する % へ正規化 chart_png(rows, path) matplotlib で棒グラフ PNG → 所要時間の約 7 割 ③ Flowable の列 Paragraph(タイトル) Table(品名・ロット) Table(7 列・判定) Paragraph(総合判定) PageBreak Image(グラフ PNG) ④ PDF doc.build(flow, ...) SimpleDocTemplate report_single.pdf 2 ページ / 21,078 bytes 全ページに重ねて描かれるもの(Flowable ではない) doc.build(flow, onFirstPage=draw_frame, onLaterPages=draw_frame) draw_frame(canvas, doc) が canvas に直接描く: ヘッダー罫線 / 文書番号 / フッター罫線 / ページ番号「- N -」
図 1: 測定データから検査成績書 PDF までのデータフロー(本記事の実測構成)
  1. サイズ・ページ数・所要時間はいずれも検証機での実測値です(5.2・7 章)。
  2. ③ の Flowable はページに収まらなければ自動で次ページへ送られます。ヘッダー・フッターは流れに乗せず、④ のコールバックで毎ページ描きます。

5.1 inspection_report.py(138 行)

以下が実際に動かしたコードの全文です。このファイルには reportlab と matplotlib の 2 つが必要です(python -m pip install reportlab matplotlib)。

# -*- coding: utf-8 -*-
"""検査成績書 PDF ジェネレータ(記事用・データはすべて架空)"""
import os

import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt

from reportlab.lib import colors
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import ParagraphStyle
from reportlab.lib.units import mm
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.cidfonts import UnicodeCIDFont
from reportlab.platypus import (Image, PageBreak, Paragraph, SimpleDocTemplate,
                                Spacer, Table, TableStyle)

FONT = "HeiseiKakuGo-W5"
MARGIN = 18 * mm


def register_font():
    """同梱 CID フォントを登録する(外部フォントファイル不要)。"""
    pdfmetrics.registerFont(UnicodeCIDFont(FONT))


def styles():
    return {
        "title": ParagraphStyle("t", fontName=FONT, fontSize=16, leading=22,
                                alignment=1),
        "body": ParagraphStyle("b", fontName=FONT, fontSize=9, leading=13),
        "cell": ParagraphStyle("c", fontName=FONT, fontSize=8.5, leading=12),
    }


def draw_frame(canvas, doc):
    """全ページ共通のヘッダー罫線とフッター(ページ番号)。"""
    canvas.saveState()
    canvas.setFont(FONT, 8)
    w, h = A4
    canvas.setLineWidth(0.8)
    canvas.line(MARGIN, h - MARGIN + 4 * mm, w - MARGIN, h - MARGIN + 4 * mm)
    canvas.drawString(MARGIN, h - MARGIN + 6 * mm, "検査成績書 / INSPECTION REPORT")
    canvas.drawRightString(w - MARGIN, h - MARGIN + 6 * mm, "文書番号 QR-2026-0001")
    canvas.line(MARGIN, MARGIN, w - MARGIN, MARGIN)
    canvas.drawString(MARGIN, MARGIN - 5 * mm, "※本票は記事用の架空データです")
    canvas.drawRightString(w - MARGIN, MARGIN - 5 * mm,
                           "- %d -" % canvas.getPageNumber())
    canvas.restoreState()


def meta_table(meta, st):
    """品名・ロット・検査日・検査員の 2 列 x 4 行。"""
    data = [[k, Paragraph(v, st["cell"])] for k, v in meta.items()]
    t = Table(data, colWidths=[30 * mm, 60 * mm], hAlign="LEFT")
    t.setStyle(TableStyle([
        ("FONTNAME", (0, 0), (-1, -1), FONT), ("FONTSIZE", (0, 0), (-1, -1), 8.5),
        ("GRID", (0, 0), (-1, -1), 0.4, colors.HexColor("#888888")),
        ("BACKGROUND", (0, 0), (0, -1), colors.HexColor("#F0F0F0")),
        ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
    ]))
    return t


def judge(value, lower, upper):
    return "OK" if lower <= value <= upper else "NG"


def result_table(rows, st):
    """規格値 x 実測値 x 判定。NG 行だけ背景を変える。"""
    head = ["No.", "検査項目", "規格値", "下限", "上限", "実測値", "判定"]
    data = [head]
    ng = []
    for i, r in enumerate(rows, 1):
        v = judge(r["value"], r["lower"], r["upper"])
        if v == "NG":
            ng.append(i)
        data.append([str(i), Paragraph(r["item"], st["cell"]), r["spec"],
                     "%.3f" % r["lower"], "%.3f" % r["upper"],
                     "%.3f" % r["value"], v])
    t = Table(data, repeatRows=1, colWidths=[10 * mm, 46 * mm, 32 * mm,
                                             20 * mm, 20 * mm, 22 * mm, 14 * mm])
    style = [("FONTNAME", (0, 0), (-1, -1), FONT), ("FONTSIZE", (0, 0), (-1, -1), 8.5),
             ("GRID", (0, 0), (-1, -1), 0.4, colors.HexColor("#888888")),
             ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#DDE5EE")),
             ("ALIGN", (3, 1), (-1, -1), "RIGHT"),
             ("ALIGN", (6, 0), (6, -1), "CENTER"),
             ("VALIGN", (0, 0), (-1, -1), "MIDDLE")]
    for i in ng:
        style.append(("BACKGROUND", (0, i), (-1, i), colors.HexColor("#FBE2E2")))
        style.append(("TEXTCOLOR", (6, i), (6, i), colors.HexColor("#B00020")))
    t.setStyle(TableStyle(style))
    return t, len(ng)


def margin_pct(r):
    """規格幅の何 % のところに実測値があるか(0%=中心, ±100%=規格限界)。"""
    center = (r["lower"] + r["upper"]) / 2
    half = (r["upper"] - r["lower"]) / 2
    return (r["value"] - center) / half * 100


def chart_png(rows, path):
    """単位の違う検査項目を規格幅に対する % に正規化して 1 枚の図にする。"""
    fig, ax = plt.subplots(figsize=(6.0, 2.2), dpi=150)
    x = list(range(len(rows)))
    y = [margin_pct(r) for r in rows]
    ax.bar(x, y, color=["#B00020" if abs(v) > 100 else "#2C5F8A" for v in y])
    ax.axhline(100, ls="--", color="#B00020", lw=1)
    ax.axhline(-100, ls="--", color="#B00020", lw=1)
    ax.set_ylim(-140, 140)
    ax.set_xticks(x)
    ax.set_xticklabels(["No.%d" % (i + 1) for i in x], fontsize=7)
    ax.set_ylabel("% of spec width", fontsize=8)
    ax.tick_params(labelsize=7)
    ax.grid(axis="y", alpha=0.3)
    fig.tight_layout()
    fig.savefig(path)
    plt.close(fig)
    return path


def build_report(meta, rows, out_path, png_path="chart.png"):
    register_font()
    st = styles()
    doc = SimpleDocTemplate(out_path, pagesize=A4, leftMargin=MARGIN,
                            rightMargin=MARGIN, topMargin=MARGIN + 6 * mm,
                            bottomMargin=MARGIN + 6 * mm, title="検査成績書")
    table, ng = result_table(rows, st)
    verdict = "合格 (PASS)" if ng == 0 else "不合格 (FAIL) / NG %d 項目" % ng
    flow = [Paragraph("検 査 成 績 書", st["title"]), Spacer(1, 6 * mm),
            meta_table(meta, st), Spacer(1, 5 * mm), table, Spacer(1, 5 * mm),
            Paragraph("総合判定: <b>%s</b>" % verdict, st["body"]),
            PageBreak(),
            Paragraph("測定値の推移(参考図)", st["body"]), Spacer(1, 4 * mm),
            Image(chart_png(rows, png_path), width=160 * mm, height=58.7 * mm)]
    doc.build(flow, onFirstPage=draw_frame, onLaterPages=draw_frame)
    return os.path.getsize(out_path)

各関数の役割は docstring のとおりです。読み取りにくい点だけ補足します。

  • draw_frame() は Flowable ではなく canvas へ直接描きます(現在ページは canvas.getPageNumber())。meta_table() の hAlign="LEFT" は左寄せ指定で、既定は中央です。
  • register_font() と FONT 定数の差し替えだけでフォント方式を変えられます(6 章)。
  • chart_png() の matplotlib.use("Agg") と plt.close(fig) が、バッチで回すときの要です(7 章)。

判定を人から Python に移す。 judge() は 1 行ですが、この記事の実質はここです。下限・上限をデータとして持たせ、比較をコードに任せた瞬間、「見落とし」という不良モードが消えます。NG 行の色は人間向けの補助であって、判定の根拠ではありません。

そのぶん、判定に使う値と紙に印字する値が別物であることは押さえておいてください。judge() が見るのは lower <= value <= upper の生の float で、表に出るのは "%.3f" に丸めた文字列です。規格限界ちょうどの値は OK 側に入りますが、印字が 2.100 でも生値が 2.1000000001 なら NG になります。紙の上では「規格内なのに NG」に見えるので、丸め桁は規格の桁と合わせ、境界の扱いは品質保証部門と先に決めておくのが安全です。

単位の違う項目を 1 枚のグラフにする。 外径(mm)・硬さ(HRC)・重量(g)を素の値で並べても意味のある図になりません。margin_pct() は「規格の中心を 0 %、規格限界を ±100 %」とする換算で、どの項目がどれだけ余裕を残しているかを 1 本の軸に揃えます。グラフ内のラベルは英語です(matplotlib が既定で日本語フォントを持たないため。解決法は 品質管理グラフの記事に寄せました)。PDF の文字とグラフ内の文字は別系統です。

この換算は両側規格を前提にしています。次の 5.2 では「表面粗さ Ra 1.60 以下」という片側規格を lower=0.0 として入れていますが、その瞬間 margin_pct() は中心を 0.80 とみなすので、参考図の棒は他項目と同じ意味の「余裕度」になりません(合否判定そのものは 0.0 <= value <= 1.60 で正しく出ます)。片側規格を図に載せるなら、上限までの残りを別の式で出すか、図から外してください。

5.2 呼び出し側と実行結果

データ定義と実行部分です。inspection_report.py とは別のファイル(例: run_single.py)に置き、同じフォルダから実行します。値はすべて架空で、板厚だけ規格から外れるように置いています。

import time

from inspection_report import build_report

META = {"品名": "サンプル品A(架空)", "ロット番号": "LOT-2026-0001",
        "検査日": "2026-10-20", "検査員": "検査員 A"}
ROWS = [
    {"item": "外径", "spec": "10.00 ± 0.05", "lower": 9.95, "upper": 10.05, "value": 10.021},
    {"item": "内径", "spec": "6.00 ± 0.05", "lower": 5.95, "upper": 6.05, "value": 5.982},
    {"item": "全長", "spec": "45.0 ± 0.2", "lower": 44.8, "upper": 45.2, "value": 45.07},
    {"item": "板厚", "spec": "2.00 ± 0.10", "lower": 1.90, "upper": 2.10, "value": 2.134},
    {"item": "表面粗さ Ra", "spec": "1.60 以下", "lower": 0.0, "upper": 1.60, "value": 1.24},
    {"item": "硬さ HRC", "spec": "58 〜 62", "lower": 58.0, "upper": 62.0, "value": 60.4},
    {"item": "重量", "spec": "120 ± 3 g", "lower": 117.0, "upper": 123.0, "value": 121.2},
]

t0 = time.perf_counter()
size = build_report(META, ROWS, "report_single.pdf")
t1 = time.perf_counter()
print("1 件生成: %.3f 秒 / %d bytes" % (t1 - t0, size))

実行結果です(2 件目と、行数を 60 行に増やした版も続けて計測)。

1 件生成: 0.082 秒 / 21078 bytes
2 件目   : 0.067 秒 / 21078 bytes
60 行版  : 0.222 秒 / 37507 bytes
report_single.pdf ページ数: 2
report_60rows.pdf ページ数: 3

7 項目・2 ページの成績書が 0.08 秒前後、21,078 バイト。60 行に増やすと PDF 全体が 3 ページになります。表は 1〜2 ページ目にまたがり、2 ページ目の先頭にも見出し行が繰り返されました(repeatRows=1 の効果)。3 ページ目は PageBreak() の後の参考図ページです(ページ数は pypdf で計測)。出来上がる PDF はこうなります(紙面の寸法は 図 3(8.3))。

  • 1 ページ目: ヘッダー罫線・文書番号 QR-2026-0001、タイトル「検 査 成 績 書」、2 列のメタ表、7 列の結果表。板厚 2.134 mm が規格 2.00 ± 0.10 を外れて NG(背景が薄赤・判定文字が赤)、総合判定は「不合格 (FAIL) / NG 1 項目」。フッター罫線と「※本票は記事用の架空データです」、右下にページ番号「- 1 -」。
  • 2 ページ目: 「測定値の推移(参考図)」と、7 項目の余裕度を規格幅の % で並べた棒グラフ。±100 % に破線が引かれ、規格を外れた板厚の棒だけ赤になります。

6. ReportLab で日本語 PDF を出す 3 方式の実測 — 社内は CID・社外は TTF

ReportLab で日本語を出す方法は 3 つあります。見た目の好みではなく、「その PDF を誰が開くか」で決まる設計上の判断です。

6.1 登録できるか — 試した 7 フォントはすべて登録に成功

同じ 1 ページの見本を、方式だけ変えて生成した結果です。

== 方式1: 同梱 CID フォント(フォントファイル不要) ==
CID HeiseiMin-W3                   OK      3497 bytes  (font_cid_HeiseiMin-W3.pdf)
CID HeiseiKakuGo-W5                OK      2884 bytes  (font_cid_HeiseiKakuGo-W5.pdf)

== 方式2: Noto Sans JP TTF を埋め込み ==
path exists: True C:\WINDOWS\Fonts\NotoSansJP-VF.ttf
TTF NotoSansJP-VF                  OK     17556 bytes  (font_ttf_noto.pdf)

== 方式3: Windows 同梱システムフォント(TTC) ==
TTC Meiryo (meiryo.ttc)            OK     28489 bytes  (font_ttc_Meiryo.pdf)
TTC YuGothic-Regular (YuGothR.ttc) OK     29881 bytes  (font_ttc_YuGothic-Regular.pdf)
TTC MSGothic (msgothic.ttc)        OK     17375 bytes  (font_ttc_MSGothic.pdf)
TTC BIZ-UDGothic (BIZ-UDGothicR.ttc) OK    22924 bytes  (font_ttc_BIZ-UDGothic.pdf)

登録のコードは次のとおりです。

from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.cidfonts import UnicodeCIDFont
from reportlab.pdfbase.ttfonts import TTFont

# 方式1: 同梱 CID フォント(ファイル不要)
pdfmetrics.registerFont(UnicodeCIDFont("HeiseiKakuGo-W5"))

# 方式2: TTF を埋め込む
pdfmetrics.registerFont(TTFont("NotoSansJP", r"C:\WINDOWS\Fonts\NotoSansJP-VF.ttf"))

# 方式3: TTC(複数フォントの入れ物)は subfontIndex で 1 つ選ぶ
pdfmetrics.registerFont(TTFont("Meiryo", r"C:\WINDOWS\Fonts\meiryo.ttc", subfontIndex=0))

メイリオや MS ゴシックの .ttc(複数の書体が 1 ファイルに入った形式)も subfontIndex=0 で登録できました。 見本には 髙 﨑 ①②③ μ を入れてあり、いずれの方式でも欠字せず描画されました(PyMuPDF で 110 dpi にラスタライズして目視)。異体字・丸数字・単位の μ は成績書に出やすいので、自分の環境でも同じ確認をしておくと安心です。

6.2 PDF に埋め込まれるかどうか(ここが分かれ道)

同じ成績書をフォント方式だけ変えて生成し、PDF の中身を PyMuPDF で調べた結果です(日本語フォントの行のみ抜粋。実際の出力には、描画には使われない Helvetica[Type1,WinAnsiEncoding,embedded=False] も併記されます)。

CID HeiseiKakuGo-W5          21078 bytes  HeiseiKakuGo-W5[Type0,UniJIS-UCS2-H,embedded=False]
CID HeiseiMin-W3             21701 bytes  HeiseiMin-W3[Type0,UniJIS-UCS2-H,embedded=False]
TTF NotoSansJP-VF            41055 bytes  AAAAAA+NotoSansJP-Thin[TrueType,embedded=True]
TTC Meiryo                   55613 bytes  AAAAAA+Meiryo-0[TrueType,embedded=True]
TTC MSGothic                 40300 bytes  AAAAAA+MS-Gothic-0[TrueType,embedded=True]

== 画像なし(表だけ)での比較 ==
CID HeiseiKakuGo-W5           3963 bytes
TTF NotoSansJP-VF            22372 bytes
TTC Meiryo                   35892 bytes

① CID 方式は、フォントが PDF に埋め込まれません(embedded=False)。 PDF には「この文字をこのフォント名で描いて」という指示だけが入り、実体は入りません。だからファイルが小さく、表だけの比較では 3,963 バイト——メイリオ埋め込み(35,892 バイト)の約 9 分の 1 です。代わりに、表示は開く側の環境が持つ日本語フォントに依存します(今回はビューア側が代替フォントで描画)。「文字化けする」と断定できる検証はしていませんが、社外に出す提出物では相手の環境で書体が変わりうる点は判断材料に入れておくべきです。

② TTF / TTC はサブセットで埋め込まれます。 フォント名の AAAAAA+ が、使った文字だけを抜き出した印です。相手の環境に関係なく同じ書体になる代わりに、使う文字の種類が増えればファイルも大きくなります。

③ 可変フォントには注意が必要です。 Windows 11 同梱の NotoSansJP-VF.ttf は可変フォント(Variable Font)で、ReportLab が埋め込んだ実体は NotoSansJP-Thin、つまり最も細いマスターでした。画面では読めても、印刷すると線が細く頼りない帳票になります。Noto Sans JP を使うなら、Google Fonts 配布の静的ウェイト(Regular)を別途用意するのが安全です。ライセンスは SIL Open Font License 1.1(フォント単体の販売は不可・再配布は可)ですが、配布元のライセンス表記を各自で確認してください(9 章のように exe へ同梱する場合はとくに)。

6.3 結論: どちらを選ぶか

用途おすすめ理由
社内で回す帳票 CID フォント
HeiseiKakuGo-W5 / HeiseiMin-W3
軽い(表だけで 3,963 bytes)。フォントファイルの配置もライセンス確認も不要
社外に出す提出物 静的 TTF の埋め込み
(Regular ウェイト)
相手の環境に依存せず書体が固定される。ただし再配布条件の確認が要る

完成コードは CID 方式(HeiseiKakuGo-W5)が既定です。

7. CSV・SQLite から 100 件を一括生成する

入力を CSV と SQLite の 2 通りで用意し、100 ロット分(1 ロット 7 項目・2 ページ)を生成しました。まず入力の形です。CSV は1 行 1 検査項目・ロット番号で束ねる持ち方にしてあります(先頭 4 行を抜粋・値は架空)。

lot,item,spec,lower,upper,value
LOT-2026-0001,外径,10.00 ± 0.05,9.95,10.05,10.032
LOT-2026-0001,内径,6.00 ± 0.05,5.95,6.05,6.028
LOT-2026-0001,全長,45.0 ± 0.2,44.8,45.2,45.148

SQLite 側も同じ列構成で、id を 1 本足しただけです。

CREATE TABLE measurement(id INTEGER PRIMARY KEY, lot TEXT, item TEXT,
                         spec TEXT, lower REAL, upper REAL, value REAL)

読み込みから一括生成・計測までの全体です。以下は 1 つのファイル(例: batch_report.py)にまとめ、inspection_report.py と同じフォルダに置いて実行しました(measurements.csv と measurements.db は上の形式で用意した 100 ロット分のサンプルです)。

import csv
import os
import shutil
import sqlite3
import time

import inspection_report as ir


def load_csv(path):
    lots = {}
    with open(path, encoding="utf-8") as f:
        for r in csv.DictReader(f):
            lots.setdefault(r["lot"], []).append(
                {"item": r["item"], "spec": r["spec"], "lower": float(r["lower"]),
                 "upper": float(r["upper"]), "value": float(r["value"])})
    return lots


def load_sqlite(db):
    con = sqlite3.connect(db)
    lots = {}
    for lot, item, spec, lo, up, v in con.execute(
            "SELECT lot,item,spec,lower,upper,value FROM measurement ORDER BY lot,id"):
        lots.setdefault(lot, []).append(
            {"item": item, "spec": spec, "lower": lo, "upper": up, "value": v})
    con.close()
    return lots


_orig_chart = ir.chart_png  # 差し替える前を退避しておく


def batch(lots, outdir, with_chart=True):
    os.makedirs(outdir, exist_ok=True)
    if not with_chart:
        ir.chart_png = lambda rows, path: path  # 既存 PNG を使い回す(再生成しない)
    else:
        ir.chart_png = _orig_chart
    total = 0
    t0 = time.perf_counter()
    for lot, rows in lots.items():
        p = os.path.join(outdir, "%s.pdf" % lot)
        total += ir.build_report(
            {"品名": "サンプル品A(架空)", "ロット番号": lot,
             "検査日": "2026-10-20", "検査員": "検査員 A"},
            rows, p, png_path=os.path.join(outdir, "_chart.png"))
    return time.perf_counter() - t0, total


if __name__ == "__main__":
    lots = load_csv("measurements.csv")
    print("CSV から読み込んだロット数:", len(lots),
          "/ 1 ロットの検査項目数:", len(next(iter(lots.values()))))
    ir.build_report({"品名": "x", "ロット番号": "x", "検査日": "x", "検査員": "x"},
                    next(iter(lots.values())), "warmup.pdf")  # ウォームアップ
    el, total = batch(lots, "out_csv")
    print("CSV 100 件(グラフ画像あり) : %.2f 秒 / 1 件あたり %.3f 秒 / 合計 %.1f MB"
          % (el, el / len(lots), total / 1024 / 1024))

    lots2 = load_sqlite("measurements.db")
    el2, total2 = batch(lots2, "out_db")
    print("SQLite 100 件(グラフ画像あり): %.2f 秒 / 1 件あたり %.3f 秒 / 合計 %.1f MB"
          % (el2, el2 / len(lots2), total2 / 1024 / 1024))

    os.makedirs("out_reuse", exist_ok=True)
    shutil.copy(os.path.join("out_csv", "_chart.png"),
                os.path.join("out_reuse", "_chart.png"))  # 使い回す PNG を先に置く
    el3, total3 = batch(lots, "out_reuse", with_chart=False)
    print("100 件(グラフ PNG 使い回し)  : %.2f 秒 / 1 件あたり %.3f 秒 / 合計 %.1f MB"
          % (el3, el3 / len(lots), total3 / 1024 / 1024))
    print("CSV ファイルサイズ: %d bytes / SQLite: %d bytes"
          % (os.path.getsize("measurements.csv"), os.path.getsize("measurements.db")))

どちらの読み込み関数も、ロット番号をキーにした辞書に行をまとめる形に揃えています。こうしておけば、build_report は入力がどこから来たかを知らずに済みます。

2 点、先に断っておきます。with_chart=False の lambda は PNG を作らずパスを返すだけです。 build_report() はその戻り値を Image() に渡すので、出力先に _chart.png が無い状態で使い回し版から実行すると 1 件目で落ちます。後述の 2.26 秒は、先に走らせた CSV 版の _chart.png を出力先へコピーしてから計測した値です。もう 1 点、ロット番号をそのままファイル名にしています。A-123/45 のようにスラッシュや空白を含む採番規則なら、意図しないサブフォルダやエラーになるので記号を落としてから渡してください。

実行結果です。

CSV から読み込んだロット数: 100 / 1 ロットの検査項目数: 7
CSV 100 件(グラフ画像あり) : 7.88 秒 / 1 件あたり 0.079 秒 / 合計 2.0 MB
SQLite 100 件(グラフ画像あり): 8.29 秒 / 1 件あたり 0.083 秒 / 合計 2.0 MB
100 件(グラフ PNG 使い回し)  : 2.26 秒 / 1 件あたり 0.023 秒 / 合計 2.1 MB
CSV ファイルサイズ: 37724 bytes / SQLite: 57344 bytes

① 読み込み元が CSV か SQLite かでは、100 件で 0.4 秒程度しか変わりませんでした。 ボトルネックはデータ取得ではありません。SQLite を選ぶ理由は速度ではなく、同時アクセス(WAL による読み書きの共存)・履歴の蓄積・検索や集計のしやすさといった運用面です(製造データを SQLite で管理する — Excel からデータベース運用へ)。

② 所要時間の約 7 割は PDF 生成ではなくグラフ描画でした。 ロットごとに図を描き直すのをやめて PNG を使い回すと、7.88 秒 → 2.26 秒(約 3.5 倍)。図が全ロット共通で構わないなら 1 枚だけ作る、ロットごとに必要なら描画は避けられない、という判断になります。ロットごとに描くならplt.close(fig) を必ず呼んでください。呼ばないと Figure が積み上がります。

100 件で合計 2.0 MiB(上のログの「MB」は 1024 × 1024 換算=MiB のことです。10 進なら約 2.1 MB)、生成に 8 秒弱。この規模なら定期実行に載せられます。ただし無人で回すなら、成功したか失敗したかが後から分かる形にしてください(Python のログ設計)。print() で流していると、失敗に気づくのが提出期限の当日になります。上のコードに例外処理は入れていません。 測定値が空欄や --- の行が 1 つ混ざるだけで float() が ValueError を投げ、途中まで出来た PDF を残して止まります。実運用に載せるならロット単位で try し、落ちたロット番号を記録してください。そもそも紙にせず画面で見せるほうが速い場面もあり、その比較は Streamlit の記事(2026 年 11 月公開)に譲ります。

7.1 起動コスト — 遅いのは PDF 生成ではなく import

matplotlib のフォントキャッシュ(fontlist-v3.11.0.json)が無い状態と、ある状態で、プロセス起動から 1 枚目の PDF までを分解して測りました。キャッシュの場所は matplotlib 自身に聞けます。フォルダごと消す必要はなく、fontlist-*.json の 1 ファイルを削除すれば再現できます(パスを取り違えた一括削除は避けてください)。

$ python -c "import matplotlib; print(matplotlib.get_cachedir())"
<matplotlib cachedir>            # ← 表示されたフォルダの fontlist-v3.11.0.json だけを削除
--- フォントキャッシュ削除後(コールド) ---
import(reportlab + matplotlib): 0.812 秒
1 件目の build_report            : 0.084 秒 / 21082 bytes
2 件目の build_report            : 0.068 秒
合計(起動から 1 枚目まで)      : 0.896 秒
--- 2 回目(キャッシュあり=ウォーム) ---
import(reportlab + matplotlib): 0.524 秒
1 件目の build_report            : 0.090 秒 / 21082 bytes
2 件目の build_report            : 0.071 秒
合計(起動から 1 枚目まで)      : 0.613 秒

キャッシュが無くても build_report 自体は 0.084 秒でした。 起動から 1 枚目までの 0.896 秒のうち 0.812 秒は import です。つまり遅いのは PDF 生成ではなく importで、キャッシュの有無はその import 時間に乗ります(0.812 秒 → 0.524 秒)。

効いてくるのは設計です。タスクスケジューラで 1 日 1 回起動する使い方なら、この 0.5〜0.8 秒の起動コストが毎回乗る計算。1 プロセスで全ロットを回せば起動は 1 回で済み、2 件目以降は 0.07 秒前後で出続けます。まとめて 1 回走らせる設計のほうが速いということです。

8. 紙にして初めて気づく落とし穴 — PDF は正常なのに表が A4 からはみ出す

画面では問題なく見えるのに、印刷すると崩れている。実際にぶつかった点を実測付きで並べます。断っておくと、ここでの確認は PyMuPDF による座標の解析と画面上の目視までで、実際に印刷機へ流してはいません(11 章)。

8.1 セルに素の文字列を入れると折り返さない

これが一番危険です。Table のセルに素の str を入れると、列幅を超えても折り返さず横へ伸びます。長い検査項目名——「外観検査(キズ・打痕・バリ・変色・異物付着の有無を目視および 10 倍ルーペで確認する)」——で、罫線と文字の位置を PyMuPDF で測った結果です。

== セル折返し: 素の str vs Paragraph ==
素の str       罫線 x=170.1..425.2 pt / 文字 x=176.1..600.2 pt / はみ出し=175.0 pt
Paragraph    罫線 x=170.1..425.2 pt / 文字 x=176.1..414.1 pt / はみ出し=0.0 pt

素の文字列では、文字が x = 600.2 pt まで伸びました。A4 の紙幅は 595.3 pt です。 罫線の右端から 175.0 pt(約 62 mm)はみ出し、その先は紙の外——印刷すると消えます。例外も警告も出ません。対策は、折り返したいセルを Paragraph でくるむことです。

セルに素の文字列を入れた表と Paragraph でくるんだ表を並べ、前者の文字が罫線の右端 425.2pt から 600.2pt まで伸びて A4 の紙幅 595.3pt を超える様子を示す比較図 上段は素の文字列を入れた場合。表の外周罫線は横位置 170.1pt から 425.2pt にあるが、長い検査項目名は折り返さず横に伸び、右端が 600.2pt に達する。罫線の右端からのはみ出しは 175.0pt、約 62mm。A4 の紙幅は 595.3pt なので、文字の末尾は紙の外に出て印刷されない。下段は同じ文字列を Paragraph でくるんだ場合。文字は列幅の中で 3 行に折り返され、右端は 414.1pt で罫線の内側に収まり、はみ出しは 0.0pt になる。どちらの場合も例外や警告は出ない。 素の str と Paragraph — 文字がどこまで伸びるか(実測 pt) 紙の外 A4 の紙幅 595.3 pt NG: セルに素の str を入れた 検査項目 長い検査項目名が折り返さず横へ伸びる(素の str) はみ出し 175.0 pt(約 62 mm) 罫線 425.2 罫線 170.1 600.2 OK: Paragraph でくるんだ 検査項目 列幅の中で 3 行に折り返す はみ出し 0.0 pt(右端 414.1)
図 2: セル折返しの実測比較(数値は 8.1 の測定値。列幅 30 mm + 60 mm の表を左右余白 20 mm の A4 に置いた場合)
from reportlab.lib.styles import ParagraphStyle
from reportlab.platypus import Paragraph

CELL = ParagraphStyle("c", fontName="HeiseiKakuGo-W5", fontSize=8.5, leading=12)

# NG: 長い文字列をそのまま入れる
data.append([str(i), r["item"], ...])

# OK: Paragraph でくるむと列幅で折り返す
data.append([str(i), Paragraph(r["item"], CELL), ...])

Paragraph を使った側は、はみ出し 0.0 pt。完成コードが項目名と値を Paragraph でくるんでいるのはこのためです。「検査項目名は短いから大丈夫」は通用しません。品目が増えた半年後に、誰も気づかないまま紙の外へ文字が消えます。

8.2 列幅の合計がはみ出しても、何も言われない

次は表そのものがはみ出す場合。本文幅 170 mm のフレームに、合計 240 mm の表を入れました。

== 表の合計幅がフレーム幅を超えたら ==
240mm の表を 170mm のフレームに入れた結果: 例外なし・罫線 x=-15.0..225.0 mm(ページ幅 210.0 mm)=左右とも紙の外

例外も警告も出ません。 表は中央揃えのまま左右へ広がり、罫線が左 −15.0 mm・右 225.0 mm、つまり紙の両側からはみ出しました。PDF 自体は正常に生成されるので、気づくのは印刷したときです。

防ぎ方は、colWidths の合計を「ページ幅 − 左右余白」以内に収めること。完成コードの 7 列は 10 + 46 + 32 + 20 + 20 + 22 + 14 = 164 mm で、本文幅 174 mm の内側です。テストするなら sum(colWidths) <= A4[0] - leftMargin - rightMargin を assert しておくのが手軽です。

8.3 A4 の実効幅を把握しておく

余白をいくつにすると本文が何 mm になるか、実測しておきました。

== A4 とフレームの実効幅 ==
A4        : 595.3 x 841.9 pt = 210.0 x 297.0 mm
A4 横     : 297.0 x 210.0 mm
左右余白 15 mm → 本文幅 180.0 mm
左右余白 18 mm → 本文幅 174.0 mm
左右余白 20 mm → 本文幅 170.0 mm
左右余白 25 mm → 本文幅 160.0 mm

A4 縦は 595.3 × 841.9 pt。横向きにするなら pagesize=landscape(A4) を渡します。完成コードは左右余白 18 mm = 本文幅 174 mm です。

A4 縦 210×297mm の紙面に対して左右余白 18mm・本文幅 174mm を取り、ヘッダー罫線・タイトル・メタ表・検査結果表・総合判定・フッター罫線がどの位置に載るかを示すレイアウト図 A4 縦の紙は幅 210mm、高さ 297mm。左余白 18mm、本文幅 174mm、右余白 18mm。上からの位置は、ヘッダー文字が上端から 12mm、ヘッダー罫線が 14mm、本文の開始が 24mm。本文の中にはタイトルの検査成績書、品名とロット番号などの 2 列表が幅 90mm で左寄せ、検査結果の 7 列表が幅 164mm、総合判定の行が並ぶ。下からの位置は、本文の下端が下端から 24mm、フッター罫線が 18mm、フッター文字とページ番号が 13mm。グラフは 2 ページ目に幅 160mm、高さ 58.7mm で入る。 A4 縦 210 × 297 mm の中の配置(左右余白 18 mm) 検査成績書 / INSPECTION REPORT 文書番号 QR-2026-0001 検 査 成 績 書 2 列表 幅 90 mm(左寄せ) No. / 検査項目 / 規格値 / 下限 / 上限 / 実測値 / 判定 NG 7 列表 幅 164 mm 総合判定: 不合格 (FAIL) / NG 1 項目 (グラフは PageBreak の後、2 ページ目に 160 × 58.7 mm で配置) ※本票は記事用の架空データです - 1 - 18 本文幅 174 mm 18 用紙幅 210 mm 上 24 mm 罫線 14 / 文字 12 下 24 mm 罫線 18 / 文字 13 用紙 高さ 297 mm
図 3: 完成コードの紙面レイアウト(数値は 8.3 の実測値と inspection_report.py の設定に一致)
  1. 上下の余白は MARGIN + 6 * mm = 24 mm。ヘッダー・フッターは本文領域の外側に draw_frame() が直接描いています。
  2. 図はページ幅に対する比率を保っていますが、寸法線の位置は説明用に単純化しています。

8.4 画像のアスペクト比は自分で計算する

Platypus の Image は幅と高さを両方受け取るので、比率を無視した値を渡せばそのまま歪みます。元画像の実寸から計算するのが確実です。

== 画像のアスペクト比 ==
PNG 実寸: 900 x 330 px / アスペクト 0.3667
幅 160mm で入れるときの正しい高さ: 58.7 mm
from reportlab.lib.utils import ImageReader

w_px, h_px = ImageReader("chart.png").getSize()
width_mm = 160
height_mm = width_mm * h_px / w_px      # 160 * 330 / 900 = 58.666... → 58.7

完成コードの Image(..., width=160 * mm, height=58.7 * mm) はこの計算の結果です。

8.5 改ページとページ番号

道具は 3 つ。PageBreak() でページを切り、repeatRows=1 で見出し行を各ページの頭に繰り返し、onFirstPage= / onLaterPages= のコールバックでページ番号を canvas に描きます。

1 点、正直に書いておきます。「N / 全 M ページ」形式の総ページ数表示は今回実装していません。 1 パスの生成では描画時点で全体が何ページになるか確定していないためで(2 パス構成などの対応が要ります)、本記事のコードは "- %d -" 形式にとどめました。

8.6 印刷前チェックリスト

8 章で出てきた確認を、刷る前に通す順に並べます。どれも PDF 側は正常に生成されてしまう項目です。

  • sum(colWidths) <= A4[0] - leftMargin - rightMargin を assert したか(8.2)
  • 長くなりうるセル(検査項目名・備考)を Paragraph でくるんだか(8.1)
  • 実データで一番長い項目名を入れて 1 枚出したか(本記事の例では罫線の右端から 62 mm はみ出し、文字の末尾が紙の外へ出ました・8.1)
  • Image の幅と高さを元画像の実寸から計算したか(8.4)
  • 2 ページ以上になる件数で試し、repeatRows=1 の見出し繰り返しとページ番号を確認したか(8.5)
  • プリンタの用紙サイズと余白設定(「実際のサイズ」で印刷しているか。縮小印刷だと寸法が変わります)

9. exe にして配る(--onefile)との相性

「Python の入っていない PC でも動かしたい」という話は必ず出ます。ReportLab と CID フォント登録だけの最小スクリプトを PyInstaller で固めた結果です。

27755043 bytes  dist/rlreport.exe
exe OK 0.002 sec frozen=True

約 26.5 MiB。実行すると PDF が生成され、sys.frozen が True と出ました。注目点は、CID フォントの CMap データが追加指定なしで同梱されたことです。日本語 PDF を出す exe を --add-data の試行錯誤なしにビルドできました。

ただし最小構成での話です。matplotlib 入りの exe は今回測っていませんし、TTF を埋め込む構成では --add-data でフォントを同梱する必要があり、これも未検証です(再配布条件の確認も要ります・6.2)。配布方法・起動時間・誤検知といった exe 固有の論点は PyInstaller で exe を配布する記事にまとめています。

10. よくある質問(FAQ)

日本語が表示されません(文字が出ない・□になる)

フォントの登録漏れです。pdfmetrics.registerFont(UnicodeCIDFont("HeiseiKakuGo-W5")) を実行し、段落スタイルと TableStyle の両方に fontName を指定してください(片方だけだと表の中だけ日本語が出ません)。グラフ画像の中の文字が豆腐になるのは別問題で、matplotlib 側の設定です(品質管理グラフの記事)。

フォントを埋め込むとライセンス違反になりませんか

フォントごとに条件が異なるため、使うフォントのライセンス原文を必ず確認してください。Noto Sans JP は SIL Open Font License 1.1(単体販売は不可・再配布は可)です。Windows 同梱フォントを埋め込んで社外配布する場合の可否は当サイトでは判断できません。法務・知財の担当部門に確認するのが確実です。

Noto Sans JP を指定したのに文字が細くなります

Windows 11 同梱の NotoSansJP-VF.ttf は可変フォントで、検証機では埋め込まれた実体が NotoSansJP-Thin(最も細いマスター)になりました。帳票としては線が細くなるため、Google Fonts が配布している静的ウェイトの Regular を別途用意するのが安全です(6.2)。

表が 1 ページに収まりません

長い表は自動で次ページへ送られます。見出し行を各ページの頭に繰り返すなら Table(..., repeatRows=1) を付けてください(60 行の表は 2 ページにまたがり、2 ページ目の先頭にも見出しが出ました。参考図を含めた PDF 全体は 3 ページです)。逆に横方向は折り返されず、列幅の合計が本文幅を超えると警告なしに紙からはみ出します(8.2)。

100 件の生成が思ったより遅いです

検証機では所要時間の約 7 割がグラフ描画で、PDF 生成ではありませんでした。図が全ロット共通なら 1 枚だけ作って使い回すと 7.88 秒 → 2.26 秒になります。1 件だけ遅い場合は import が原因で、起動から 1 枚目までの 0.896 秒のうち 0.812 秒が import でした(7.1)。なお並列実行の効果は測っていません。

A4 横向きにできますか

できます。from reportlab.lib.pagesizes import A4, landscape として pagesize=landscape(A4) を渡すと、検証機では 297.0 × 210.0 mm になりました。横にしたら colWidths の検算と draw_frame() の座標計算もやり直してください(完成コードは w, h = A4 と縦向き前提です)。

11. 検証環境と、当サイトが測れなかったこと

右の列は「当サイトでは確認できていない」という意味で、間違いという意味ではありません。 結果が本記事と食い違ったら、それはあなたの環境での事実です。

実測した(検証機で実行)実測していない・扱っていない
最小帳票(2,752 bytes)・7 項目 2 ページの成績書(0.082 秒 / 21,078 bytes)・60 行版(表 2 ページ+参考図 1 ページ=計 3 ページ)での見出し行の繰り返し openpyxl / fpdf2 / WeasyPrint / LibreOffice headless の実動作。2 章の比較表は公式メタデータ(2026-09-16 取得)に基づく机上比較です
フォント 3 方式の登録可否・髙﨑①②③μ の描画・埋め込み有無とファイルサイズ PDF/A 準拠・電子署名(デジタル署名・タイムスタンプ)。本記事のコードはこれらの要件を満たしません
100 件の一括生成(CSV 7.88 秒 / SQLite 8.29 秒 / PNG 使い回し 2.26 秒)・コールドスタート(import 0.812 秒) 「N / 全 M ページ」の総ページ数表示(未実装・8.5)/1,000 件以上のバッチ・並列実行の効果/承認欄・押印欄を含む様式(4.1 の追加例は本記事の実測セットに含まず、サイズ・ページ数は測っていません)
セル折返し(175.0 pt はみ出し)・列幅超過で例外が出ないこと・A4 の実効幅・画像アスペクト比 matplotlib を含む exe・TTF の --add-data 同梱(9 章)/ReportLab PLUS の機能・価格
--onefile の exe 化(27,755,043 bytes)・CID の CMap が追加指定なしで同梱されること 各社 PDF ビューアでの表示差と実際の印刷。 判定は PyMuPDF / pypdf の解析と画面上の目視のみです

検証環境: 自宅の検証機(Windows 11 Pro)で確認しました。会社支給の端末は使っていません。 Python 3.12.10(venv)/reportlab 5.0.1 / Pillow 12.3.0 / matplotlib 3.11.2 / numpy 2.5.3 / pyinstaller 6.22.2、検証用に pymupdf 1.28.2 と pypdf 6.18.1。実測日は 2026 年 9 月 16 日、バージョンとライセンス情報も同日に PyPI から取得したものです(いずれ変わります)。

11.1 現場に持ち込む前に確認すること

本記事のコードは設備や PLC に触りませんが、検査成績書は品質記録であり、提出物です。運用に載せる前に次の 4 点を確認してください。

  • 様式の変更は品質保証部門の承認を得る — 記載項目を変えるのは技術の問題ではなく品質システムの問題です。文書区分(4 章)に指定があるなら、コードを書く前に確認します
  • 既存の帳票と値を突き合わせる — 同じロットで現行 Excel と Python の値が一致することを確認してから切り替えます。丸め(%.3f の桁)と単位がズレやすい箇所です
  • データ取得は読み出し専用に限る — 設備側のデータベースや共有フォルダから取るなら書き込み・更新をしない構成にし、情報システム部門・設備保全部門の承認を得てから接続します
  • 失敗を検知できる形にする — 無人バッチにするなら成功・失敗を追えるログを残します(ログ設計の記事)

本記事の手順・数値は当サイトの検証機での確認に基づくもので、生成された帳票の内容・妥当性・法令や規格への適合を保証するものではありません。

関連記事

参考文献・一次情報