ota2000
10 min read

Lightdash の PDF 配信を改善した

業務で Lightdash を使っている。Lightdash は dbt プロジェクトに接続してセルフサービス分析を実現するオープンソースの BI ツール。

Scheduled Delivery という機能があり、ダッシュボードやチャートを定期的に Slack やメールに配信できる。この機能で生成される PDF の品質に不満があったので、改善するコードを書いた。0.2673.0 でリリースされ、コントリビューターにも追加してもらった。

何が問題だったか

Scheduled Delivery には CSV、XLSX、Image、Google Sheets の4つのフォーマットがある。Image フォーマットを選ぶと「Also include image as PDF attachment」というオプションが出て、画像に加えて PDF も添付できる。

この PDF の生成方法に問題があった。pdf-lib というライブラリを使って、スクリーンショットの PNG 画像をそのまま PDF に埋め込んでいた。

const pdfDoc = await PDFDocument.create();
const pngImage = await pdfDoc.embedPng(buffer);
const page = pdfDoc.addPage([pngImage.width, pngImage.height]);
page.drawImage(pngImage);
const pdfBytes = await pdfDoc.save();

これだと以下の問題がある。

  • テキストが選択できない(ラスター画像なので)
  • リンクがクリックできない
  • 拡大するとぼやける(ベクターではなくピクセルデータ)
  • 検索ができない
  • CJK フォントが正しくレンダリングされない(日本語が崩れる)
  • スクリーンショットの解像度に依存するので粗い

画像を PDF コンテナで包んだだけなので、PDF である利点がほぼない。メールで受け取った人がダッシュボードの数値をコピーしたり、チャートのリンクを辿ったりできない。日本語環境ではフォントの問題もあって、そもそも読みづらかった。

既存のスクリーンショット生成の構成

コードを読んで、スクリーンショットがどう撮られているか調べた。

Lightdash のアーキテクチャでは、バックエンド API、スケジューラーワーカー、ヘッドレスブラウザがそれぞれ別のコンテナとして動いている。

サービス役割
Backend APIExpress.js の REST サーバー
Scheduler WorkerGraphile Worker によるバックグラウンドジョブ処理
Headless Browserヘッドレス Chromium コンテナ(スクリーンショット・PDF 生成)

スクリーンショットの流れはこうなっている。

  1. スケジューラーワーカーがジョブをキューから取り出す
  2. UnfurlService が Playwright 経由でヘッドレス Chromium に CDP(Chrome DevTools Protocol)接続する
  3. ダッシュボードの minimal ページ(サイドバー等を除いたレンダリング専用ページ)を開く
  4. フロントエンドが全タイルのレンダリング完了を検知し、#lightdash-ready-indicator という hidden div を DOM に挿入する
  5. Playwright が page.waitForSelector('#lightdash-ready-indicator') でこの要素を検出する
  6. page.screenshot() でキャプチャする

ここで気づくのは、Playwright とヘッドレス Chromium の接続が既にあること。page.screenshot() の代わりに page.pdf() を呼べば、同じブラウザセッションから PDF を生成できる。

なぜ page.pdf() か

PDF 生成にはいろいろなアプローチがある。サーバーサイドで HTML を PDF に変換するライブラリ(Puppeteer、wkhtmltopdf、WeasyPrint など)を使う方法もある。Playwright の page.pdf() がベストだとは限らない。

ただ、今回のケースでは Playwright が最も合理的だった。

  • ヘッドレス Chromium のサイドカーコンテナが既に動いている
  • Playwright の CDP 接続が既にある
  • スクリーンショットと同じブラウザセッションを使える
  • 新しいライブラリやインフラが不要
  • むしろ pdf-lib を削除できる(依存が減る)

page.pdf() は Chromium の印刷機能を使う。ブラウザがレンダリングした DOM がそのまま PDF になる。テキスト選択やリンクが効き、ベクター描画なので拡大しても潰れない。

page.pdf() のクリッピング問題

page.pdf() を呼ぶだけなら簡単だが、1つ問題がある。

page.screenshot() には clip オプションがあり、ページの特定領域だけをキャプチャできる。Lightdash ではダッシュボードのコンテンツ領域(グリッドコンテナ)だけを切り出すのに使われている。

// screenshot の場合 — clip で領域指定できる
imageBuffer = await page.locator(finalSelector).screenshot({
    path,
    animations: 'disabled',
    timeout: RESPONSE_TIMEOUT_MS,
});

page.pdf() にはこの clip オプションがない。何もしないとページ全体が PDF になり、ヘッダーや余白が含まれてしまう。ダッシュボードのコンテンツ領域だけを切り出すには別の方法が必要だった。

