🐘

ついに、Webアプリでの帳票印刷のベストプラクティスを編み出しました

2021/06/05に公開

2024/05/07 追記

最新の登壇スライドバージョンはこちらです。

登壇時の様子がYouTubeに上がっているのでよろしければあわせてご覧ください。

https://www.youtube.com/watch?v=tIxd8C5IDLQ

はじめに

  • 言い切りタイトルすみません
  • 僕を含む一定数の人にとって現時点でのベストプラクティスとなりうる手法という意味で紹介しています
  • 極めてシビアな帳票出力の世界にいる人から見ると使い物にならない内容かもしれないと思います
  • 帳票印刷の世界では SVF というサービスが有名らしいです。が、こういった外部サービスは使わずに自力で実装するというのがこの記事の前提です
  • 動的に明細行の数が増減する連票はこの記事の解説では考慮していませんが、追加で実装するのはそれほど難しくないということは読んでいただければ分かるかなと思います

結論から

https://twitter.com/ttskch/status/1397926291127508993

僕が考える現時点でのWebアプリでの帳票印刷のベストプラクティスは、

  • Adobe XDFigma で帳票のレイアウトをデザインして
  • それをSVG形式でエクスポートしたものをテンプレートとしてアプリで読み込み
  • プレースホルダーに当たる文字列を置換した上でSVGをそのままHTMLに埋め込んで出力し
  • SVGの外側のレイアウト(プレビュー画面の見え方、印刷時のページ設定)だけCSSで整え
  • 文字の自動縮小・自動折り返し等を別途実装しておき
  • PDF出力やプリンタでの印刷はブラウザの印刷機能を使ってもらう(ブラウザで見えているままが印刷される)

です。

色々試しましたが、

  • ピクセル単位で細かく帳票をデザインできる(しかも簡単に)
  • 帳票デザインの保守性が高い(修正が容易)
  • 印刷時に見た目が一切崩れない

という条件を満たせる方法は今のところこれしかないという結論です。

この方法を使うと、例えばこんな感じの帳票も簡単かつ保守性高く作れます👍👍👍

デモ環境

下記に実際にアプリを動かせるデモ環境を用意しました。ぜひ触ってみてください。(Herokuの無料プランなので初回起動重いです)

https://svg-paper-example.herokuapp.com/

また、このデモのソースコードは以下のリポジトリで公開していますので、あわせてご参照ください。
デモはPHP(Laravel)で作ってありますが、知見そのものは他の言語・フレームワークでもそのまま流用できるかと思います。

https://github.com/ttskch/svg-paper-example

既存の方法の欠点

さて、実装方法について説明する前に、既存の方法のどこがダメだったのというのを簡単に話しておきたいと思います。

僕の観測している範囲だと、Webアプリでの帳票出力の実装には以下の2つの方法が採用されていることが多そうかなと思っています。

  • (1) 完全にHTMLで作って、ブラウザの印刷機能で印刷
  • (2) ExcelやWordのテンプレートを元に一旦ExcelやWordで帳票を出力し、それをLibreOfficeのヘッドレスモードなどを使ってPDFに変換

(1) 完全にHTMLで作って、ブラウザの印刷機能で印刷

はじめはこの方法ですんなり行けると考えていました。

下記のような偉大なる先人の知恵があったので、慣れ親しんだHTML/CSSで帳票をデザインするだけだと。

そろそろ真面目に、HTMLで帳票を描く話をしようか - Qiita

【帳票CSS】A4印刷用のHTMLを作ろう(Chrome用) | deep-space.blue

しかし実際にやってみると、帳票の細かなデザインをHTML/CSSで再現するのがひたすらに面倒臭く、お客さんの要望を細かいところまで再現していった結果、非常に難読なHTML/CSSが出来上がりました😓

考えてみれば、帳票のデザインって多くの場合A4一枚にピッタリ収まることが大前提になっていて、Webにおけるページレイアウトのセオリーとはかけ離れているので、保守性を維持しながらこれを作るのは相当難しいです。

例えばテーブル(表)1つとっても、普段それほど使わない rowspan colspan を大量に使ってめちゃくちゃ複雑なレイアウトのテーブルを組み立てることとかを普通に要求されます。作るだけならまだしも、その後仕様変更でこのテーブルの中にセルを追加(しかも全体がちゃんとA4に収まるように)しないといけなくなったときのことを考えると、遠い目にならざるを得ません。

特に僕のように普段BootstrapなどのCSSフレームワークのレールに乗っかったHTMLしか書いていない人間にはとにかく苦行でしかありませんでした。(普段から複雑なHTMLを書いているデザイナーさんとかにとっては別にしんどくないのかもしれません)

