Laravel環境下でPDFを出力する際の注意点(DomPdf)



[Laravel Advent Calendar 2022](https://qiita.com/advent-calendar/2022/laravel) 4日目の投稿です。

## 目的

Laravelでウェブサービスを構築時にPDFを出力する場合の考慮すべき点、注意点について書きます。

## 動作環境

- Docker
- PHP-8.1.33
- laravel-9.19
- laravel-dompdf-2.0
- lara-pdf-merger-2.0

なお説明のため、プロジェクト名を `sample-pdf-gen` としておきます。

## PHPでPDFを出力するライブラリの候補

PHP言語でPDFを出力したいというニーズは昔からあるため、新旧様々なライブラリが存在します。主にこれらが有名所でしょう。

- tcpdf
  - 大昔からある老舗のPDF出力ライブラリ。内部のAPIを叩いて少しずつPDFを生成していく
- fpdi
  - 既存のPDFをテンプレートとして他のテキストや図形を合成できるライブラリ。書き込むAPIはtcpdfと同じ。
- mpdf
  - HTMLからPDFに変換するライブラリ
- dompdf
  - HTMLからPDFに変換するライブラリ。laravel-dompdf越しに使うと簡単に運用できる。

tcpdfを少し触りましたが、これで見栄えのする文書を生成するのは不可能と判断し、早々に諦めました。CrystalReport等のGUIのデザイナーがあればもっと選択肢が増えると思うのですが、PHPの世界にそんな便利ツールはないので、次善の解決策としては最初にHTMLの原稿を作り、それをPDFに変換するのが無難ということになります。

LaravelではBlade等で簡単にHTMLを生成する機能を内蔵しているわけで、いわばHTML自体をPDFデザイナーとして活用するということです。

mpdfとdompdf、どちらも packagist.org では同じようなfavorite数、DL数でどちらを採用しても良さそうだったのですが、試しにlaravel-dompdfを入れてみたら楽にセットアップできたのでこのまま行ってしまうことにしました。

## DomPdfの特徴

- 単独でPDFを生成する機能は持たない。あくまでもHTMLを入力値としてPDFを描画(render)するライブラリ
- ページ制御可能
- フォント指定可能
- laravel-dompdfを通して呼び出す場合
  - PDFをページの出力として返却することもできるし、サーバー上にファイルとして保存することもできる
  - `Pdf::loadView($view, $compact)` という形で呼び出すことで、Laravel標準の`view()`関数と同じ構文でHTMLのレンダリング結果をそのままDomPdfに渡した状態でライブラリのインスタンスを作ってくれる

## DomPdfの欠点

- 不完全なCSS対応
  - 基本的に一旦HTMLに出力してブラウザで内容を確認してからDomPdfに変換させることになりますが、出力内容の差に最初は驚くことになると思います。対応内容がかなり脱落しているのでDomPdfが理解できるようなシンプルなCSS指定の組み合わせでページをデザインすることが必要です。
  - したがってBootstrap等の大規模なCSSライブラリやJavaScript、SVGなどを含まない簡素なHTMLで記述する必要があります。
  - 基本的にCSSのフォント指定、画像指定以外は外部ファイルの参照を行わない形にします。
- 使い勝手の悪いfont指定
  - HTML5におけるfont指定とDomPdfが要求するfont指定のやり方がかなり異なるため、HTMLプレビュー用とPDF出力用とでCSS定義部分を差し替えるしかありません
- 2回以上HTMLを渡してページ数を増やしたPDFを生成することが不可能
  - ※ソースの二度漬け禁止
  - 意外なことですが、HTMLを２回与えて描画すると失敗します。複数のHTMLテンプレートを使って１個のPDFドキュメントを生成する場合、まずそれぞれのテンプレートで断片的なPDFをそれぞれ作成し、ファイルとして保存した上で別のライブラリで１個のPDFファイルに合成する必要があります。
- 不可解というかメチャクチャな自動改行
  - DomPDFで日本語を自動改行させたときの不可思議な挙動は、最初見たときはびっくりすると思います。しかも自動改行を制御するCSSのサポートが極めて不十分なためまともに改行させるだけでも苦労するでしょう
 
## DomPdfにおけるCSSの例(フォント指定)

```html
<style>
  @page { 
    margin: 0px; /* 余白の制御はbodyタグ側で行いたいのでページ単位のマージンをキャンセルする */
  }
@if ($pdf_preview)
  @font-face{
    font-family: ipag;
    font-style: normal;
    font-weight: normal;
    /* ブラウザでのプレビュー時、 `sample-pdf-gen/public/css/*`
     * 以下を指定することになるだろう 
     */
    src:url(/css/fonts/ipag.ttf) format('truetype');
  }
  @font-face{
    font-family: ipam;
    font-style: normal;
    font-weight: normal;
    src:url(/css/fonts/ipam.ttf) format('truetype');
  }
@else
  @font-face{
    font-family: ipag;
    font-style: normal;
    font-weight: normal;
    /* だがDomPdfから開けるのは `sample-pdf-gen/storage/app/*` 以下だ。
     * 面倒だが同じフォントファイルを２箇所のディレクトリに設置して使い分けるしかない
     */
    src:url('{{ storage_path('fonts/ipag.ttf')}}') format('truetype');
  }
  @font-face{
    font-family: ipam;
    font-style: normal;
    font-weight: normal;
    src:url('{{ storage_path('fonts/ipam.ttf')}}') format('truetype');
  }
