Javascriptで帳票印刷したい


Vue.jsを利用したWebシステムで帳票印刷する必要がありました。
方法がいくつかあり、検討した知見を紹介します。

背景

  • フロントはVue.jsを利用したSPA
  • 指定条件を入力しビジネスの統計情報を画面表示する
    • 集計、グラフを伴う
  • 統計画面上の「印刷」ボタンを押下すると帳票印刷 or PDFダウンロードする
    • ビジネスレポートは複数ページに渡る
    • 数値の集計表、グラフなどを伴う
  • ヘッダ/フッタなどの体裁はこだわらない

また、画面ページ上の統計情報と印刷ページ上の統計情報は同じ情報であり、
開発コスト低減のために可能であれば、集計表、グラフなどの画面表示用コンポーネントを流用して帳票をレイアウトしたい状況です。

方式検討

実現方式は以下のように複数考えられますが、
最終的には「ブラウザの印刷機能でPDF保存」させる方法を採用しました。

  • サーバサイドでPDFファイルを生成
    • テンプレートPDFを元に差し込み
    • PDF生成エンジンを利用
      • 独自レイアウト方式型
      • HTML+CSSでレイアウト型
    • headlessブラウザのレンダリング/印刷機能を利用
  • クライアントサイドでPDFファイルを生成
    • PDFファイルを生成するJSライブラリ利用
      • 独自レイアウト型
      • HTML+CSSでレイアウト型
      • Domを元にCanvas化し、Canvas画像をPDFに貼り付ける
  • クライアントサイドでブラウザの印刷機能でPDF保存 ←採用
    • レイアウトはHTML+CSS
    • Mediaクエリで印刷用CSS定義

以下、検討の際に考慮した観点について補足します。

定形雛形に差し込み vs ページ記述

例えば、「領収書印刷」などの用途のような、以下の条件のようなものは

  • 1枚ペラもの
  • 定型フォーマット
  • 所定の位置に 宛名、金額 が記載できればOK

定形のテンプレートPDFを作成しておき、宛名、金額など一部のテキストに必要な値を差し込む方式が適していると言えます。

本要件は比較的ボリュームの多いビジネスレポートであり、以下のような特性があるため、テンプレート形式を取りにくいと考えました。

  • ページ数は不定
    • 長文は改ページして複数ページをまたぐ
  • グラフ、表が含まれる

レイアウト方式

独自方式

このとき、どのようにレイアウトをPDF化するか、という問題がありますが、
PDF生成エンジン・ライブラリによっては、独自のレイアウト指定が必要なものがあります。

以下はPDF生成ライブラリpdfkit の例です。
テキストだけなら良いですが、表やグラフをこれで描画するのは骨が折れそうです。

pdfkitの例
const PDFDocument = require('pdfkit');

// Create a document
const doc = new PDFDocument;

// Pipe its output somewhere, like to a file or HTTP response
// See below for browser usage
doc.pipe(fs.createWriteStream('output.pdf'));

// Embed a font, set the font size, and render some text
doc.font('fonts/PalatinoBold.ttf')
   .fontSize(25)
   .text('Some text with an embedded font!', 100, 100);

// Add an image, constrain it to a given size, and center it vertically and horizontally
doc.image('path/to/image.png', {
   fit: [250, 300],
   align: 'center',
   valign: 'center'
});

HTMLでレイアウト

独自レイアウト方法が大変なので、
慣れたHTML+CSSでレイアウト指定が可能なものがありますが、
カスタムCSSが利用しづらいなどの注意点ががあるようです。

bpampuch/pdfmake

ブラウザの印刷機能でPDFレンダリング

ChromeやFirefox, Safariなど人気のあるモダンブラウザでは
印刷機能でPDFとして保存する機能を備えており、
そのレンダリング性能も信頼できると言って良いでしょう。

Mediaクエリを利用して印刷用CSSを定義し、
印刷する際のコンテンツサイズ指定、不要な画面サイドのナビやボタン類を非表示にするなどの制御が可能です。

フォント

PDF生成エンジン/ライブラリによってはデフォルトで日本語フォントを扱えないものが多いようです。
pdfkit, pdfmake(内部でpdfkitを利用)などはフォント埋め込みのための事前処理や設定が必要なようです。

特に、クライアントサイドでPDFを生成する場合は埋め込み用フォントを事前生成するという方法以外に、
Domとして日本語表示したイメージをあえて画像化してPDFに埋め込む、という方法もあります。

日本語コンテンツのボリュームによっては、多くの画像が埋め込まれるため、(フォント埋め込みの場合よりも)PDFのサイズは大きくなるかもしれません。

改ページ

複数ページにまたがるPDFの場合は改ページ位置を制御したいかもしれません。
PDF生成エンジン、ライブラリを利用する場合は、そのエンジンの改ページ制御方法に倣う必要があります。

