Rails用PDF生成ライブラリ「wicked_pdf」のソースを読んでみる


興味50%、仕事50%でRailsでPDFを出力したいことがあり、「rails pdf」でググると最初にヒットする wicked_pdf のソースコードを読んでみた。

まとめ

  • PDF生成は wkhtmltopdf に全て委譲している。
  • Controllerの #render_to_string でHTMLのStringを生成、保存。この結果を wkhtmltopdf に渡してPDF生成。最後にこの結果を #send_data で送信。
  • css/js/画像などの静的ファイルは、そのままでは参照できないため一工夫している。
  • HTMLやPDFをいったんメモリ上に全て読み込む点は注意。文書のサイズが大きいとメモリ使用量が多くなってしまう可能性がありそう。
  • コード量も多くなく、読みやすかった。ただ、Ruby 1.8.7、Rails 2.3をサポートしており、このせいで若干コードが汚くなっている気はした。
  • 完全に理解するには wkhtmltopdf コマンドで何ができるかを確認する必要がある。

詳細

ブログ投稿時点で最新のリリースバージョンである 1.1.0 を対象に主要なコードを一通り読んでみる。
https://github.com/mileszs/wicked_pdf

ライブラリの主要なファイルはこんな感じ。

初期化時の処理

スタート地点は lib/wicked_pdf.rb から。このファイルでは wicked_pdf が必要とするライブラリのロード、そしていくつかのClass、Moduleの定義を行なっている。
その中でも重要だと思ったのは、27行目の wicked_pdf/railtie をrequireしているところ。

wicked_pdf/railtie は何をしているかというと、Rails 5系のみで抽出するとこうなる。

Railsの初期化処理において以下を実施している模様。

  • ActionController::BaseWickedPdf::PdfHelper をprependしてごにょごにょする。
  • ActionView::BaseWickedPdf::WickedPdfHelper::Assets をincludeすることで、view内で独自のメソッドを使えるようにしている。

PdfHelper はprependedメソッドを定義しているため、単純なprepend以上のことが実行されている。

  • after_actionで #clear_temp_files を実行するように設定。
  • #render#render_to_string をそれぞれ alias_method_chain する。

前者は名前から何をするか容易に推測可能。
後者に関しては、PDFを生成する場合、このライブラリでごにょごにょして、それ以外の場合 ActionController::Base にお任せすると推測される。prependを使っているのになぜalias_method_chainのようなことをしているのかわからなかったが、prependが使えないコードと共通化するためにやっていると推測。

ファイルの大半を占める WickedPdf クラスも読みたいが、これはこの後で読むはずなので一旦保留。

Generator

wicked_pdfにはGeneratorが定義されており、 bin/rails g wicked_pdf で実行可能。実行すると config/initializers/wicked_pdf.rb が生成され、WickedPdfのデフォルト設定をすることができるようになっている。

生成されたファイルには :exe_path:layout しか説明が書かれていないが、PDF生成時ではこのconfigで指定されたHashをベースにoptionsを生成するので、PDF生成時に指定可能なオプションは全て指定可能なはず。

Controllerでの処理

wicked_pdfを使ったPDF生成は非常に簡単で自分のControllerの中で render pdf: "ファイル名" を呼び出すだけでPDFの生成とファイル送信までやってくれる。HTMLとPDFで指定されたフォーマットで生成方法を分ける場合、こんな感じになると思われる。

#render が何をやっているかを追ってみる。

WickedPdf::PdfHelper#render_with_wicked_pdf

ライブラリのロードでみた通り WickedPdf::PdfHelper#render_with_wicked_pdf が呼ばれることになる。

options がHashで、かつ :pdf というキーがあった場合のみ、wicked_pdfのコードが継続して実行される。それ以外の場合は #render_without_wicked_pdf に委譲される。これは、 ActionController::Base#render なのでControllerで通常の #render を呼び出した時と同じ処理になる。

PDF生成の場合、最終的に #make_and_send_pdf を呼んでいる。
第1引数はPDFファイル名、第2引数でデフォルトのオプションとマージされたHashが渡されている。
この時 options から :pdf というキーが取り除かれている。そのため、再度 #render を呼んだ場合はHTML生成になる。

WickedPdf::PdfHelper#make_and_send_pdf

#make_and_send_pdf の処理は以下の流れで実施されている。

  1. #make_pdf を呼び、返り値としてPDFのバイナリデータを受け取る。
  2. オプションで指定されている場合、PDFファイルを指定されたパスに保存する。
  3. #send_data を使用してバイナリデータをファイルとしてダウンロードできるように返却する。

