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


Webシステムで書類発行したい！という要望って毎日のように聞くじゃないですか。
意外と悩むPDF発行...  

## はじめに

PDFって気軽に言われるが、中身バイナリだしめっちゃ昔の規格のせいで、ライブラリ無しで生成するのは困難。
なので HTML を利用して出力するのが一番簡単。
スクショを取ってPDFにするゴリ押し戦法荒業もあるが、文字検索できないし、容量でかくなるわで使い物にならないPDFとなる...

弊社では [barryvdh/laravel\-snappy](https://github.com/barryvdh/laravel-snappy) 経由で [wkhtmltopdf/wkhtmltopdf](https://github.com/wkhtmltopdf/wkhtmltopdf) を使って出力していたのですが、アーカイブになってしまったので、令和最新版を探す旅に出ました...

Laravel にて PDF を出力するライブラリはいくつかある。

- [barryvdh/laravel\-snappy](https://github.com/barryvdh/laravel-snappy)
- [elibyy/tcpdf\-laravel](https://github.com/elibyy/tcpdf-laravel)
- [mccarlosen/laravel\-mpdf](https://github.com/mccarlosen/laravel-mpdf)
- [barryvdh/laravel\-dompdf](https://github.com/barryvdh/laravel-dompdf)

どれもCSSのレンダリングに難ありな印象...

今流行の Python なら何かあるか...

- [Kozea/WeasyPrint](https://github.com/Kozea/WeasyPrint)
- [xhtml2pdf/xhtml2pdf](https://github.com/xhtml2pdf/xhtml2pdf)

うーんわざわざ Python 動かすのもなぁ...
じゃぁいっそ Chrome で印刷すればよくね？というのが前置き  



## 環境

- Ubuntu 23.04 (Windows11 WSL2)
- Laravel 11


## Chrome PHP とは

https://github.com/chrome-php/chrome

このライブラリは PHP からヘッドレスモード（画面無しモード）で `Chrome` を起動、操作できるすごいやつ。
自動操作で有名な [Puppeteer](https://pptr.dev/) とかと同ジャンル。

どう実現するかというと、Blade で作成した HTML を PDF として印刷してもらおうという。

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

![drawio (14).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3833190/589278ff-cc0c-7a06-8b6f-382918cde94a.png)


## インストール

事前に Chrome と使用するフォントをインストールしておく。

```bash
# 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は自分で作る。
（個人的にはダイレクト使用派）

```bash
$ composer require chrome-php/chrome
```



## 出力コード

サンプルとして請求書を出力してみる。
請求書のテンプレートは `views/template_invoice.blade.php` に作成した。

ブラウザにて使用するわけではないので、CSSの書き方に凝る必要は無い。
メンテナンスを考慮すると、inline style で記述するほうが分かりやすいかもしれない。

<details><summary>Blade ファイル（長いので折り畳み）</summary>

今回は練習を兼ねて、初めて gird layout で記述した。なのでちょっと汚いHTML。
最新の CSS もちゃんと対応！

色はCSS変数に落とし込んだほうが綺麗かもしれない。

実は `body` でフォントを指定するのがポイント。
未指定の場合、日本語が文字化けする。
また Webフォントの場合は、リンク切れの懸念があるので、必ずローカルにあるフォントを使用しよう。

```html: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>
```

</details>


出力の方法はこちら。
本来は Controller に書くべきだが、テストのため `web.php` に記載。

```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()` のオプションは [ここ](https://github.com/chrome-php/chrome?tab=readme-ov-file#print-as-pdf) を参照のこと。

一時ファイルは、ダウンロードの成功とともに削除される。
エラーが起きたファイルは残るので、後日ログとともに検証を...。

また `download()` の第三引数を変えることで、PDFの取り扱いを変えることができる。

### `attachment`: ファイルとしてダウンロードする

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3833190/9362b6c9-402f-1e5d-c6d3-08a2d134afc7.png)


### `inline`: ブラウザで表示する

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3833190/43888bd8-f709-6238-9790-2cb780b30efa.png)

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



## Blade のポイント

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

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

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

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

try {
...
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3833190/b043382c-47f6-1ad0-196b-5bbc3ac15936.png)


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

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

```php
$invoice = [
    'customer' => [
        'name' => '株式会社○○○○',
    ], 
];
```

```html
<div>
    {{ data_get($invoice, 'customer.name' )}} 御中
</div>
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3833190/e8f0e394-d781-6e48-c590-687913424e9c.png)


### `\n` で改行したい

文章中の改行コードで改行したいときは、`{!! !!}` と `nl2br()` 関数を使用する。
（nl2br => new line to br）
その時、必ず `e()` 関数を経由する。
`e()` は HTMLのエスケープ を行うもので、XSSなどの攻撃を防ぐことができる。

※ ちなみに、PHPにて `\n` を使用するときは、 `""` で囲う必要がある。
※ また `PHP_EOL` の定数に環境ごとの改行コードが入っている。

```php
$invoice = [
    'note' => "お打ち合わせ時に、一度リテイクが可能です。\n".
        "振込手数料は貴社負担にてお願いいたします。\n".
        "※この請求書はダミーです。",
];
```

```html
<div>
    {!! nl2br(e(data_get($invoice, 'note'))) !!}
</div>
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3833190/81611ef8-bcfc-30c2-21ef-7729b71578c3.png)


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

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

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

```php
$invoice = [
    'total' => 264000,
];
```

```html
<div>
    {{ data_get($invoice, 'total') ? Number::format(data_get($invoice, 'total')) : '' }}
</div>
```


### 画像を渡したい

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

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3833190/94e56efa-5496-0d3d-42b6-3c4cc9c7913b.png)

```php
$blade = Blade::render('template_invoice', [
    'file' => "data:image/png;base64,".base64_encode(Storage::get('pet_darui_cat.png')),
]);
```

```html
<img src="{{ $file }}" width="300">
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3833190/b03754b6-5e94-368c-b259-bf394e8cc027.png)  

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

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


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

できるが。

- [break\-after \- CSS: カスケーディングスタイルシート \| MDN](https://developer.mozilla.org/ja/docs/Web/CSS/break-after)
- [11\.x Bladeテンプレート Laravel ▷ サブビューの読み込み](https://readouble.com/laravel/11.x/ja/blade.html#including-subviews)
- 
### PDF ファイルのマージは出来ないの？

できるが。

- [Webklex/laravel\-pdfmerger](https://github.com/Webklex/laravel-pdfmerger)
- [karriereat/pdf\-merge](https://github.com/karriereat/pdf-merge)
- [Setasign/FPDI](https://github.com/Setasign/FPDI)




## 終わりに

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

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