ブラウザの印刷機能でPDFを生成する場合もMediaクエリで改ページの制御が可能です。

一方、フォントの問題のためにコンテンツを画像化してPDFに貼り付けている場合は改ページ制御が煩わしいかもしれません。

その他のデザイン・体裁のコントロール

特に、ブラウザの印刷機能を利用してPDFファイルを作成する場合、
クライアントユーザの設定によって生成されるPDFが異なるため、
デザイン・体裁的にコントロール、画一化することは難しいのが現状です。

  • 用紙サイズ、向き、マージン
  • 背景画像の有無
  • ヘッダ・フッタの有無
    • URL, ページタイトル, 作成日

主要なモダンブラウザのデフォルトで利用した際に
文章の中身が読めれば良く、ヘッダ・フッタなどにはこだわらなくて良い、
という今回の要件では許容できると判断しました。

処理の負荷 (サーバサイド/クライアントサイド)

不特定多数のユーザが利用するシステムであり、
サーバ負荷を抑えたいため、可能であればクライアント側でPDFを生成したいと考えました。

デバッグのしやすさ

ブラウザの印刷機能を利用する場合は、デバッグの際に、印刷用CSSと画面表示用CSSを合わせて置けばレイアウト確認は楽々ですね。
ブラウザのデベロッパーツールも活用できるので、ルーラを表示したり、画面を見ながら微調整を試すことも容易です。

PDF生成エンジン/ライブラリの場合はデバッグは大変かも。

Vue.js での実装例

<template>
  <div class="sheets">
    <div>
      <el-button type="primary" @click="handlePrint">印刷</el-button>
      ※PDFで保存したい場合は印刷ダイアログで「PDF保存」を指定してください
    </div>
    <div class="sheet">
      <h2>帳票サンプル</h2>
      <h3>テーブルを印刷する</h3>
      <el-table :data="list" border fit>
        <el-table-column label="ID" prop="id" align="center" width="80px">
          <template slot-scope="scope">
            <span>{{ scope.row.id }}</span>
          </template>
        </el-table-column>
        <el-table-column label="Title" min-width="150px">
          <template slot-scope="{row}">
            <span>{{ row.title }}</span>
          </template>
        </el-table-column>
        <el-table-column label="Author" width="110px" align="center">
          <template slot-scope="scope">
            <span>{{ scope.row.author }}</span>
          </template>
        </el-table-column>
      </el-table>
    </div>
    <div class="sheet">
      <h3>改ページのテスト</h3>
      2ページ目
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      list: null
    }
  },
  created() {
    this.getList()
  },
  mounted() {
    this.fetchData()
  },
  methods: {
    getList() {
      // APIからデータ取得する想定
      this.list = [
        { id: 1, author: 'John Due', title: 'Hello, world' },
        { id: 2, author: '太郎', title: 'あいうえお かきくけこ' }
      ]
    },
    fetchData() {
      document.title = 'タイトルをいい感じに設定する'
      setTimeout(() => {
        this.$nextTick(() => {
          this.handlePrint()
        })
      })
    },
    handlePrint() {
      window.print()
    }
  }
}
</script>

<style lang="scss" scoped>
.sheet {
  page-break-after: always;
}

/* hide in print */
@media print {
  .sheets > :not(.sheet) {
    display: none;
  }
}

/* for preview */
@media screen {
  /* mm単位で指定しているけど、vueコンポ側はpx単位なので、無理にmmにしなくてもいいかも。解像度の違いでハマるかも */
  .sheet {
    width: 200mm;
    min-height: 296mm; /* 設定しなくてもいいかも。あまり印刷画面に似せすぎると、些細な違いがバグに見えてしまう */
    margin: 5mm;
    padding: 5mm;
    background: white;
    box-shadow: 0 .5mm 2mm rgba(0,0,0,.3);
  }
}
</style>
<style lang="scss">
/* for preview */
@media screen {
  BODY {
    background: #eee;
  }
}
</style>

Vueで実装した帳票プレビュー画面

Webシステムで帳票印刷機能を実行すると、プレビュー画面を表示するようにしています。
画面上部にある「印刷」ボタンは、メディアクエリにて画面表示の場合のみボタン表示して、印刷時には非表示にしています。

あえて、印刷プレビューっぽく見えるようにグレー背景、縦ページ、ドロップシャドウなどを設定していますが、ブラウザの印刷ダイアログでもプレビュー表示されるので、無くて良いかも。

ブラウザの印刷ダイアログ

Chromeの印刷ダイアログの例。
デフォルトではWebページタイトルがPDFファイル名になるので、印刷直前にWebページタイトルを切り替えるように実装しておきます。

参考