WickedPdf::PdfHelper#make_pdf

  1. #render_to_string を呼びPDF本文のHTMLをStringとして保存する。( #render_to_string#render_to_string_with_wicked_pdf になっている)
  2. #prerender_header_and_footer を呼び、ヘッダー/フッターが指定されている場合、これらのHTMLも生成する。こいつらは一時ファイルとして生成し、file:形式のURLをoptionsにセットする。
  3. WickedPdf#pdf_from_string で本文のHTMLとオプションからPDFを生成。返り値として、バイナリのPDFが返される。

HTMLをStringでメモリに保持するため、HTMLが大きいとメモリの消費量が多くなるかもしれない。
HTMLだけであれば大したサイズにならないと思うが、後述のJSや画像をBase64でHTMLに組み込む場合、注意が必要か。

PDF生成は WickedPdf に全て委譲されている。次は、このクラスの中身をみてみる。

PDF生成

PDF生成を担当する WickedPdf クラスを見てみる。

初期化

初期化のコードは以下。以下の2つのことを実施している。

  1. wkhtmltopdf コマンドのパスの決定。renderのオプション wkhtmltopdf を指定した場合、そちらが使用される。次は、initializerで :exe_path。この2つで見つからないとコマンドの探索を始めるが、これは信用度が低いし無駄な処理だと思うので、基本initializerで :exe_path を指定しておいた方がよさそう。
  2. wkhtmltopdf コマンドのバージョンを取得。

PDF生成

WickedPdf#pdf_from_string が呼ばれることで実行される。処理の概要は以下。

  1. StringのHTMLを一時ファイルに保存
  2. optionsを wkhtmltopdf コマンドに渡すオプションに変換
  3. Open3.popen3 を使用して wkthmltopdf コマンドの実行
  4. 生成されたPDFを読み込み、バイナリデータで返す

HTML同様、PDFもいったんメモリに全て読み込む点に注意が必要か。
OSコマンドインジェクションへの対策は、Open3ライブラリで対応できているのだろうか?ここは、Open3ライブラリを勉強するときに確認したい。

残りのコードの大半は #parse_options 以下のオプション解析コードだったので、ここは省略。これで、PDF生成の流れが一通り確認できた。

静的ファイルの扱い

通常、production環境における静的ファイルは <link rel="stylesheet" media="all" href="/assets/application-xxxx.css"> のようなタグに変換される。
Assetsのprecompleの結果、public/assets/ の下に上記のファイルが生成されており、ブラウザからみる分には問題なく静的ファイルが取得が可能で、表示も問題なくできる。
しかし、 wkhtmltopdf の場合、このパスの静的ファイルがないためエラーになってしまうと思われる (おそらく file:////assets/application-xxxx.css で探しに行く?)。

この問題を解決するために特別なhelperをviewで使えるようにしている。
このhelperはAsset Pipelineのあり/なしで分かれている模様。(assets:recompile の有無と考えて良いか?)

Asset Pipelineなし

css, js, 画像それぞれに専用のhelperメソッドがある。
ざっくり内部を確認した限り、以下の処理を実施している。

  • cssは <style type='text/css>...</style> に展開して埋め込むようにしている。
  • css内のurl属性、js、そして画像は、 http:// のようなURI形式の場合そのままで、 パス形式であれば file:/// をつけて参照できるようにしている。

Asset Pipelineあり

Asset Pipelineありの場合シンプルで、 <%= stylesheet_link_tag wicked_pdf_asset_base64("pdf") %> のような形で、Railsのhelperに #wicked_pdf_asset_base64 で変換した結果を渡すようにすればOK。

やっている内容は想像通り対象の静的ファイルをBase64化して、それをdata_uri形式のStringにして返している。
毎回リクエストのたびにBase64を計算しているのは、CPU負荷が高くなるかもしれない。特に、ファイルサイズの大きな画像を参照している場合、要注意か。

感想

  • コード量がそれほど多くなく、最終的なコードが wkhtmltopdf コマンドを呼び出すところに集約されているため、読みやすかった。
  • Ruby 1.8.7、Rails 2.3への対応をするため、コードがやや汚くなっている印象。次のメジャーバージョンアップでは、Rubyは2.2以上、Rails 4.2以上にして、コードを整理した方がよさそう。
  • 静的ファイルの扱いはNGなケースがまだありそう。Base64形式は確実そうだけど、性能面で不安(Rails側でViewをキャッシュしてくれればまだいいんだけど、毎回Base64を計算するのは無駄が多そう)
  • wkhtmltopdf コマンドを理解しないとどういうPDFが生成されるのか、結局わからない。ということで、次はこのコマンドを調査します。