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 API | Express.js の REST サーバー |
| Scheduler Worker | Graphile Worker によるバックグラウンドジョブ処理 |
| Headless Browser | ヘッドレス Chromium コンテナ(スクリーンショット・PDF 生成) |
スクリーンショットの流れはこうなっている。
- スケジューラーワーカーがジョブをキューから取り出す
UnfurlServiceが Playwright 経由でヘッドレス Chromium に CDP(Chrome DevTools Protocol)接続する- ダッシュボードの minimal ページ(サイドバー等を除いたレンダリング専用ページ)を開く
- フロントエンドが全タイルのレンダリング完了を検知し、
#lightdash-ready-indicatorという hidden div を DOM に挿入する - Playwright が
page.waitForSelector('#lightdash-ready-indicator')でこの要素を検出する 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 が適用される。
やっていることを簡単に説明する。
- PDF バイナリから Page オブジェクトを探す(
/Type /Pageを持つオブジェクト) - 既存の MediaBox からページの高さを取得する
- クリッピング領域を px から pt に変換する(PDF の座標系は 72pt/inch、ブラウザは 96px/inch)
- 新しい MediaBox を計算する(PDF の座標系は左下原点で Y 軸が上向き)
- 変更した 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」フローへの組み込みに絞って整理された。createImagePdf(pdf-lib で画像を埋め込むメソッド)を createBrowserPdf(page.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 フォーマットを追加するもの。こちらはレビュー待ち。