🌄

jspdfとhtml-to-imageを使って動的にPDFを複数ページ生成する

2024/04/16に公開

はじめに

少し前にインターン先の業務で動的なPDF生成機能を実装しました.
要件は下記の通りです.

  • APIのresponseに応じてPDFの中身を変えて複数ページ生成する
  • 画像やCSSスタイルなどを盛り込んでも,デザイン通りに生成される
  • 出来るだけファイルサイズは抑える

動的なPDFを生成する方法は記事こそ多くないもののパターンがいくつかあり,色々試してスタイルが当たらなかったりと,四苦八苦したので今回はそんな中で上記の要件を満たせるかつ,自分にとって一番しっくりきた実装方法をご紹介します.

採用技術

今回はjspdfhtml-to-imageを使用します.
https://github.com/parallax/jsPDF
https://github.com/bubkoo/html-to-image
動的なPDFを生成するときのライブラリの候補としてreact-pdfや,jspdf × html2canvasという選択肢もあると思います.
今回これらを採用しなかった理由は下記の通りです.

react-pdf

Reactと相性は良いですが,ライブラリ側で用意された指定のコンポーネントを用いる必要があり,既存のコンポーネントを用いることができません.業務では既に実装しているコンポーネントをPDFに変換する必要があり,導入コストが高かったため採用を見送りました.

jspdf × html2canvas

html-to-imagehtml2canvasを内包しているため,できること,やることに大した違いはありませんが,html-to-imageの方がより簡易的に実装できます.また,html2canvasは今回の実装に必要なCSSプロパティが対応していないなどの問題点があったため,採用を見送りました.
https://github.com/niklasvh/html2canvas/issues/1258

実装方法

PDF用コンポーネント

まずは,PDF生成の対象となるコンポーネントを作成していきます.

components/container-pdf.tsx
export const ContainerPdf = ({ children }: ContainerPdfProps) => {
  return (
    <div
      style={{
        width: "595px",
        height: "842px",
        border: "solid",
      }}
    >
      {children}
    </div>
  );
};
pages/index.tsx
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生成ロジック

hooks/use-pdf.ts
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を参考にしてください.
https://artskydj.github.io/jsPDF/docs/jsPDF.html

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関数を用いていますが,他にもtoPngtoSvgなどを使うとさまざまな形式に変換することができます.以前は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を参考にしてください.
https://github.com/bubkoo/html-to-image/pkgs/npm/html-to-image

//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オブジェクトからファイルを直接生成することも可能です.

pages/index.tsx
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;

最後はコンポーネントとロジックを繋ぎ込んで終了です.
下画像のような出力結果になりました.
プレビュー

あとがき

初めての記事執筆のため,拙い文章だったと思いますが最後まで読んでいただきありがとうございました.

本記事で利用したコードはこちらのリポジトリにあります.
https://github.com/hyphen-o/dynamic-pdf

GitHubで編集を提案

Discussion