Converting a JavaScript-Rendered Web Page to PDFのアイキャッチ画像

PetrDev

モダンなWebページをプログラムでPDFに変換しようとしたことがあるなら、おそらく壁にぶつかったことがあるでしょう。ファイルが空白になったり、半分しか表示されなかったり、読み込みスピナーで止まったりします。ブラウザでは完璧に見えるのに、どうしてなのでしょうか?

答えはタイミングにあります。多くのPDF生成手法は、JavaScriptがコンテンツをレンダリングするにHTMLを取得します。サーバーサイドレンダリングされたページなら問題ありません。マークアップはすでに存在するからです。React/Vue/Angularアプリでは、サーバーはほぼ空のシェルを送り、ブラウザがその後でDOMを構築します。早すぎると、シェルだけを保存することになります。

以下が正しい方法です。

素朴なアプローチが失敗する理由

wkhtmltopdfはGoogleの定番回答です。高速で昔から存在していますが、事実上モダンなJavaScriptをサポートしない古いWebKitビルドを使用しています。静的ページなら問題ありませんが、クライアントレンダリングされたコンテンツには空の状態をキャプチャしてしまいます。

ブラウザの window.print() / Ctrl+P本物のブラウザであるため動作しますが、手動操作で、単一ページ用であり、大規模に自動化することは困難です。

生のHTMLをHTTPクライアントで取得する(axios/fetchで取得してPDFライブラリに渡す)場合も、wkhtmltopdfと同様に致命的な欠陥があります。JSの実行がなく、レンダリングされたコンテンツが取得できません。

実際に必要なのは、ページのJavaScriptを実行し、安定するまで待ってから印刷する本物のブラウザエンジンです。それがPuppeteerです。

Puppeteerアプローチ

PuppeteerはヘッドレスChromiumを操作します。ページを通常のChromeタブと同じように実行するため、画面に表示されるものがそのままキャプチャされます。

const puppeteer = require('puppeteer');

async function pageToPdf(url, outPath) {
  const browser = await puppeteer.launch({
    args: ['--no-sandbox', '--disable-setuid-sandbox'], // ほとんどのコンテナで必要
  });
  const page = await browser.newPage();

  await page.goto(url, { waitUntil: 'networkidle0', timeout: 60000 });

  await page.pdf({
    path: outPath,
    format: 'A4',
    printBackground: true, // 指定しないとCSSの背景色が失われる
    margin: { top: '20px', bottom: '20px', left: '16px', right: '16px' },
  });

  await browser.close();
}

pageToPdf('https://example.com', 'out.pdf');

全画面表示モードに入る 全画面表示モードを終了

ここで重要な2つのオプションがあります。

  • waitUntil: 'networkidle0'は、500ms以上ネットワーク接続がない場合にのみナビゲーション完了とみなすようPuppeteerに指示します。ほとんどのSPAでは、データが読み込まれDOMが構築された合図となります。(networkidle2は最大2接続まで許可 — ロングポーリングやアナリティクスビーコンが完全に停止しないページに便利です。)
  • printBackground: true — これを忘れると、背景色、グラデーション、画像がすべて消失します。Chromeの印刷パスがデフォルトでそれらを除外するためです。

まだ注意が必要な落とし穴

空白でないPDFを得ることは最初のステップです。正しいPDFを得ることはここで問題が表面化します。

遅延読み込みコンテンツ

スクロール時に読み込まれる画像やセクションはPDFに含まれません。Puppeteerがスクロールしないためです。印刷前に自動スクロールして強制的に読み込ませます。

await page.evaluate(async () => {
  await new Promise((resolve) => {
    let y = 0;
    const step = 300;
    const timer = setInterval(() => {
      window.scrollBy(0, step);
      y += step;
      if (y >= document.body.scrollHeight) {
        clearInterval(timer);
        window.scrollTo(0, 0);
        resolve();
      }
    }, 100);
  });
});

全画面表示モードに入る 全画面表示モードを終了

Webフォントがまだ読み込まれていない

Webフォントの読み込みが完了していないため、テキストがフォールバックフォントでレンダリングされます。明示的に待機します。

await page.evaluateHandle('document.fonts.ready');

全画面表示モードに入る 全画面表示モードを終了

印刷用CSSがレイアウトを上書きする

サイトが@media printスタイルシートを提供している場合、Chromeはそれを適用します。意図した動作の場合もありますが、レイアウトを破壊する場合もあります。次のコードで画面表示を強制します。

await page.emulateMediaType('screen');

全画面表示モードに入る 全画面表示モードを終了

固定ヘッダーが繰り返し表示されたりコンテンツを覆ったりする

sticky/fixed要素はPDFの全ページで重複したり、本文と重なったりする可能性があります。信頼できる修正方法は、印刷前にpage.addStyleTag()で位置をstaticに設定(または非表示)することです。

Puppeteerをセルフホストする価値がない場合

Puppeteerは制御下のパイプライン内でPDFを必要とする場合に適したツールです。しかし実際の運用上の負担もあります。ヘッドレスChromiumは重く(200MB以上)、コンテナに適切なシステムライブラリが必要で、ブラウザインスタンスを慎重に管理しないとメモリリークが発生し、ページプーリングと並列処理の上限設定をしなければ同時実行時にクラッシュします。「ユーザーにこのページをエクスポートさせる」といったサブ機能としては、運用管理が非常に負担になります。

出力だけが必要で、仕組みは不要なら、ホスト型サービスが同じヘッドレスレンダリング→キャプチャ処理を内部で行い、運用負担を軽減します。私はまさにこの用途でsite2pdf.onlineを運営しています。各ページを本物のChromeでレンダリング(JSコンテンツも反映)し、サイト内部リンクを追跡して複数ページを一度にキャプチャし、PDF、PNG、ZIP形式でエクスポートできます。Chromiumのクラスタを自前で構築・運用する代替手段として便利です。

経験則:PDF生成が製品の中心機能である場合はPuppeteerをセルフホストして管理します。エッジの利便性機能である場合は外部サービスに委ねます。

まとめ

空白PDFの問題はほぼ常にタイミングの問題です。JavaScriptがレンダリングする前にキャプチャが行われています。本物のブラウザエンジンを使用し、ネットワーク(およびフォント)が安定するまで待ち、遅延コンテンツをトリガーするためにスクロールし、printBackgroundを有効にします。これで動的ページもきれいに変換できます。

この記事の完全版(修正済みヘッダーや複数ページ対応を含む)は、どこかにスニペットとして保存しておく価値があります。思った以上に頻繁に使うことになるでしょう。