開発・技術選定

紙の帳票を HTML/CSS で再現して PDF にする — 実務で崩れないための書き方

業務システムでは「今使っている紙の帳票と同じものを出したい」という要件が頻繁に出ます。
HTML/CSS から PDF を生成する方式で対応していますが、画面用の CSS とは書き方が違います

ページの物理サイズを指定する

@page {
    size: A4 portrait;
    margin: 12mm 10mm;
}

帳票では px ではなく mm を使います。
印刷したときに実寸で一致させる必要があるためです。

.logo { width: 32mm; }
.signature-box { height: 18mm; }

改ページを制御する

長い明細で最も問題になるのが、表の途中や見出し直後での改ページです。

table { page-break-inside: auto; }
tr    { page-break-inside: avoid; }   /* 行の途中で切らない */
thead { display: table-header-group; } /* 各ページに見出しを繰り返す */
tfoot { display: table-footer-group; }

h2 { page-break-after: avoid; }        /* 見出しだけ残さない */
.new-page { page-break-before: always; }

display: table-header-group を入れると、2ページ目以降にも表の見出しが出ます
これが無いと、続きのページが何の表か分からなくなります。

ページ番号

@page {
    @bottom-right {
        content: counter(page) " / " counter(pages);
        font-size: 9pt;
    }
}

顧客区分で様式を切り替える

個人向けと法人向けで敬称や記載項目が変わることがよくあります。
テンプレート側で分岐させます。

<div class="addressee">
  {{ customer.name }}
  {% if customer.is_corporate %}御中{% else %}様{% endif %}
</div>

崩れを機械的に検知する

見た目の確認を目視だけに頼ると、必ず見落とします。
生成したPDFからテキストを抽出して検証しています。

from pdfminer.high_level import extract_text

text = extract_text(pdf_path)
assert "請求書" in text
assert f"{total:,}" in text          # 合計金額が入っているか
assert text.count("小計") == 1        # 重複していないか

CI に入れておくと、テンプレートを直したときの崩れに気づけます。

よくある落とし穴

フォントが埋め込まれない

サーバーに日本語フォントが入っていないと、文字が欠けます。

apt install fonts-noto-cjk
body { font-family: "Noto Sans CJK JP", sans-serif; }

外部リソースの読み込み

画像やCSSを外部URLで参照すると、生成が遅くなるか失敗します。
ローカルパスか埋め込みにします。

色の指定

背景色は印刷時に出ないことがあります。罫線で表現するほうが確実です。

まとめ

  • 単位は mm。@page で用紙とマージンを指定
  • theadtable-header-group にして見出しを繰り返す
  • 行の途中で切らない、見出しだけ残さない
  • テキスト抽出でレイアウト崩れを機械的に検証する
  • 日本語フォントの導入と、リソースのローカル化を忘れない