📁

RailsのPDF生成をwicked_pdfからferrumに移行する

2024/05/04に公開

wicked_pdfで実装されていたPDF出力機能をferrumに書き換えるという作業をしたので思い出しつつメモ
サンプルアプリはこちら

移行の理由

RailsでPDF生成する方法を調べるといまだ上位に出てきたりするwicked_pdfですが、
PDF生成に使っているwkhtmltopdfがブラウザエンジン(QtWebKit)の更新停滞に伴って更新されなくなり、アーカイブされています。
また、wkhtmltopdfのバイナリをインストールするためのgemであるwkhtmltopdf-binaryも更新が滞っているので環境によっては他の方法でインストールするなりする必要があります。
今後のメンテナンスコストや古いQtWebKitを使い続けるセキュリティリスクを考えて他の手段に乗り換えることなりました。

移行先の選定

既存のviewファイルを再利用するためにHTML2PDF的なものであることはマストだったので、ヘッドレスChromeでレンダリングする方向で考えることにしました。

ヘッドレスChromeのクライアントライブラリは色々ありますが、Rubyだと直接Chromeを操作するFerrumか、PuppeteerのラッパーであるGroverの二択になるかなと思います。

Groverの方がリリースが早かったためか日本語圏の情報が多かったり、社内で利用実績があったりしたのでGroverにしようかと考えていたのですが、
触ってみるとFerrumの方がPuppeteerのようなAPIでChromeDevToolsProtocolとの対応がわかりやすく感じたのと、Node.jsランタイムが不要で面倒を見なければならないものが減るのでFerrumを使うことにしました。

既存のPDF出力機能

def wicked_pdf
  @users = DummyUser.all
  render pdf: 'WickedPdfサンプル', template: 'pdf/index', layout: 'pdf'
end

上のようなactionがあり、

app/views/layouts/pdf.html.erb
<!DOCTYPE html>
<html>
  <head>
    <title>FerrumSample</title>
    <meta charset="UTF-8">
    <%= wicked_pdf_stylesheet_link_tag "pdf" %>
  </head>

  <body>
    <%= yield %>
  </body>
</html>
pdf.css
/*
*= require destyle.css
*/

table {
  width: 100%;
  border-collapse: collapse;
  border: 1px solid #ddd;
}

th, td {
  padding: 8px;
  text-align: left;
  border: 0;
  border-bottom: 1px solid #ddd;
}

@media screen {
  tr:nth-child(odd) {
    background-color: #f2f2f2;
  }
}

app/views/pdf/index.html.erb
<table>
  <thead>
    <tr>
      <th>Name</th>
      <th>ID</th>
      <th>Email</th>
    </tr>
  </thead>
  <tbody>
    <% @users.each do |user| %>
      <tr>
        <td><%= user.name %></td>
        <td><%= user.id %></td>
        <td><%= user.email %></td>
      </tr>
    <% end %>
  </tbody>
</table>

これらのview+cssをwicked_pdfを使って以下のようなPDFとして出力できています。

これと同等のPDFをferrumで出力することを目的にしていきます。

ferrum導入

まずchromiumをインストールしておきます。

# Install packages needed to build gems
RUN apt-get update -qq && \
    apt-get install --no-install-recommends -y [] chromium

Gemfileにferrumを追加してbundle install

Gemfile
gem 'wkhtmltopdf-binary-ng'
+ gem 'ferrum'

ferrumでPDF出力するアクションを実装

wicked_pdfに寄せる調整も込みでの実装が以下になります。

def ferrum
  @users = DummyUser.all
  pdf = generate_pdf(template: 'pdf/index')
  send_data pdf, filename: "Ferrumサンプル.pdf", type: "application/pdf", disposition: "inline"
end

private

def generate_pdf(layout: 'pdf', template: nil, **pdf_options)
  # viewファイルをHTMLに変換
  html_content = render_to_string(layout:, template:, formats: :html)
  # HTMLをPDFに変換
  html_to_pdf(html_content, **pdf_options)
end

def html_to_pdf(html_content, format: :A4, encoding: :binary, scale: 1.0, print_background: true)
  browser = Ferrum::Browser.new(browser_options: { "no-sandbox": nil }, js_errors: true)
  page = browser.create_page

  # Ferrum::PageにHTMLをセット
  page.content = html_content

  # メディアタイプをscreenに設定
  page.command("Emulation.setEmulatedMedia", media: "screen")

  # PDFを生成
  page.pdf(
    format:,
    encoding:,
    scale: scale * 0.8,
    print_background:,
  )
ensure
  browser&.quit
end

後述しますがメディアタイプ周りの調整のためにCSSも一部修正しました

viewファイルはそのままです。viewでwicked_pdf_stylesheet_link_tagというwicked_pdf用helperを使っていますが、これに限らずwicked_pdfのviewヘルパーはアセットをインライン化するだけのものなので現状ちゃんと動いているなら移植したり流用したりしていいんじゃないかと思ってます。

出力はこのようになります。

大体同じにはできているかなと思います。

スタイルの調整について

ferrumで特に指定せず出力すると以下のようにスタイルが崩れるので調整します。
ここでは今回移行する上で対応した部分について書いていきます。

文字スケール

まずページ数が変わるレベルで文字サイズが大きくなることですが、
wicked_pdfの文字スケールはferrumの約0.8倍[1]なのでのでPDF生成時に調整します。

  # PDFを生成
  page.pdf(
    format:,
    encoding:,
    scale: scale * 0.8, # ここ
    print_background: true
  )

メディアタイプ

次にPDF生成時のメディアタイプはwicked_pdfではデフォルトでscreenとなっていて、ferrumではprintになります。そのためferrumではapp/assets/stylesheets/pdf.cssの背景色が効いていません。

CSSをうまく調整できるならそれに越したことはないですがフレームワーク内の定義とかだとしんどいと思います。
そこで、メディアタイプをscreenにエミュレートします。

page.command("Emulation.setEmulatedMedia", media: "screen")

これで解決という着地をさせようとしていたんですが、screenにエミュレートするとページを跨いだtableのヘッダーが複製されないことに記事を書きながら気がつきました...。

https://github.com/chromium/chromium/blob/326c700331fce74d87ebb1091ecd0f1ae7eea5d4/third_party/blink/renderer/core/html/resources/html.css#L1517-L1530
chromiumのuser agent stylesheetによるものなので、screenでもprint向けスタイルが当たるように追加しました

app/assets/stylesheets/pdf.css
/* 追加 */
@page {
/* FIXME: Define the right default values for page properties. */
size: auto;

padding: 0px;
border-width: 0px;
}

/* Allows table headers and footers to print at the top / bottom of each
 page. */
thead { break-inside: avoid; }
tfoot { break-inside: avoid; }

フォント

特にサンプルアプリでは扱ってませんが、QtWebKitとChromeではフォントの優先度の決め方が異なるようで、書体のみの指定だとフォントが変わることがあります。フォントまで指定した方が良いです。

参考

https://speakerdeck.com/morimorihoge/20220125-ling-he-ban-railsapuridepdfsheng-cheng-surutekunitukuji-in-yin-zuo-rails-number-41

https://blog.willnet.in/entry/2023/02/10/233053

脚注
  1. CMオペレーターの総積から計算しています。他の環境やサイズでも比は同じだったので変わらないはず...多分 ↩︎

Discussion