コンテンツサイズの測定

まず page.evaluate() でブラウザ内の DOM を調べ、コンテンツの実際の高さを測定する。ダッシュボードのグリッドコンテナは CSS 上の高さがコンテンツより大きいことがあるため、子要素の getBoundingClientRect() を1つずつ調べて最大の bottom を取る。

const contentBottom = await page.evaluate((sel: string) => {
    const container = document.querySelector(sel);
    if (!container) return 800;
    let maxBottom = 0;
    for (const child of Array.from(container.children)) {
        const rect = child.getBoundingClientRect();
        if (rect.height > 0 && rect.bottom > maxBottom)
            maxBottom = rect.bottom;
    }
    return Math.ceil(maxBottom || 800);
}, finalSelector);

PDF 生成

測定したサイズを page.pdf()width / height に渡す。margin を全て 0 にし、pageRanges: '1' で1ページだけ出力する。printBackground: true でダッシュボードの背景色も含める。

const pdfBuffer = await page.pdf({
    width: `${clip.width}px`,
    height: `${contentBottom + 10}px`,
    printBackground: true,
    pageRanges: '1',
    margin: { top: 0, right: 0, bottom: 0, left: 0 },
});

PDF の MediaBox 書き換え

ページサイズをコンテンツに合わせても、Chromium の PDF レンダリングで微妙な余白が残る。そこで PDF のバイナリを直接操作し、MediaBox(表示領域を定義する矩形)を書き換える。

return cropPdfToClip(Buffer.from(pdfBuffer), clip);

cropPdfToClip は PDF の incremental update(PDF spec §7.5.6)を使っている。これは PDF ファイルの末尾に変更を追記する仕組みで、元のバイト列を一切変更しない。PDF ビューアは末尾の更新を優先的に読むので、追記した MediaBox が適用される。

やっていることを簡単に説明する。

  1. PDF バイナリから Page オブジェクトを探す(/Type /Page を持つオブジェクト)
  2. 既存の MediaBox からページの高さを取得する
  3. クリッピング領域を px から pt に変換する(PDF の座標系は 72pt/inch、ブラウザは 96px/inch)
  4. 新しい MediaBox を計算する(PDF の座標系は左下原点で Y 軸が上向き)
  5. 変更した Page オブジェクトと新しい xref テーブルを PDF の末尾に追記する

pdf-lib のようなライブラリを使わず、バッファ操作だけで実現している。これにより新しい依存を追加する必要がなく、結果として pdf-lib 自体も不要になり削除できた。

PR の経緯

最初の PR(#21143)では、PDF 品質改善に加えて PDF 単体配信(画像なしで PDF だけ送る新しいフォーマット)も一緒に出していた。スケジューラーの enum 追加、フロントエンドのフォーマット選択 UI、各通知チャネルの対応と、スコープが大きかった。

メンテナーからフィードバックをもらった。

I think we can replace our pdf generation on our “image” export with your new method, so we can avoid this new “delivery” method entirely. This would simplify this PR

つまり、まず既存の「Image + PDF」フローの PDF 品質を改善するだけにして、PDF 単体配信は別 PR にしたいということ。変更を小さく段階的に入れる方針。

メンテナーが自分のブランチをベースに #21438 を作った。page.pdf()cropPdfToClip のコードはそのまま採用され、既存の「Image + PDF」フローへの組み込みに絞って整理された。createImagePdfpdf-lib で画像を埋め込むメソッド)を createBrowserPdfpage.pdf() でネイティブ PDF を生成するメソッド)に置き換え、pdf-lib の依存を削除する内容。

Based on work by @OTA2000 in #21143 — thank you for the contribution! 🙏

#21438 は 0.2673.0 でリリースされた。自分の PR はクローズしたが、コア部分のコードが採用されてリリースまで到達した。

振り返り

最初の PR はスコープが大きすぎた。PDF 品質改善と PDF 単体配信を1つの PR に入れてしまい、メンテナーに分割してもらう形になった。分割後は品質改善の部分が数日でマージ・リリースされたので、最初から小さく切って出せばよかった。

自分の PR がクローズされたときは正直がっかりした。ただ、コードは使われているしリリースもされている。PR の番号が自分のものかどうかより、書いたコードがプロダクトに入ったかどうかのほうが大事だと思い直した。

PDF 単体配信

品質改善とは別に、画像なしの PDF だけを送る機能も #21457 で出している。#21438 で入った page.pdf() のインフラを使い、新しい SchedulerFormat.PDF フォーマットを追加するもの。こちらはレビュー待ち。