Webシステムで書類発行したい!という要望って毎日のように聞くじゃないですか。
意外と悩むPDF発行...
はじめに
PDFって気軽に言われるが、中身バイナリだしめっちゃ昔の規格のせいで、ライブラリ無しで生成するのは困難。
なので HTML を利用して出力するのが一番簡単。
スクショを取ってPDFにするゴリ押し戦法荒業もあるが、文字検索できないし、容量でかくなるわで使い物にならないPDFとなる...
弊社では barryvdh/laravel-snappy 経由で wkhtmltopdf/wkhtmltopdf を使って出力していたのですが、アーカイブになってしまったので、令和最新版を探す旅に出ました...
Laravel にて PDF を出力するライブラリはいくつかある。
どれもCSSのレンダリングに難ありな印象...
今流行の Python なら何かあるか...
うーんわざわざ Python 動かすのもなぁ...
じゃぁいっそ Chrome で印刷すればよくね?というのが前置き
環境
- Ubuntu 23.04 (Windows11 WSL2)
- Laravel 11
Chrome PHP とは
このライブラリは PHP からヘッドレスモード(画面無しモード)で Chrome
を起動、操作できるすごいやつ。
自動操作で有名な Puppeteer とかと同ジャンル。
どう実現するかというと、Blade で作成した HTML を PDF として印刷してもらおうという。
フローとしてはこんな感じ。
インストール
事前に Chrome と使用するフォントをインストールしておく。
# chrome のインストール
$ sudo apt install -y chromium-browser
$ which chromium-browser
/usr/bin/chromium-browser
# フォントのインストール
$ sudo apt install -y fonts-ipafont
$ fc-cache -fv
$ fc-list | grep IPA
/usr/share/fonts/opentype/ipafont-mincho/ipam.ttf: IPAMincho,IPA明朝:style=Regular
/usr/share/fonts/opentype/ipafont-gothic/ipagp.ttf: IPAPGothic,IPA Pゴシック:style=Regular
/usr/share/fonts/opentype/ipafont-mincho/ipamp.ttf: IPAPMincho,IPA P明朝:style=Regular
/usr/share/fonts/opentype/ipafont-gothic/ipag.ttf: IPAGothic,IPAゴシック:style=Regular
/usr/share/fonts/truetype/fonts-japanese-mincho.ttf: IPAMincho,IPA明朝:style=Regular
/usr/share/fonts/truetype/fonts-japanese-gothic.ttf: IPAGothic,IPAゴシック:style=Regular
Composer を使って、Chrome-PHP をインストールする。
Laravel 用のライブラリではないので、必要であればFacadeは自分で作る。
(個人的にはダイレクト使用派)
$ composer require chrome-php/chrome
出力コード
サンプルとして請求書を出力してみる。
請求書のテンプレートは views/template_invoice.blade.php
に作成した。
ブラウザにて使用するわけではないので、CSSの書き方に凝る必要は無い。
メンテナンスを考慮すると、inline style で記述するほうが分かりやすいかもしれない。
Blade ファイル(長いので折り畳み)
今回は練習を兼ねて、初めて gird layout で記述した。なのでちょっと汚いHTML。
最新の CSS もちゃんと対応!
色はCSS変数に落とし込んだほうが綺麗かもしれない。
実は body
でフォントを指定するのがポイント。
未指定の場合、日本語が文字化けする。
また Webフォントの場合は、リンク切れの懸念があるので、必ずローカルにあるフォントを使用しよう。
<html>
<header>
<title>{{ $filename }}</title>
<style>
/* A4 縦 */
body {
margin: 0mm 5mm;
width: 210mm;
height: 287mm; /* 297mm だと改ページしてしまう */
font-family: "IPAPGothic";
}
.label {
background-color: rgba(75, 158, 215);
color: #FFFFFF;
font-weight: bold;
}
.outline {
border: 1px solid rgba(75, 158, 215);
}
.center {
text-align: center;
}
.right {
text-align: right;
}
.flex-col {
display: flex;
flex-direction: column;
gap: 1rem;
}
.logo-dummy {
background-color: #D3D3D3;
color: #696969;
width: 18rem;
height: 4rem;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.logo-stamp {
position: absolute;
bottom: -2.5rem;
right: 2rem;
color: red;
font-weight: bold;
font-size: 1.5rem;
line-height: 1.5rem;
padding: 1rem;
border: 4px solid red;
}
/* テーブル系 */
.detail-table {
width: 100%;
border-collapse: collapse;
}
.detail-table th {
padding: 0.25rem 0.5rem;
background: rgba(75, 158, 215);
border: solid 1px rgba(75, 158, 215);
border-bottom: solid 1px rgba(75, 158, 215);
color: #FFFFFF;
}
.detail-table th:not(:last-child) {
border-right: solid 1px #FFFFFF !important;
}
.detail-table td {
padding: 0.25rem 0.5rem;
border: solid 1px rgba(75, 158, 215);
}
/* grid コンテナ系 */
.container {
display: grid;
grid-template-columns: 2fr 1fr;
grid-template-areas:
"header header"
"title title"
"header-left header-right"
"detail detail"
"note detail-amount";
row-gap: 1rem;
column-gap: 2rem;
}
.subject-container {
display: grid;
grid-template-columns: 10rem 1fr;
gap: 2px;
}
.subject-container > * {
padding: 0.25rem 0.5rem;
}
.amount-container {
display: grid;
grid-template-columns: 10rem 8rem;
gap: 2px;
justify-content: end;
}
.amount-container > * {
padding: 0.25rem 0.5rem;
}
.note-container {
display: grid;
grid-template-columns: 1fr;
gap: 2px;
}
.note-container > * {
padding: 0.25rem 0.5rem;
}
</style>
</header>
<body>
<div class="container">
<div style="grid-area: header;">
<div class="right">
No. {{ data_get($invoice, 'no') }}
</div>
<div class="right">
{{ optional($publishedAt)->format('Y年n月j日') }}
</div>
</div>
<div style="grid-area: title;">
<h1 class="center" style="
text-align: center;
letter-spacing: 2rem;
margin-right: -2rem;
">請求書</h1>
</div>
<div style="grid-area: header-left;" class="flex-col">
<div
style="font-size: 1.5rem;"
>{{ data_get($invoice, 'customer.name' )}} 御中</div>
<div>下記のとおりご請求申し上げます。</div>
<div class="subject-container">
<div class="label">件名</div>
<div>{{ data_get($invoice, 'title') }}</div>
<div class="label" style="
display: flex;
align-items: center;
">御請求金額</div>
<div class="total" style="
background-color: rgba(75, 158, 215, 0.2);
font-weight: bold;
font-size: 1.5rem;
">\{{ data_get($invoice, 'total') ? Number::format(data_get($invoice, 'total')) : '' }}-</div>
<div class="label">お支払い期限</div>
<div>{{ optional(data_get($invoice, 'limit'))->format('Y年n月j日') }}</div>
<div class="label">御振込先</div>
<div>
{!! nl2br(e(data_get($invoice, 'own.bank'))) !!}
</div>
</div>
</div>
<div style="grid-area: header-right;" class="flex-col">
<div class="logo-dummy">
logo
<div class="logo-stamp">印</div>
</div>
<div>{{ data_get($invoice, 'own.name') }}</div>
<div>
<div>〒{{ data_get($invoice, 'own.postcode') }}</div>
<div>
{!! nl2br(e(data_get($invoice, 'own.address'))) !!}
</div>
</div>
<div>TEL:{{ data_get($invoice, 'own.tel') }}</div>
<div>登録番号:{{ data_get($invoice, 'own.invoice') }}</div>
</div>
<div style="grid-area: detail;">
<table class="detail-table">
<thead>
<tr>
<th>項番</th>
<th>品目</th>
<th>数量</th>
<th>単価</th>
<th>金額</th>
</tr>
</thead>
<tbody>
@foreach(data_get($invoice, 'details') as $detail)
<tr>
<td class="center">
{{ data_get($detail, 'id') ?? ' ' }}
</td>
<td>
{{ data_get($detail, 'title') }}
</td>
<td class="center">
{{ data_get($detail, 'qty') }} {{ data_get($detail, 'unit') }}
</td>
<td class="right">
{{ data_get($detail, 'ppu') ? Number::format(data_get($detail, 'ppu')) : '' }}
</td>
<td class="right">
{{ data_get($detail, 'price') ? Number::format(data_get($detail, 'price')) : '' }}
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
<div style="grid-area: detail-amount;">
<div class="amount-container">
<div class="label">小計</div>
<div class="outline right">
{{ data_get($invoice, 'sum') ? Number::format(data_get($invoice, 'sum')) : '' }}
</div>
<div class="label">消費税 (10%)</div>
<div class="outline right">
{{ data_get($invoice, 'tax') ? Number::format(data_get($invoice, 'tax')) : '' }}
</div>
<div class="label">合計</div>
<div class="outline right">
{{ data_get($invoice, 'total') ? Number::format(data_get($invoice, 'total')) : '' }}
</div>
</div>
</div>
<div style="grid-area: note;">
<div class="note-container">
<div class="label" style="width: 5rem;">備考</div>
<div>
{!! nl2br(e(data_get($invoice, 'note'))) !!}
</div>
</div>
</div>
</div>
</body>
</html>
出力の方法はこちら。
本来は Controller に書くべきだが、テストのため web.php
に記載。
use HeadlessChromium\BrowserFactory;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Str;
Route::get('/invoice', function () {
$invoice = [
'no' => '123456',
'title' => 'ホームページ作成',
'sum' => 240000,
'tax' => 24000,
'total' => 264000,
'limit' => Carbon::make('2024-09-20'),
'details' => collect([
['id' => 1, 'title' => 'ページ制作', 'qty' => 10, 'unit' => 'ページ', 'ppu' => 10000, 'price' => 100000],
['id' => 2, 'title' => 'サーバー更新費用', 'qty' => 1, 'unit' => '式', 'ppu' => 20000, 'price' => 20000],
['id' => 3, 'title' => '保守費用(1年間)', 'qty' => 12, 'unit' => 'ヵ月', 'ppu' => 10000, 'price' => 120000],
])->pad(14, []),
'note' => "お打ち合わせ時に、一度リテイクが可能です。\n".
"振込手数料は貴社負担にてお願いいたします。\n".
"※この請求書はダミーです。",
'customer' => [
'name' => '株式会社○○○○',
],
'own' => [
'name' => '△△△△株式会社',
'postcode' => '000-0000',
'address' => "東京都××市△町1-23\n45ビル 67F",
'tel' => '000-000-0000',
'invoice' => 'T0000000000000',
'bank' => "○○銀行 △△支店\n普通 0000000\nカ)○○○○",
],
];
// Blade -> HTML 変換
$publishedAt = Carbon::now();
$filename = $publishedAt->format('Ymd').'_請求書_'.data_get($invoice, 'customer.name').'御中_'.data_get($invoice, 'title').'.pdf';
$blade = Blade::render('template_invoice', [
'publishedAt' => $publishedAt,
'filename' => $filename,
'invoice' => $invoice,
]);
try {
// chrome 立ち上げ
$browserFactory = new BrowserFactory('/usr/bin/chromium-browser');
$browser = $browserFactory->createBrowser();
// ページ生成
$page = $browser->createPage();
$page->setHtml($blade);
// PDF の一時ファイルを生成
$tempFile = Str::uuid().".pdf";
$tempPath = storage_path($tempFile);
$page->pdf([
'printBackground' => true,
])->saveToFile($tempPath);
// ブラウザへ返却 (inline or attachment)
return response()
->download($tempPath, $filename, ['Content-Type' => 'application/pdf'], 'attachment')
->deleteFileAfterSend();
} finally {
// chrome を閉じる
$browser->close();
}
});
たったこれだけで出力が可能!
=> http://localhost:8000/invoice
$page->pdf()
のオプションは ここ を参照のこと。
一時ファイルは、ダウンロードの成功とともに削除される。
エラーが起きたファイルは残るので、後日ログとともに検証を...。
また download()
の第三引数を変えることで、PDFの取り扱いを変えることができる。
attachment
: ファイルとしてダウンロードする
inline
: ブラウザで表示する
我ながらカンペキな出来。
Blade のポイント
PDF で画面確認しながら作るのしんどい
今回の仕組みはあくまでも Blade
を使用している。
なので、途中の $blade
を return するだけでブラウザで閲覧可能!
dev tools
と一緒に開発しよう。
$blade = Blade::render('template_invoice', [
'publishedAt' => $publishedAt,
'filename' => $filename,
'invoice' => $invoice,
]);
return $blade; // ここで処理終了
try {
...
渡したデータ配列から、値を取り出したい
data_get()
を使用しよう。
[]
を使ったインデックスアクセスも可能だが、値が無いときに例外となるので、こちらを推奨。
またdot記法のため、目的の値にアクセスしやすい。
$invoice = [
'customer' => [
'name' => '株式会社○○○○',
],
];
<div>
{{ data_get($invoice, 'customer.name' )}} 御中
</div>
\n
で改行したい
文章中の改行コードで改行したいときは、{!! !!}
と nl2br()
関数を使用する。
(nl2br => new line to br)
その時、必ず e()
関数を経由する。
e()
は HTMLのエスケープ を行うもので、XSSなどの攻撃を防ぐことができる。
※ ちなみに、PHPにて \n
を使用するときは、 ""
で囲う必要がある。
※ また PHP_EOL
の定数に環境ごとの改行コードが入っている。
$invoice = [
'note' => "お打ち合わせ時に、一度リテイクが可能です。\n".
"振込手数料は貴社負担にてお願いいたします。\n".
"※この請求書はダミーです。",
];
<div>
{!! nl2br(e(data_get($invoice, 'note'))) !!}
</div>
数値のカンマ区切りがしたい
最近の Laravel には Number ヘルパーが増えました!
とはいっても、数字以外の null や文字列を渡すと例外を吐き出すので、三項演算子などで上手くやる必要がある。
マクロ関数を作成しても良いと思う。(というかマクロを推奨)
$invoice = [
'total' => 264000,
];
<div>
{{ data_get($invoice, 'total') ? Number::format(data_get($invoice, 'total')) : '' }}
</div>
画像を渡したい
上記サンプルには使用しなかったが、ちゃんと画像も渡せる。
ただし、assets()
や URL 指定だと、処理中に通信が走ることになるため、直接ファイルを渡すことを推奨。
img
タグは base64
を受け入れるので、こちらを利用する。
Storage
に配置したファイルを表示する。
$blade = Blade::render('template_invoice', [
'file' => "data:image/png;base64,".base64_encode(Storage::get('pet_darui_cat.png')),
]);
<img src="{{ $file }}" width="300">
これも何かしらのヘルパーを用意したほうが良いかも。
Model
をそのまま利用するときは、変換メソッドを搭載すると綺麗に書けそう。
決まったファイルは /resources/img
配下に配置。(こちらは File ファサードを利用する)
動的なファイルは storage/app
配下に配置すると綺麗。
複数ページだったり、ページごとに違うテンプレートが使いたい
できるが。
PDF ファイルのマージは出来ないの?
できるが。
終わりに
今の時代、変にライブラリに頼るより、ブラウザを頼ったほうが強いのか...
富豪の時代...逆に言うと Chronium が全てに侵食してて、問題が発生した時がやばそう...
IE...
話は変わるが、令和最新版って表記、意外といつの情報が分かりやすくて良いのでは?