(2) ExcelやWordのテンプレートを元に一旦ExcelやWordで帳票を出力し、それをLibreOfficeのヘッドレスモードなどを使ってPDFに変換

HTML/CSSのメンテが大変すぎるということが分かったので、思い切ってExcelファイルをテンプレートにする方法を試してみました。

帳票を視覚的にデザインできますし、Excelなら(Windows版ならWordも)「縮小して全体を表示」というお馴染みの機能があるのでフォントの縮小についても何も考えなくてよさそうです。

調べてみると、LibreOffice のヘッドレスモードを使えばCLIでExcelファイルのPDFへの変換ができるというこを知り、これなら行けるのではと思いました。

LibreOfficeでドキュメントコンバータを作ろう - Qiita

ところがこの方法にも色々と難があり、特に

  • Excelで帳票の細かいデザインをしようとすると、行の高さ・列の幅を極端に小さくした 地獄のExcel方眼紙 にならざるを得ない
  • ExcelをPDFに変換する際に多少 見た目が崩れる
  • 同じLibreOfficeでも Mac版とLinux版でPDFの出力結果が微妙に異なる

の3点が致命的でした。

Excel方眼紙は、セルの大きさがフォント1文字分ぐらいならまだギリ許せる(?)のですが、ピクセル単位に近い微妙なデザインを実現しようと思うと地獄のようにセルを小さくする必要が出てきて心が折れます。

LibreOfficeによるPDFへの変換が完璧でない点も、多くの案件において許容不可能でしょう。

ベストプラクティスの具体的なやり方

というわけでたどり着いたのが、冒頭でご紹介した方法です。

上記2つの方法で満たせなかった

  • ピクセル単位で細かく帳票をデザインできる(しかも簡単に)
  • 帳票デザインの保守性が高い(修正が容易)
  • 印刷時に見た目が一切崩れない

という要求を 完璧に満たしてくれる のがこのSVGを使った方法です👍

以下、順を追って具体的なやり方を解説していきます。

1. Adobe XDやFigmaを使って帳票をデザインし、SVG形式でエクスポートする

まず、Adobe XDFigma といったUI/UXデザインツールを使って帳票をデザインし、それをSVG形式でエクスポートします。

Adobe XDでのSVGエクスポートの手順

ファイル > 書き出し > すべてのアートボード でファイル保存のダイアログが出ます。

ここで フォーマットSVG にして保存すればOKです。

帳票内で画像を使う場合は、下図のように 画像を保存 の設定を 埋め込み にする必要があります。

埋め込み にすると画像はbase64エンコードされてデータURLとして埋め込まれます。

FigmaでのSVGエクスポートの手順

フレーム単位で選択して、右カラム最下部の Export メニューでエクスポートします。

この際、

  • Include "id" Attribute にチェックを入れる
  • Outline Textチェックを外す

の2点を忘れないようにしてください。

後述するJSによる調整の段階で id 属性を使いたいのと、そもそもテキストの置換を行うために文字列を <path> タグではなく <text> タグで出力してほしいのでこの設定が必要です。

デモアプリのソースコードの対応するコミットは こちら

2. HTMLにSVGを埋め込み、CSSで印刷に最適化して出力する

SVG形式のテキストファイルができたので、まずはこのテキストをそのままHTMLに埋め込んで画面に出力します。

その際、

  • 印刷時にA4縦ぴったりで出力されるように
  • 画面表示時に印刷プレビューっぽい見た目で表示されるように

の2点を実現するために多少のCSSを書く必要があります。

具体的には以下のような内容でOKです。(これはSCSSで書いてあります)

@page {
    size: A4 portrait;
    margin: 0; // ヘッダー・フッターが出力されないように
}

* {
    margin: 0;
    padding: 0;
    user-select: none;
}

body {
    width: 210mm;
    color-adjust: exact;
    > svg {
        width: 210mm;
        height: 295.5mm; // 297mmだと2ページ目にはみ出してしまうので微調整
        page-break-after: always;
    }
}

// プレビュー用
@media screen {
    body {
        background: #ccc;
        margin: 0 auto;
        > svg {
            background: #fff;
            box-shadow: 0 .5mm 2mm rgba(0,0,0,.3);
            margin-top: 5mm;
        }
    }
}

このCSSの意味については今回は詳しい解説は割愛します🙏

以下の参考記事を読んでいただければ理解できると思います。

参考:

この時点で、下図のように Adobe XDでデザインした帳票がそのままの見た目で印刷プレビューっぽく画面に表示でき、ブラウザの印刷機能を使えばそのままの見た目でPDF出力もできる という状態まで来ました👍

