HTMLからPDF出力機能を作りました


課題定義

  • HTMLページをPDFで出力する機能を作りたい(ページの一部出力可能)
  • HTMLページに画像があり
  • フロントエンドでやること

調査の結果

PDF出力機能を開発する時にいくつか問題が発生しました。解決するため、いくつか記事を参考しまた。
やっとPDF出力機能が出来ましたので、共有します。

ライブラリーを選ぶ

まずにPDFのライブラリーです。
https://github.com/MrRio/jsPDF
このライブラリーの目的はPDF作ることです。

PDFライブラリーがありましたが、このライブラリーでPDFに直接HTMLエレメントが追加出来ません。
→ 中間段階が必要になります。

こちらです:
HTMLエレメント → HTMLCanvasにする → 画像にする → PDFファイルに追加

HTMLエレメントからHTMLCanvasにする時、html2canvasライブラリーを使います。
https://github.com/niklasvh/html2canvas

HTMLCanvasから画像にする時、canvas.toDataURL()関数を使います。

簡単なコードを作りましょう

こちらです


function htmlElementToPDF(element: HTMLElement) {
  html2canvas(element, {}).then((canvas: HTMLCanvasElement) => {
    const imgData = canvas.toDataURL('image/jpeg', 1.0);
    const canvasImageWidth = canvas.width;
    const canvasImageHeight = canvas.height;

    const pdf = new jsPDF('p', 'pt',  [canvasImageWidth, canvasImageHeight]);

    pdf.addImage(imgData, 'JPG', 0, 0, canvasImageWidth, canvasImageHeight);

    pdf.save('sample.pdf');
  });
}

解読:
- html2canvasはelementからcanvasにする
- canvas.toDataURL('image/jpeg', 1.0)はcanvasから画像作る
- pdf.addImage(imgData, 'JPG', 0, 0, canvasImageWidth, canvasImageHeight)は画像をPDFに追加する

簡単なコードなのでいくつか問題があります
- elementの高さは長くてもPDFは1ページになりますので、見た目が正しくない
- canvas.toDataURLの時にCORSエラー発生するかもしれません(あとで説明する)

ちょっと複雑なコードでPDFページングします

  • Elementの高さは長いすぎる時に、PDFの一つページが足りなく、PDFページ追加必要です。
function htmlElementToPDF(element: HTMLElement) {
  html2canvas(element, {}).then((canvas: HTMLCanvasElement) => {
    const imgData = canvas.toDataURL('image/jpeg', 1.0);

    const pdfWidth = canvas.width;
    const pdfHeight = canvas.width * 1.5;

    const canvasImageWidth = canvas.width;
    const canvasImageHeight = canvas.height;

    const pdf = new jsPDF('p', 'pt',  [pdfWidth, pdfHeight]);

    // PDFに追加した分をここに入れます
    let filledImageHeight = 0;

    while (true) {
      // PDFに画像を追加する時に、追加した分を外したいので、-filledImageHeightにする
      pdf.addImage(imgData, 'JPG', -filledImageHeight, 0, canvasImageWidth, canvasImageHeight);
      filledImageHeight += canvasImageHeight;

      // 全部画像追加された場合、PDF出力完了です。
      if (filledImageHeight >= canvasImageHeight) {
        break;
      }

      // PDFページが足りないため、一つページを追加する
      pdf.addPage([pdfWidth, pdfHeight]);
    }

    pdf.save('sample.pdf');
  });
}

解読
- コードに付けました

良いこと
- elementの高さは長くてもPDFページがどんどん追加されます。

問題

canvas.toDataURLの時にCORSエラー発生するかもしれません

原因は html2canvasでエレメントにある画像をダウンロードしますが、画像のドメインは今のドメインじゃありせん(s3やgoogledriveなど)。まずに以下のオプションをためてください

html2canvas(element, {allowTaint : false, useCORS: true})

それで出来たらいいですが、出来ない場合が結構あります。(画像はs3リンックなら、出来ません。https://stackoverflow.com/questions/51317126/aws-s3-with-html2canvas-cors-issue-with-multiple-browsers/51354027

出来なければ、自分のサーバーで画像をダウンロードして、フロントエンに渡すれて解決出来ます。(直接にS3リンク使わず、バックエンドのAPIを用意すること)

Elementにある画像がたくさんがある時に、Canvasエレメントが重いすぎて、PDFに追加する時に、最後に一部出力出来ないこと (これはライブラリーのせいと思います)

これもよく発生する問題です。解決できる為に、エレメントを分けて、Canvasにすれば解決できると思います。

こんな感じ

async function htmlElementToPDF(element: HTMLElement) {

  // canvasを取る
  const canvasElements: HTMLCanvasElement[] = [];
  for (let index = 0; index < element.children.length; index++) {
    const item = element.children[index] as HTMLElement;
    canvasElements.push(await html2canvas(item, {}));
  }

  // PDF Create
  let pdfWidth = 0;
  canvasElements.forEach(canvas => {
    if (canvas.width > pdfWidth) {
      pdfWidth = canvas.width;
    }
  });
  const pdfHeight = (pdfWidth * 1.5);
  const pdf = new jsPDF('p', 'pt',  [pdfWidth, pdfHeight]);

  // Add canvas into pdf
  // PDFページにどこまでにデーターを入れたのか、この変数に値を入れます。
  let currentHeightIndex = 0;
  canvasElements.forEach((canvas) => {
    const imgData = canvas.toDataURL('image/jpeg', 1.0);
    const canvasImageWidth = canvas.width;
    const canvasImageHeight = canvas.height;

    // PDFに追加されていない高さの変数です 
    let unFilledImageHeight = canvas.height;

    while (true) {
      if (currentHeightIndex > 0) {
        // currentHeightIndex > 0 の場合、画像を最初に追加されますので、currentHeightIndexから追加する
        pdf.addImage(imgData, 'JPG', 0, currentHeightIndex, canvasImageWidth, canvasImageHeight);
      } else {
        // currentHeightIndex = 0 の場合、画像の追加の途中かもしれませんので、追加された部分を外す
        const filledImageHeight = canvas.height - unFilledImageHeight;
        pdf.addImage(imgData, 'JPG', 0, -filledImageHeight, canvasImageWidth, canvasImageHeight);
      }

      if ((pdfHeight - currentHeightIndex) > unFilledImageHeight) {
        // 全部の画像は追加されたのでBreakして、PDFの残り部分を覚える
        currentHeightIndex += unFilledImageHeight;

        break;
      } else {
        // PDFに追加された高さを更新します
        unFilledImageHeight -= (pdfHeight - currentHeightIndex);

        // PDFページがたりないので、次のPDFページにします。
        pdf.addPage([pdfWidth, pdfHeight]);

        // 新しいページですので、一番上にデータ追加します。
        currentHeightIndex = 0;
      }
    }
  });

  pdf.save('sample.pdf');

}

その他

  • コードをちょっと修正すると、PDDのMargin設定できると思いますが、この記事には書いていません。