以前、Ruby on Rails な Webアプリで PDF を出力する際に、wkhtmltopdf / wicked_pdf という HTML を PDF 化するソリューションがよく使われていた。
wkhtmltopdf はさまざまな問題で 2023 年にアーカイブされてしまったが、個人的にとても好きなソリューションだったので、CSS3 Flexbox, Grid や後述する脆弱性の問題を解消したものを作って復活させた。
github.com
名前は、Second Generation な wkhtmltopdf なので、リスペクトを込めて sghtmltopdf とした。
ドキュメントサイトも用意してみた。詳しい仕様はこちらで。
waka.github.io
こういう HTML を そのまま PDF として出力できる。
請求書サンプル
HTTP サーバ経由で使える docker イメージと Rails アプリから使うのを想定した Rubygem を公開している。
Rails アプリでの使い方は、wicked_pdf と同じ。移行時に使い勝手を変えなくて済むようにしている。
wicked_pdf の良さでもある ActionView の DSL で PDF が出せることにこだわった。
gem " sghtmltopdf "
class InvoicesController < ApplicationController
def show
render pdf : " invoice " , template : " invoices/show " , layout : " pdf " , page_size : " A4 " , margin_top : " 20mm "
end
end
Sghtmltopdf .configure { |c| c.server_url = " http://{REMOTE_SERVER_URL}:8080 " }
class InvoicesController < ApplicationController
def show
render pdf : " invoice " ,
template : " invoices/show " ,
layout : " pdf " ,
page_size : " A4 " ,
margin_top : " 20mm " ,
server_url : " http://{REMOTE_SERVER_URL}:8080 "
end
end
wkhtmltopdf / wicked_pdf の思い出
Webアプリケーションで PDF を出力しないといけない場面は、請求書や領収書、レポートや一覧など割とよくある。
自分が最初に触れたのは前職で請求関連の機能から請求書を PDF ダウンロードするという機能で、それまでぶっちゃけ PDF に対してはあまり分かってないというのもあり、何となく怖いなという印象を持っていた。
そこで使われていたのが wkhtmltopdf という QtWebkit を組み込んだ HTML を PDF 化するソフトウェアで、自社の開発は Rails アプリだったので wicked_pdf という wkhtmltopdf のラッパー gem を通して PDF を生成していた。
wicked_pdf は wkhtmltopdf が公式で出しているものではなく、mileszs さんが個人で開発している Rubygem。Rails のレンダラー拡張を提供しているため、Rails View の DSL で PDF を出力できる。
gem " wkhtmltopdf-binary "
gem " wicked_pdf "
class InvoicesController < ApplicationController
def show
format.pdf do
render pdf : " invoice " , template : " invoices/show " , layout : " pdf " , page_size : " A4 " , margin_top : " 20mm "
end
end
end
wicked_pdf は show_as_html というオプションがあり、これを使うとなんと PDF ではなく HTML で出力してくれるのでブラウザ上で Developer Console からスタイルを確認しながら開発できる。
普段の Rails アプリの開発から特別なことを覚える必要なく PDF が作れることにとても感動した。
wkhtmltopdf + wicked_pdf のおかげで、すっかり PDF が怖くなくなったため、その後名著であるオライリーの PDF 構造解説 を読んで vim で PDF をある程度手書きできるようになったり、フロントエンドで PDF を描画する際によく使われる PDF.js で描画バグが起きた時もこの辺かなみたいなアタリがつけやすくなった。
しかしこの wkhtmltopdf は、QtWebkit が事実上開発終了してしまい、QtWebkit に依存していた wkhtmltopdf も 2023 年にアーカイブされてしまった。
2026 年 8 月現在は、SSRF の問題 が未解決のままになっているし、旧バージョンの QtWebkit の脆弱性もそのままなので、完全に信頼できる HTML しか扱ってはいけません。もしまだ使っていたら別のソリューションに乗り換えよう。
現状の PDF 出力ソリューション
現状 HTML を PDF 化する際のソリューションとしてよくあるものは、Headless Chrome に HTML を渡して PDF 出力させるやつでしょう。
Ruby / Rails だったら Ferrum というHeadless Chrome のラッパーgem があったり、Lambda などで Puppeteer 経由で Headless Chrome を操作するといった選択肢がある。
また、HTML からの変換でなく、テンプレートを専用デザイナーで作成して、アプリで値を埋め込んで出力するようなソリューションもある。
テンプレート系だと pdfme という TypeScript 製の OSS が個人的にめちゃめちゃ良く出来てると思っていてオススメ。日本語フォントに対応しているし、WebFont も読み込める。日本のエンジニアが作ってるので日本語サポートされる安心感があるという点も推したい。
やっぱり wkhtmltopdf が忘れられない
Puppeteer や pdfme もいいんだけど、やっぱり wkhtmltopdf / wicked_pdf で経験した アプリ開発の DSL のまま PDF を出力できる体験が忘れられない。
というわけで、今の技術でリバイスした wkhtmltopdf として、sghtmltopdf を作った。
wkhtmltopdf は QtWebkit を組み込んでいたけど、sghtmltopdf は PDF に特化するのでブラウザの機能一式は不要。HTML / CSS のパーサーと、PDF に特化したレンダリングエンジンがあればよい。
Servo プロジェクトが成果物を Rust crate として公開してくれているので、それを活用したレンダリングエンジンを作ることにした。
sghtmltopdfでは、HTMLパーサとして html5ever、CSSパーサとして cssparser、CSS セレクタのマッチングエンジンとして selectors crate を使っている。
Rust で作るので、Ruby からは必然的に FFI 経由で呼び出すことになる。Rust FFI するための rb-sys と magnus という crate が公開されているので、それを使っている。
rb-sys(というかmagnus) は採用事例も多そうなのと、コミュニティで運営されているので、メンテナンスは期待できそう。
昨今のセキュリティイシューの増加もあり precompiled gem で配布するようにしたのと、Rubygemsへの publish も Trusted publishing を使うようにしている。
precompiled gem に同梱している Rust のネイティブ拡張は 10MB もないので、サイズも小さい。
wkhtmltopdf の SSRF の問題も sghtmltopdf ではリモート取得をデフォルトで無効にしていて、 --allow-remote-assets オプションで有効化するようにしている。
機能的にただ同じ使い勝手のものをなぞるだけでは進化がないので、wkhtmltopdf や Headless Chrome での PDF 出力ソリューションを触ってきて以前から思っていた以下のような課題を解消させた。
wkhtmltopdf / Headless Chrome 共通で感じていた課題
サーバレス環境で動かす際、Lambda Layer や サイドカー構成を取る必要が出てきて環境構築が大変
巨大な HTML を渡すと、ブラウザプロセスで極端に処理時間 / メモリ消費が増えてサーバ台数が増える
wkhtmltopdf に感じていた課題
古い QtWebkit に依存していたため、CSS3 への対応が早期で止まっていた(Flexbox / Grid / カスタムプロパティ / マージンボックスといった現在メジャーなものとして使うプロパティが使えない)
Webフォントの読み込みを待たずに PDF が出力されることがある
テーブルの途中で改ページされると、ヘッダーを引き継ぐことができず帳票の見栄えが落ちる
CLI のみの提供で、HTTP サーバとして動かすことはできないため Lambda や ECS 等で PDF 出力用のマイクロサービスを用意することができない(自前で HTTP サーバから叩くラッパースクリプトを用意する必要がある)
CSS3 への対応や改ページ対応は、レイアウトエンジンの基礎を作った後はプロパティごとに物量で勝負。ゴールが見た目なので、E2E テストを定義しながら LLM の助けを借りて実現できた。
冒頭に載せた領収書サンプルは カスタムプロパティ / Flexbox や @page(マージンボックス)をがっつり使った CSS にしているので、wkhtmltopdf 時代に見送っていた人がいたらぜひ使ってみてほしい。
wkhtmltopdf はマージンボックスが使えずヘッダーを --header-html オプションとして渡すのだけど、別テンプレートとして扱われるので、CSS でヘッダーのレイアウトを指定することができない。なので結果を見ながら手動で調整しないといけなかった。
マージンボックスが使えるようになることで、CSS でヘッダー/コンテンツ/フッターのレイアウト管理ができるのと、副産物としてページ数を counter() 関数で自動セットできるようになる。
@page {
margin : 25mm 20mm ;
@top-center { content : string(chapter-title); }
@bottom-right { content : counter( page ) " / " counter( pages ) ; }
}
@page :first {
@top-center { content : none ; }
}
h1 { string-set: chapter-title content (); }
フッターの右側に自動でページ数を挿入したい場合、マージンボックスだとこういう風に書ける。
wkhtmltopdf だと、JavaScript で頑張って HTML に挿入するしかなかったのが不要に。
表の最中に改ページが起きたときにテーブルのヘッダーが引き継がれないのも、帳票がお客様にお渡しする成果物の場合見栄えがよくないのでどうにかしたい問題だったけど、これも PDF に特化したレンダリングエンジンを作ることで解消できた。これはブラウザの出力ではどうしようもできない課題だったので対応できて嬉しい。
Web フォントの読み込み対応や、ゴシック/明朝/モノそれぞれのフォントをオプションで指定できるようにしたり、細かな使い勝手もこだわった。
また、前々からやってみたかったこととして、sghtmltopdf では PDF のストリーミングでの書き出しを提供している。正確には通常の同期的な書き出しモードに加えて、オプションで HTML をチャンク単位で読みながらページ確定のタイミングで書き出してしまうモードを用意した。
文書全体を見ないと決まらない要素、例えば body タグ直下が巨大なテーブルタグで構成されている場合、テーブルタグの仕様上最後まで読み終わらないとレイアウトが決まらないのでストリーミングモードは使えないのだけど、body タグ直下が複数の div タグで構成されている場合、div タグごとをチャンクとして読み込んで書き出せるようになる。
書き出すタイミングが速くなることでチャンクをメモリから解放するタイミングも速くできるので、巨大な文書の構成によっては劇的に消費メモリ量を抑えることが可能になった。
こんな感じでめちゃめちゃデカい PDF を書き出しながら Rack のストリーミングレスポンスで返したり、S3 にマルチパートアップロードするような使い方を想定している。
class InvoicesController < ApplicationController
include ActionController ::Live
def show
response.headers[" Content-Type " ] = " application/pdf "
html = render_to_string(template : " invoices/show " , layout : " pdf " )
Sghtmltopdf .render(html) { |bytes| response.stream.write(bytes) }
ensure
response.stream.close
end
end
class InvoicesController < ApplicationController
def upload_pdf
parts, buffer = [], +"" .b
flush = lambda do
part = s3.upload_part(bucket : bucket, key : key, upload_id : upload.upload_id,
part_number : parts.size + 1 , body : buffer)
parts << {part_number : parts.size + 1 , etag : part.etag}
buffer.clear
end
Sghtmltopdf .render(html, server_url : server_url) do |bytes|
buffer << bytes
flush.call if buffer.bytesize >= 5 * 1024 * 1024
end
flush.call unless buffer.empty?
s3.complete_multipart_upload(bucket : bucket, key : key, upload_id : upload.upload_id, multipart_upload : {parts : parts})
render :ok
end
end
個人的には課題の中で特に帳票系で数万行ある大量の表を PDF 出力しないといけないユースケースでのパフォーマンスは前職でもかなり悩まされたので、「基本的に高速であること」「負荷分散しやすい」の2点に注力している。
sghtmltopdf は PDF に特化したレンダリングエンジンを持っているため描画ロジックをパフォーマンスチューニング出来ることが強みで、適用するスタイルオブジェクトの使いまわしやページツリーのメモリ解放のタイミングを早めたりすることで、既存ソリューションに対して大幅な省メモリ且つ高速化が叶った。
段落主体の文書と表主体の文書それぞれで、wkhtmltopdf と Headless Chrome と PDF 出力のパフォーマンスを比較した。
段落メインの文書:
要素数
sghtmltopdf
sghtmltopdf(ストリーミング)
wkhtmltopdf
ヘッドレスChrome
5,000
26MB / 0.11秒
9MB / 0.10秒
44MB / 0.49秒
543MB / 1.32秒
20,000
80MB / 0.46秒
14MB / 0.34秒
86MB / 2.60秒
943MB / 7.45秒
60,000
230MB / 1.99秒
25MB / 1.31秒
199MB / 42.02秒
1,525MB / 105.77秒
表メインの帳票:
行数
sghtmltopdf
sghtmltopdf(ストリーミング)
wkhtmltopdf
ヘッドレスChrome
5,000
49MB / 0.56秒
48MB / 0.60秒
62MB / 1.55秒
1,372MB / 5.12秒
20,000
173MB / 2.44秒
173MB / 2.36秒
163MB / 14.60秒
6,222MB / 39.94秒
狙い通り結構速度改善はできたかなと思う。ストリーミングモードで段落主体の文書を書き出すと消費メモリが大幅に削減されるのも想定通り。
20000 行の表を 2 秒前後で書き出せるので、請求書や領収書といったページ数が少ない帳票であれば、Rails アプリから直接 PDF を書き出してもほとんど詰まることはないんじゃないかと思う。
Webアプリと同じプロセスで書き出すのが不安であったり、Ruby / Rails 以外の環境で使う場合でも、HTTP サーバモード及び docker イメージを提供しているので、Lambda や ECS によるマイクロサービスが簡単に用意できるのでスケールアウトもしやすいはず。
帳票なので JS の実行はクリティカルな要件ではないだろうと思いファーストリリースでは入れてないけど、そのうち QuickJS あたりを組み込んで script タグを実行可能にするとかもやるかもしれない。
自分もそうだけど PDF ってたまに異常に関心が強い人がいたりするので、そのうち PDF developers 勉強会とかやって知見を共有しあいたい。