デモアプリのソースコードの対応するコミットは こちら

3. 帳票テンプレート内のプレースホルダーを実際の値に置換する

この時点の出力内容は、デザインの時点で埋め込んでおいた %顧客名% のようなプレースホルダー文字列になっているので、出力する前にこれを実際の値に置換する処理を書きます。

PHPの場合は、普通に str_replace() で一つひとつ置換していけばOKです。画像を差し替える場合は xlink:href="data:image/png;base64,略" といった画像URL部分を置換します。

なお、<text> タグの font-family 属性の値も置換する必要があることに注意しましょう。Adobe XDやFigmaでデザインしたときにテキストオブジェクトに設定していたフォントが font-family 属性に書かれていますが、フォント自体が埋め込まれているわけではないので、別途ロードしたWebフォントに置き換えるか、明朝体とゴシック体の使い分けぐらいでいいなら serif sans-serif に置き換えてユーザーの環境に任せてしまってもよいかと思います。

この時点の出力結果は以下のような感じです。内容は実際の値に置換されましたが、テキストが枠をはみ出していますし、金額を右寄せにしたりもしたい感じですね。

デモアプリのソースコードの対応するコミットは こちら

4. 一行テキストの自動縮小・中央寄せ・右寄せをJSで処理する

JavaScriptから <text> 要素を( id 属性で指定して)いじることで、文字の自動縮小や配置の調整が可能です👍

それぞれ具体的な方法を説明します。

縮小して全体を表示

まずは、Excelにおける 縮小して全体を表示 相当の挙動をJavaScriptで実装します。

SVGの <text> <tspan> 要素には textLength という属性があり、テキスト全体の幅を指定することができます。

textLength をコンテンツ幅よりも小さく設定すると、デフォルトの挙動では文字のサイズは変わらず字間が無理矢理詰められて文字と文字が重なってしまうのですが、lengthAdjust 属性に spacingAndGlyphs を設定することでこの挙動を変更することができます。

spacingAndGlyphs は、これ以上字間を詰められなくなると文字自体の幅を縮小してくれます。高さは変わらず幅だけが縮小されるので、狭い領域にめちゃくちゃ長いテキストを入れてしまうと異常に縦長な文字になってしまいますが、その状況では仮に縦横比を維持したまま縮小されたとしても字が小さすぎて読めないと思いますし、帳票印刷という文脈ではほぼ気にしなくていいかなと思います。

注意すべきは、textLength で指定した幅よりもコンテンツの幅のほうが小さい場合、逆に拡大されてしまうことです。これは、

if (elem.clientWidth > config.textLength) {
  elem.setAttribute('textLength', config.textLength)
}

といった具合にコンテンツ幅が指定の幅を超えているときのみ textLength を適用するようにすればよいでしょう。

一応こんな議論もあるようです。
lengthAdjust values just for shrinking · Issue #341 · w3c/svgwg

なお、Firefoxでは

  • インライン要素に対しては clientWidth で幅が取得できない(常に0が返る)という 仕様
  • tspan 要素に対して textLength lengthAdjust 属性が機能しないという 既知のバグ

があるため、追加で このような対応 が必要になります。

中央寄せ・右寄せ

次に中央寄せ・右寄せについてですが、これは text-anchor 属性を使うことで実現可能です。

text-anchor 属性を middle にすれば、基準となるx座標にテキストの中心が来るようになり、end にすれば、基準となるx座標にテキストの末尾が来るようになります。特に指定しなければデフォルトで start という値になり、基準となるx座標にテキストの先頭が来るようになります。

「基準となるx座標」とは、text 要素の transform 属性(の translate(<x> [<y>]) 変換関数 )や tspan 要素の x 属性で指定されているx座標のことです。

なので、例えば右寄せを実現したい場合は、

  1. text 要素の transform 属性や tspan 要素の x 属性を操作して、右端となるx座標まで移動させる
  2. その上で、text 要素に text-anchor="end" を追加する

という操作が必要になります。

具体的な実装例は デモアプリの実際のコード をご参照ください。

この時点の出力結果は以下のような感じです。(備考とコメント以外の)テキストが枠内に収まり、中央寄せ・右寄せが適切に施されて見た目がだいぶ整いました。

デモアプリのソースコードの対応するコミットは こちら

5. 複数行テキストの自動折り返し・自動縮小をマークアップの置換で処理する

最後に、複数行テキストの自動折り返し・自動縮小に対応します。これは正直かなりの力技で対応する必要があります。

