1
1

【Chrome-PHP】LaravelでPDF出力する最適解【2024年夏最新版】

Last updated at Posted at 2024-08-31

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 として印刷してもらおうという。

フローとしてはこんな感じ。

drawio (14).png

インストール

事前に 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フォントの場合は、リンク切れの懸念があるので、必ずローカルにあるフォントを使用しよう。

views/template_invoice.blade.php
<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: ファイルとしてダウンロードする

image.png

inline: ブラウザで表示する

image.png

我ながらカンペキな出来。

Blade のポイント

PDF で画面確認しながら作るのしんどい

今回の仕組みはあくまでも Blade を使用している。
なので、途中の $blade を return するだけでブラウザで閲覧可能!
dev tools と一緒に開発しよう。

$blade = Blade::render('template_invoice', [
    'publishedAt' => $publishedAt,
    'filename' => $filename,
    'invoice' => $invoice,
]);

return $blade; // ここで処理終了

try {
...

image.png

渡したデータ配列から、値を取り出したい

data_get() を使用しよう。
[] を使ったインデックスアクセスも可能だが、値が無いときに例外となるので、こちらを推奨。
またdot記法のため、目的の値にアクセスしやすい。

$invoice = [
    'customer' => [
        'name' => '株式会社○○○○',
    ], 
];
<div>
    {{ data_get($invoice, 'customer.name' )}} 御中
</div>

image.png

\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>

image.png

数値のカンマ区切りがしたい

最近の Laravel には Number ヘルパーが増えました!

とはいっても、数字以外の null や文字列を渡すと例外を吐き出すので、三項演算子などで上手くやる必要がある。
マクロ関数を作成しても良いと思う。(というかマクロを推奨)

$invoice = [
    'total' => 264000,
];
<div>
    {{ data_get($invoice, 'total') ? Number::format(data_get($invoice, 'total')) : '' }}
</div>

画像を渡したい

上記サンプルには使用しなかったが、ちゃんと画像も渡せる。
ただし、assets() や URL 指定だと、処理中に通信が走ることになるため、直接ファイルを渡すことを推奨。
img タグは base64 を受け入れるので、こちらを利用する。

Storage に配置したファイルを表示する。

image.png

$blade = Blade::render('template_invoice', [
    'file' => "data:image/png;base64,".base64_encode(Storage::get('pet_darui_cat.png')),
]);
<img src="{{ $file }}" width="300">

image.png

これも何かしらのヘルパーを用意したほうが良いかも。
Model をそのまま利用するときは、変換メソッドを搭載すると綺麗に書けそう。

決まったファイルは /resources/img 配下に配置。(こちらは File ファサードを利用する)
動的なファイルは storage/app 配下に配置すると綺麗。

複数ページだったり、ページごとに違うテンプレートが使いたい

できるが。

PDF ファイルのマージは出来ないの?

できるが。

終わりに

今の時代、変にライブラリに頼るより、ブラウザを頼ったほうが強いのか...
富豪の時代...逆に言うと Chronium が全てに侵食してて、問題が発生した時がやばそう...
IE...

話は変わるが、令和最新版って表記、意外といつの情報が分かりやすくて良いのでは?

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1