@endif
</style>
```

## DomPdfにおけるCSSの例(主だったスタイル指定)

```html
<style>
  /* A4の紙を縦方向(portrait)で自然な感じで余白を付けるのはだいたいこんな感じか。
   * page-break-inside：avoid とはページの切り出しをブロック単位で行うという指定。
   * プレーンテキストだけで複数ページ埋まるような構成でないならばいい感じでページ割付をしてくれる。
   */
  body {
    font-family: ipag;
    padding-left: 5em;
    padding-right: 5em;
    padding-top: 3em;
    padding-bottom: 3em;
    page-break-inside: avoid;
  }
  /* 記事本文用のスタイル。ここに日本語の長文が入る想定。DomPdfはそのままでは日本語の自然文の
   * 自動改行がかなりおかしい処理結果となるので、可能な限り自然になるよう試行錯誤した結果、
   * このような設定に落ち着いた。まだ自動改行の右端が一致していない不具合が残っているが、
   * これはもうDomPdfの実装のまずさに起因しているためやむを得ないところか。
   */
  article section {
    font-family: ipamp;
    font-size: 11pt;
    text-align: justify;
    line-break: strict;
    word-wrap: break-word;
    padding-left: 6em;
    padding-right: 1em;
    line-height: 1.7em;
    margin-top: 0em;
    margin-bottom: 0em;
  }
</style>
```

## 自動改行を改善するための補正

上でも述べたがDomPdfの自動改行の実装は日本語にとってはかなり不自然な結果となるため、そのままではまともに運用できません。したがってこの補正を入れることを推奨します。

`sample-pdf-gen/config/dompdf.php` の　３行目に以下を挿入

```php
<? php

+ // Dompdfの空白文字強制改行をキャンセルする
+ Dompdf\FrameReflower\Text::$_wordbreak_pattern = '//u';

return array(
```
ちなみにデフォルトでは半角スペース含む空白文字で改行しようとしてきます。日本語は分かち書きをする言語ではないため、全く改行されないといった挙動に陥ります。それを防ぐには「あらゆる文字で改行を許可」するルールを指定します。

本当は「、。」などの禁則文字は改行対象から除外してほしいですが、「改行できる」文字を入れるルールのため、書いてみたもののうまく行かなかったのでやめました。

## ページ番号(ノンブル)を入れる方法1

エレガントな解決方法としては<footer>タグとDomPdf専用のCSS指定で済ませるもので、
印字位置や文字のフォント制御もHTMLの文法で書けるので非常にシンプルに解決できます。

例えばこのように書きます。

```html
<body>
 <style>
  footer {
   /* Place the footer at the bottom of each page */
   position: fixed;
   right: 3em;
   bottom: 3em;

   /* Any other appropriate styling */
   font-family: ipag;
   font-weight: normal;
   font-size: 18pt;
  }

  /* Show current page number via CSS counter feature */
  .page-number:before {
     content: counter(page);
  }
 </style>
 <footer>
  p. <span class="page-number"></span>
 </footer>
 ...
</body>
```

これで、各ページに自動的に「p.XX」のページ番号が各ページ右下に印字されます。
ですがこの方式には全ページに1ページからページ番号を挿入するか、全くページ番号を入れないかの２つの動作しかとれず、途中の数ページにだけページ番号を入れるだとか、２以上のページ番号から開始するだとかの柔軟性が全くありません。

## ページ番号(ノンブル)を入れる方法2

HTML側で解決できないならばPHP側で解決するしかありません。
基本的にはCanvas::page_script()を実行することになりますが、普通に実行するとページ出力がおかしくなって頭がクエッションマークで埋まることになるでしょう。

実はマニュアルには何も書かれていませんが、先に`render()`を実行してPDFのページの出力を完了させてから、各ページに挿入するという形でページ番号（ノンブル）を入れるという動作になります。したがって通常は省略されている `render()`を明示的に実行してから `page_script()`を実行することになります。例えばこういう実装になるでしょう。

```php
$view;// PDF出力用のHTMLテンプレートのpath
$compact;// 通常　view()関数に与えるview変数の連想配列
$this;// このコードを実装しているclassのthis。ControllerでもServiceでもご自由に
$pffilename;// PDFの出力にファイル保存は必須ではないが、DBに格納したりPDFの連結などをするためになにかとファイル化する事が必要になる
$service->last_page;// 印刷した最後のページ。実はphpのuse構文で与えている変数は単なる参照であるため、複数回callbackが呼び出される場合、プロパティ等として変数運用しないと消えてしまう。この場合、PDF出力後に開始ページ、終了ページを保存したいためこのような形の実装とした
$prev_last_page;// 前回印刷されたときのPDFの最終ページ。つまり直前のページ。

$pdf = Pdf::loadView($view, $compact);
$pdf->setPaper('A4', 'portrait');

// 前の章のページ番号の続きでページ番号を記入する
$service = $this;
$service->last_page = $prev_last_page = $this->getLastPageNumber();
if ($pageCount) {
    $pdf->render();
    $pdf->get_canvas()->page_script(function ($pageNumber, $pageCount, $canvas) use ($service) {
        $service->last_page++;
        $canvas->text(520, 780, "p. {$service->last_page}", null, 20, array(0,0,0));
    });
}
// 1ページしかない場合page_scriptのcallbackは呼び出されない
if ($service->last_page == $prev_last_page) {
    $service->last_page++;
}
$pdf->save(Storage::path("public/$pdffilename"));
```

## まとめ

PDF出力はPHPの世界ではマイナーな話題のためドキュメントも少なく、DomPdf自体のソースコードをかなり追いかけて挙動を調べる必要がありました。

この記事がみなさんの開発の参考になれば幸いです。

なにか感想や質問等があれば気軽にコメントをください。