jspdfとhtml-to-imageを使って動的にPDFを複数ページ生成する
はじめに
少し前にインターン先の業務で動的なPDF生成機能を実装しました.
要件は下記の通りです.
- APIのresponseに応じてPDFの中身を変えて複数ページ生成する
- 画像やCSSスタイルなどを盛り込んでも,デザイン通りに生成される
- 出来るだけファイルサイズは抑える
動的なPDFを生成する方法は記事こそ多くないもののパターンがいくつかあり,色々試してスタイルが当たらなかったりと,四苦八苦したので今回はそんな中で上記の要件を満たせるかつ,自分にとって一番しっくりきた実装方法をご紹介します.
採用技術
今回はjspdf
とhtml-to-image
を使用します.
動的なPDFを生成するときのライブラリの候補としてreact-pdf
や,jspdf × html2canvas
という選択肢もあると思います.
今回これらを採用しなかった理由は下記の通りです.
react-pdf
Reactと相性は良いですが,ライブラリ側で用意された指定のコンポーネントを用いる必要があり,既存のコンポーネントを用いることができません.業務では既に実装しているコンポーネントをPDFに変換する必要があり,導入コストが高かったため採用を見送りました.
jspdf × html2canvas
html-to-image
はhtml2canvas
を内包しているため,できること,やることに大した違いはありませんが,html-to-image
の方がより簡易的に実装できます.また,html2canvas
は今回の実装に必要なCSSプロパティが対応していないなどの問題点があったため,採用を見送りました.
実装方法
PDF用コンポーネント
まずは,PDF生成の対象となるコンポーネントを作成していきます.
export const ContainerPdf = ({ children }: ContainerPdfProps) => {
return (
<div
style={{
width: "595px",
height: "842px",
border: "solid",
}}
>
{children}
</div>
);
};
import { ContainerPdf } from "@/components/container";
const Index = () => {
return (
<div
style={{
display: "flex",
padding: "20px",
}}
>
<div
style={{
display: "flex",
gap: "10px",
}}
>
<ContainerPdf>
<h1>sample dynamic-PDF </h1>
<p>This is sample for generating PDF!</p>
</ContainerPdf>
<ContainerPdf>
<p>This is page 2</p>
</ContainerPdf>
</div>
<button
onClick={}
style={{
width: "200px",
height: "50px",
}}
>
generate pdf
</button>
</div>
);
};
export default Index;
プレビュー
ContainerPdf
で囲まれた部分(黒枠で囲われた部分)がPDF化の対象となっており,A4縦サイズで指定しています.比率が同じであればサイズは変更しても問題ないです.
PDF生成ロジック
import { useRef } from "react";
import { toJpeg } from "html-to-image";
import jsPDF from "jspdf";
export const usePdf = () => {
const targetRef = useRef<HTMLDivElement>(null);
//PDF生成
const generatePdf = async () => {
if(!targetRef.current) return
const pdf = new jsPDF("p", "px", [595, 842]);
//PDFの高さと横幅を取得
const width = pdf.internal.pageSize.getWidth();
const height = pdf.internal.pageSize.getHeight();
//ページごとのElementを配列に格納
const childElements = Array.from(targetRef.current.children);
//ページごとにPDF生成
for (const childElement of childElements) {
const dataUrl = await toJpeg(childElement as HTMLElement, { backgroundColor: "white" });
pdf.addImage(dataUrl, "PNG", 0, 0, width, height);
pdf.addPage();
}
//最後の白紙ページを削除
pdf.deletePage(pdf.getNumberOfPages());
//Blobオブジェクトを生成
const pdfBytes = pdf.output("arraybuffer");
const pdfBlob = new Blob([pdfBytes], { type: "application/pdf" });
//URLを生成
const fileUrl = URL.createObjectURL(pdfBlob);
window.open(fileUrl);
};
return { targetRef, generatePdf };
};
コメントも書いていますが,それぞれ詳しく解説していきます.
const pdf = new jsPDF("p", "px", [595, 842]);
jsPDFクラスのインスタンスを生成しています.生成時に向きやサイズを指定することができ,今回は縦向き,A4サイズを指定しています.その他のoptionは下記のDoumentを参考にしてください.
const targetRef = useRef<HTMLDivElement>(null);
PDF化の対象コンポーネントの親Elementを得るためにRefオブジェクトを作成しておきます.
const childElements = Array.from(targetRef.current.children);
各ページのElementを取得してきて配列にします.
for (const childElement of childElements) {
const dataUrl = await toJpeg(childElement as HTMLElement, { backgroundColor: "white" });
pdf.addImage(dataUrl, "JPEG", 0, 0, width, height);
pdf.addPage();
}
//最後の白紙ページを削除
pdf.deletePage(pdf.getNumberOfPages());
各ページのElementをhtml-to-image
ライブラリのtoJpeg
関数を使って画像に変換し,pdfに追加しています.今回はtoJpeg
関数を用いていますが,他にもtoPng
やtoSvg
などを使うとさまざまな形式に変換することができます.以前はpngに変換して使っていたのですが,生成時間が非常に長く,ファイルサイズも大きくなってしまったため,jpegに乗り換えました.解像度はpngよりやや落ちますが,目立たないレベルだと思います.
ファイルサイズの比較
10ページのPDFを生成した場合(スタイルなどにより差が出る可能性あり)
toPng
:120MB
toJpeg
:4.3MB
もしさらに解像度を上げたい方はcomponents/container.tsx
で指定しているスタイルのサイズを比率を変えずに大きくすることで可能です.
また,html-to-image
が提供している関数にはいくつかoptionがあります.今回はbackgroundColor
というoptionを使って背景色を指定しています.
もしさらにファイルサイズを小さくしたいという方は下記のようにqualityというoptionを指定すると良いと思います.解像度とトレードオフなため,注意が必要です.
const dataUrl = await toJpeg(childElement as HTMLElement, {
backgroundColor: "white",
quality: 0.8 //デフォルトは1
});
その他のoptionは下記の公式READMEを参考にしてください.
//Blobオブジェクトを生成
const pdfBytes = pdf.output("arraybuffer");
const pdfBlob = new Blob([pdfBytes], { type: "application/pdf" });
//URLを生成
const fileUrl = URL.createObjectURL(pdfBlob);
window.open(fileUrl);
PDFが作成できたので,Blobオブジェクトに変換してURLを生成しています.今回はプレビューとして別ウインドウでPDFを表示するようにしています.Blobオブジェクトからファイルを直接生成することも可能です.
import { ContainerPdf } from "@/components/container";
import { usePdf } from "@/hooks";
const Index = () => {
+ const { targetRef, generatePdf } = usePdf();
return (
<div
style={{
display: "flex",
padding: "20px",
}}
>
<div
style={{
display: "flex",
gap: "10px",
}}
+ ref={targetRef}
>
<ContainerPdf>
<h1>sample dynamic-PDF </h1>
<p>This is sample for generating PDF!</p>
</ContainerPdf>
<ContainerPdf>
<p>This is page 2</p>
</ContainerPdf>
</div>
<button
+ onClick={generatePdf}
style={{
width: "200px",
height: "50px",
}}
>
generate pdf
</button>
</div>
);
};
export default Index;
最後はコンポーネントとロジックを繋ぎ込んで終了です.
下画像のような出力結果になりました.
あとがき
初めての記事執筆のため,拙い文章だったと思いますが最後まで読んでいただきありがとうございました.
本記事で利用したコードはこちらのリポジトリにあります.
Discussion