ぶちのブログ

競プロとCTFが趣味なWebエンジニアのアウトプットの場

Ruby on Railsでpdfを生成して出力するときの個人的ベストプラクティス

TL;DR

puppeteer.jsRubyでラップしたのを使って、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サーバーを作ったりすれば改善できるのかな、と考えています

おわりに

いくつか課題はありますが、現状だと不満な点や困った点はありません!
もし悩んでいる方がいれば、参考にしていただければ幸いです。