jsPDF・html2canvasで動的なデータのPDF出力機能を作った話
はじめに
初めまして、かめごろと申します。
今日は、業務で jsPDF
・html2canvas
を用いた動的なページデータの PDF 出力実装で苦戦したので思い出として記事を書いていこうと思います。
サンプルアプリはSvelte.kit
で作りましたが、 React
、Vue
を触っている方でも分かる内容になっています。
ぜひ最後までお付き合いください。
今回のコードのリポジトリです。
TL;DR
- ページ数が多くなる場合には向いていない。
- 外部から読み込む画像は全てを描画することができなかった。
- ページ数が多くなるとブラウザがクラッシュする。
- まだ、改善の余地はありそう。
なぜ jsPDF と html2Canvas でやってみようとなったのか
html2canvas
html2canvas
は、ブラウザにレンダリングされている Element を CanvasElement に描画することで画像化することが出来るライブラリです。
既に、社内の他プロジェクトで運用されており導入コストが低かった点で採用しました。
jsPDF
JavaScript で PDF を生成するためのライブラリです。
jsPDF
の他に、pdfMake
やpdf-lib
等のライブラリでも可能ですが、直近一年間の使用率や以前使用した経験があった点から採用しました。
PDF 出力までの流れ
PDF 化まで下記の流れになります。
-
HTMLElement
をhtml2canvas
でCanvasElement
に変換 -
CanvasElement
の縦横の比率を維持したまま PDF サイズに変換。 - PDF の各ページごとに描画できる範囲の
CanvasElement
を生成する。 - 全て PDF に描画し終わるまで Step3 を繰り返す。
jsPDF
とhtml2canvas
の説明は割愛させて頂きますので、ドキュメントをご確認ください。
jsPDF
html2canvas
実装詳細
HTMLElement
を html2canvas
で CanvasElement
に変換
1. ページでレンダリングされているエレメントを関数に渡します。
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);
.
.
.
}
CanvasElement
の縦横の比率を維持したまま PDF サイズに変換
2. CanvasElement
の PDF 上での高さを計算する関数を作ります。
// 縦横の比率を維持したままPDFサイズに変換
const convertCanvasHeightForPdf = (
canvasHeight: number,
canvasWidth: number,
pdfContentWidth: number
) => {
return (canvasHeight * pdfContentWidth) / canvasWidth;
};
CanvasElement
を生成する
3. PDF の各ページごとに描画できる範囲の一つの CanvasElement
の描画範囲を指定して使い回すことも出来ますが、生成された PDF をブラウザで開くとモッサリした動きになります。
Adobe Reader 等のツールで開くと分かりますが、表示されていない部分にも画像が存在していることが分かります。
悪い例 | 良い例 |
---|---|
ページを跨ぐ際に良しなに画像を切り分けては貰えないので、今回は 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 を見かけました。
パフォーマンス改善次第では変わるかもですが、7、8 ページあたりで生成に 10 秒前後かかるだけでなく、10 ページを超えるとブラウザがクラッシュし出すので結果的には動的なページ(ユーザー次第でページ数が多くなり得る場合)の PDF 化は、クライアントサイドでの生成は向いてないな。と感じました。
とはいえ、理解があれば簡単に実装する事ができるので要件によっては今後も使う機会はありそうです。
Discussion