Ruby on Railsでpdfを生成して出力するときの個人的ベストプラクティス
TL;DR
puppeteer.jsをRubyでラップしたのを使って、htmlからpdfを生成するのが使いやすかった。
こちらのサイトも参考になる。
はじめに
以前、Ruby on Railsでpdfを生成して出力するときのバットプラクティスまとめという記事を書きました。
その後も様々な手法を模索したところ、自分の中でのベストプラクティスが確立できたので、紹介します。
環境
Ruby 2.6系 + webpacker、 Rails 6系、EC2上のAmazon Linux 2にデプロイ。
前回の記事まとめ
結局、htmlからpdfに変更するのだったらWickedPdfを使うしかなさそうだけれど、flexboxも使えないし、メンテもあまりされていないので辛い。
今回の手法の概要
puppeteer.jsをうまく使い、htmlをheadless browser上で開き、pdfとして出力します。
chromeのrenderingエンジンが使える上、puppeteerは当面メンテが途切れることもなさそうなので、いい感じ!
こちらのサイトを大いに参考にしました。
実装
Rails側の実装
呼び出し方の例
PuppeteerPdf.new( ApplicationController.new.render_to_string( template: 'pdf_templates/path', layout: 'pdf_templates/path', locals: hash.with_indifferent_access # テンプレートに渡す変数 ) ).to_pdf(page_width: '297mm', page_height: '420mm')
このメソッドの返り値がpdfのバイナリなので、これをsend_data等すればpdfがダウンロードされます。
ラップクラスの実装
class PuppeteerPdf TMP_DIR = Rails.root.join('tmp') SCRIPT_PATH = Rails.root.join('app', 'javascript', 'packs', 'modules', 'generate_pdf.js') def initialize(str) @str = str @file_name = Time.current.strftime("%Y%m%d%H%M%S%N#{SecureRandom.alphanumeric}") end def to_pdf(**options) save_html save_pdf(options) end private def save_html File.open(html_path, 'w') do |f| f.puts @str end end # TODO: 必要に応じて、optionsを充実させる def save_pdf(**options) page_width = options[:page_width] page_height = options[:page_height] system( "node #{SCRIPT_PATH} #{Shellwords.escape(html_path)} #{Shellwords.escape(pdf_path)} \ #{page_width} #{page_height}" ) File.read(pdf_path) end def html_path "#{TMP_DIR.join(@file_name)}.html" end def pdf_path "#{TMP_DIR.join(@file_name)}.pdf" end end
これだけでは、テンプレートに画像などが渡せないので、以下のhelperも実装してあります。
module PuppeteerPdfHelper def img_full_path(asset_name) Rails.root.join('public').to_s + asset_pack_path(asset_name) end end
js側の実装
ほとんどがこちらのサイトからの引用です。
const puppeteer = require('puppeteer'); const createPdf = async () => { let browser; try { const browser = await puppeteer.launch({ args: ['--no-sandbox', '--disable-setuid-sandbox'], executablePath: process.env.CHROMIUM_PATH, }); const page = await browser.newPage(); await page.goto('File://' + process.argv[2], {timeout: 3000, waitUntil: 'networkidle2'}); await page.waitFor(250); await page.pdf({ path: process.argv[3], width: process.argv[4], height: process.argv[5], margin: {top: 36, right: 36, bottom: 20, left: 36}, printBackground: true, preferCSSPageSize: true, }); } catch (err) { console.log(err.message); } finally { if (browser) { browser.close(); } process.exit(); } }; createPdf();
課題
- たまたまwebpackerを使っていたのでnode.jsの導入について悩まなかったが、ない場合にはインストールする必要がある
- chromeのプロセスが立つので、そこそこ重たい
- chromiumのバージョンの管理が少し大変
- system callしているのが少し気持ち悪い
これらは、適当なAPIサーバーを作ったりすれば改善できるのかな、と考えています
おわりに
いくつか課題はありますが、現状だと不満な点や困った点はありません!
もし悩んでいる方がいれば、参考にしていただければ幸いです。