具体的には、

  • <text> 要素の中に行の数だけ <tspan> 要素を挿入して
  • 追加挿入した <tspan> 要素の y 属性を一行分ずつ大きくしていく

という処理を実装します。

SVGの <text> 要素には改行の概念がないため、このような力技が必要になります😓

SVG 1.1ではこれしかやりようがないのですが、SVG Tiny 1.2 には <textArea> という要素があり、テキストを自動で折り返してくれるようになっているようです。
また、HTMLの <br> に相当する <tbreak> という要素もあり、かなり簡単に複数行テキストを扱えそうです。
しかし残念ながらGoogle Chromeをはじめ主要なブラウザはSVG Tiny 1.2には対応していません。(要出典🙏)

ブラウザがSVG Tiny 1.2に対応しているかどうかは このページ で確認することができます。画面を開いてテキストが表示されれば対応しているということのようです。

実装方法は色々考えられますが、僕は

  1. 1文字を「一辺が font-size の正方形」と見立ててテキストエリアに収まる縦横の文字数を割り出す(プロポーショナルフォントでは誤差が出るけど無視)
  2. 横方向にその文字数を超える直前で改行を自動で入れて、もともとテキストが持っていた物理的な改行と合わせて最終的な行数を算出する
  3. その行数がテキストエリアの縦文字数よりも大きければ、font-size を少し小さく(0.95倍)して1に戻る、テキストエリアに収まっていれば4へ
  4. 各行を <tspan> 要素として書き出し、y 属性の値は各行 font-size 分ずつ大きくなるようにする(厳密には、行間も考慮して1.2倍したり)
  5. 作った文字列で元のSVGのテキストを置換する

という感じの処理を実装しました。実際にはもう少し細かい微調整もしていますが、詳細は デモアプリの実際のコード をご参照ください🙏

ここまでで、無事に完全な帳票が出力できるようになりました🙌

デモアプリ ではリロードする度に出力するテキストの量がランダムに変わるようになっているので、何度かリロードしてみて、どんな内容でも適切に折り返し・縮小されて枠に収まることを確認してみてください😉

デモアプリのソースコードの対応するコミットは こちら

ユーザーのブラウザに依存したくない場合は

ユーザーのブラウザの印刷機能に依存したくない場合は、 生成したHTMLのPDFへの変換まで含めてアプリ側でやってしまうとよいかと思います。

electron-pdfGoogle Chromeのヘッドレスモード を使えば特に問題なく実現できるでしょう✋

ChromeのヘッドレスモードによるPDF出力は、Macなら

$ /Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --headless --disable-gpu --print-to-pdf http://svg-paper-example.herokuapp.com/print/estimate/見積書(金額あり)

って感じで簡単に試せます。

おわりに

というわけで、僕の考えた最強の帳票印刷について解説しました。

解説は長くなりましたが、やっていること自体はそんなに複雑ではないですし、一度作ってしまえば他のプロジェクトにも同じ仕組みを流用できます。

今のところ自分の中でこれに勝る方法は見つけられていないので、もっといい方法あるよ!という方がいたらぜひ ご一報ください 💪

おまけ:HTML/CSSによる帳票デザインを試す中で試行錯誤したこと

おまけというか単なるメモです。試行錯誤の中で分かったことがいくつかあったので書き残しておきます。

表示する

縮小して全体を表示 の実現が意外と厄介

Excelにおける 縮小して全体を表示 相当の挙動はCSSでは実現不可能なので、JSを使う必要があります。

Fitting Text to a Container | CSS-Tricks

このページなどを参考によさげなライブラリをいくつか(rikschennink/fittySTRML/textFit 等)試してみましたが、どうもこの手のライブラリは フォントサイズをコンテンツ幅いっぱいにフィットさせる というコンセプトのものばかりで、テキストが多いときには期待どおり縮小されるのですが、逆にテキストが少ないときに枠いっぱいまで拡大されてしまう という挙動になってしまいました

やりたいのはもちろん縮小のみで拡大は一切されてほしくないのですが、標準の機能でそのような挙動を実現できるライブラリは見つけることができませんでした。

なので、複数行テキストのコンテンツにだけ maxSize 的なオプションを使って強引にフォントサイズを固定するようにする必要があります。

https://twitter.com/ttskch/status/1395242578191126529

1行目が colspan で結合されているテーブルで各列の幅を固定する方法

https://twitter.com/ttskch/status/1394812266822864901

flexboxで一方のカラムの幅を固定してもう一方のカラムを伸縮させる場合のベストプラクティス

https://twitter.com/ttskch/status/1394809869543231493

GitHubで編集を提案

Discussion