🙆

jsPDF・html2canvasで動的なデータのPDF出力機能を作った話

2023/02/25に公開

はじめに

初めまして、かめごろと申します。
今日は、業務で jsPDFhtml2canvas を用いた動的なページデータの PDF 出力実装で苦戦したので思い出として記事を書いていこうと思います。

サンプルアプリはSvelte.kit で作りましたが、 ReactVue を触っている方でも分かる内容になっています。
ぜひ最後までお付き合いください。

今回のコードのリポジトリです。

https://github.com/kamegoro/screen-pdf-output-app

TL;DR

  • ページ数が多くなる場合には向いていない。
  • 外部から読み込む画像は全てを描画することができなかった。
  • ページ数が多くなるとブラウザがクラッシュする。
  • まだ、改善の余地はありそう。

なぜ jsPDF と html2Canvas でやってみようとなったのか

html2canvas

https://github.com/niklasvh/html2canvas/

html2canvasは、ブラウザにレンダリングされている Element を CanvasElement に描画することで画像化することが出来るライブラリです。

既に、社内の他プロジェクトで運用されており導入コストが低かった点で採用しました。

jsPDF

https://github.com/parallax/jsPDF

JavaScript で PDF を生成するためのライブラリです。
jsPDFの他に、pdfMakepdf-lib等のライブラリでも可能ですが、直近一年間の使用率や以前使用した経験があった点から採用しました。

pdf-npm-trend

PDF 出力までの流れ

PDF 化まで下記の流れになります。

  1. HTMLElementhtml2canvasCanvasElement に変換
  2. CanvasElement の縦横の比率を維持したまま PDF サイズに変換。
  3. PDF の各ページごとに描画できる範囲のCanvasElement を生成する。
  4. 全て PDF に描画し終わるまで Step3 を繰り返す。

jsPDFhtml2canvasの説明は割愛させて頂きますので、ドキュメントをご確認ください。

jsPDF

https://raw.githack.com/MrRio/jsPDF/master/docs/index.html

html2canvas

https://html2canvas.hertzen.com/

実装詳細

1. HTMLElementhtml2canvasCanvasElement に変換

ページでレンダリングされているエレメントを関数に渡します。

import html2canvas from 'html2canvas';
import jsPDF from 'jspdf';

const generatePdf = async (element: HTMLElement) => {
 const doc = new jsPDF('p', 'mm', 'a4', true);

 // A4サイズのPDFの高さ・横幅
 const pdfWidth = doc.internal.pageSize.getWidth();
 const pdfHeight = doc.internal.pageSize.getHeight();

 // 引数に渡したElementがCanvasElementに描画されて返される
 const canvas = await html2canvas(element);
                       .
                       .
                       .
}

2. CanvasElement の縦横の比率を維持したまま PDF サイズに変換

CanvasElementの PDF 上での高さを計算する関数を作ります。

// 縦横の比率を維持したままPDFサイズに変換
const convertCanvasHeightForPdf = (
  canvasHeight: number,
  canvasWidth: number,
  pdfContentWidth: number
) => {
  return (canvasHeight * pdfContentWidth) / canvasWidth;
};

3. PDF の各ページごとに描画できる範囲のCanvasElement を生成する

一つの CanvasElement の描画範囲を指定して使い回すことも出来ますが、生成された PDF をブラウザで開くとモッサリした動きになります。
Adobe Reader 等のツールで開くと分かりますが、表示されていない部分にも画像が存在していることが分かります。

悪い例 良い例
bad good

ページを跨ぐ際に良しなに画像を切り分けては貰えないので、今回は CanvasElement を元にページ毎に新しい CanvasElement を生成する関数を作ります。

const clipCanvas = (
  baseCanvas: HTMLCanvasElement,
  canvasPdfY: number,
  pdfWidth: number,
  clipPdfHeight: number
) => {
  const clipCanvas = document.createElement("canvas");

  // PDFの高さからCanvasの高さを逆算
  const clipCanvasHeight = (pdfHeight: number) =>
    (pdfHeight * baseCanvas.width) / pdfWidth;

  clipCanvas.width = baseCanvas.width;
  clipCanvas.height = clipCanvasHeight(clipPdfHeight);
  const context = clipCanvas.getContext("2d");
  if (context) {
    context.drawImage(baseCanvas, 0, clipCanvasHeight(canvasPdfY));
  }

  return {
    height: clipCanvas.height,
    width: clipCanvas.width,
    dataUrl: clipCanvas.toDataURL("image/png"),
  };
};

4. 全て PDF に描画し終わるまで Step3 を繰り返す

Step2 で計算された PDF 上での CanvasElement の分、描画されるまで繰り返します。

1. 1ページ分 PDF の高さの `CanvasElement` の生成
2. PDF に描画
3. ベースの Canvas から1ページ分 PDF の高さを引く
4. ベースの Canvas の高さが 0 より小さくなるまで 1 ~ 3 を繰り返す
const heightInCanvasPdf = convertCanvasHeightForPdf(
  canvas.height,
  canvas.width,
  pdfContentWidth
);
let restImageHeight = heightInCanvasPdf;

while (restImageHeight > 0) {
  if (restImageHeight <= pdfHeight) {
    const clippedCanvas = clipCanvas(
      canvas,
      -(heightInCanvasPdf - restImageHeight),
      pdfContentWidth,
      restImageHeight
    );
    doc.addImage(clippedCanvas.dataUrl, "PNG", margin, 0, pdfContentWidth, 0);
    restImageHeight -= pdfHeight;
  } else {
    const clippedCanvas = clipCanvas(
      canvas,
      -(heightInCanvasPdf - restImageHeight),
      pdfContentWidth,
      pdfHeight
    );
    doc.addImage(clippedCanvas.dataUrl, "PNG", margin, 0, pdfContentWidth, 0);
    doc.addPage();
    restImageHeight -= pdfHeight;
  }
}

まとめ

html2canvasを用いた動的な PDF 出力の例があまり見つからなく、手探りで進めていたためバグに当たっては修正を繰り返していました。

初期実装時は、ページ毎に画像を切り分けずに一つの画像の表示領域を調整して実装しましたが Adobe Reader を使ってなかったので原因を掴むのに時間がかかったりと、総合的な時間を考えると、サーバー側で要素を組み立てる方が早かったなぁと思ったり..

デモアプリの実装はfaker.jsを使って外部から画像を取得していましたが、PDF 化時に画像が表示がレンダリングされないのを解消できなかったので断念しましたが、他にいくつか同様な問題を挙げてる issue を見かけました。

https://github.com/parallax/jsPDF/issues/1578

パフォーマンス改善次第では変わるかもですが、7、8 ページあたりで生成に 10 秒前後かかるだけでなく、10 ページを超えるとブラウザがクラッシュし出すので結果的には動的なページ(ユーザー次第でページ数が多くなり得る場合)の PDF 化は、クライアントサイドでの生成は向いてないな。と感じました。

とはいえ、理解があれば簡単に実装する事ができるので要件によっては今後も使う機会はありそうです。

GitHubで編集を提案

Discussion