Ruby / Rails
gem sghtmltopdfの使い方です。
エンジン本体はRust製で、ネイティブ拡張(magnus + rb-sys)経由で同じプロセスの中で動きます。
外部プロセスの起動も一時ファイルの受け渡しもありません。
変換オプションはCLIとまったく同じものが使えるので、オプションの意味はCLIリファレンスを参照してください。 このページはRuby側の作法(命名規則・Rails連携・エラー・サーバ委譲)を扱います。
wicked_pdfから移ってくる場合はwicked_pdfからの移行もあわせて読んでください。
インストール
# Gemfile
gem "sghtmltopdf"
ビルド済み(precompiled)のgemを配布するため、Rustのツールチェインは不要です。
| 対応 | |
|---|---|
| プラットフォーム | x86_64-linux / aarch64-linux / x86_64-linux-musl / aarch64-linux-musl / arm64-darwin / x86_64-darwin |
| Ruby | 3.2以上 |
Linuxはglibc(Debian/Ubuntu系)とmusl(Alpine)の両方があり、gem installが環境に合うほうを選びます。
Windowsは対象外で、この環境ではインストールできません。
サーバへ委譲するという手があります。
基本
pdf = Sghtmltopdf.render("<h1>請求書</h1>") # → PDFのバイト列(String)
Sghtmltopdf.render_to_file(html, "invoice.pdf") # → ファイルへ書き出す(nil)
- 返り値のエンコーディングはASCII-8BIT(バイナリ)です
- 入力のHTMLはバイト列としてそのまま渡ります。文字コードの判定はエンジン側で
BOM >
encoding:><meta charset>> UTF-8の順に行われるので、UTF-8のStringならそのまま渡せます。 Shift_JISなどを渡す場合はencoding: "Shift_JIS"を明示してください render_to_fileは一時ファイルへ書いてからrenameするので、途中で失敗しても壊れたPDFが残りません- 重い処理(レイアウト・PDFエンコード)の間はGVLを解放するので、Pumaの他のスレッドは止まりません。複数スレッドから同時に呼べます
オプション
CLIのロングオプションから--を取り、-を_にした名前をキーにします。
値の解釈もCLIと同一です(同じパーサへ通しているため)。
Sghtmltopdf.render(html, page_size: "A4", margin_top: "20mm", toc: true)
# --page-size A4 --margin-top 20mm --toc
| 値の書き方 | 意味 |
|---|---|
page_size: "A4" | 値を取るオプション |
grayscale: true | 値を取らないフラグ |
grayscale: false / nil | 指定なしと同じ |
allow_path: ["/a", "/b"] | 同じオプションの繰り返し |
font: {path: "a.ttc", index: 1} | --font a.ttc --font-index 1(順序も保つ) |
キー名の妥当性はRuby側では検査しません。
オプションの定義をRust側の1か所に集約しているため、未知のキーはエンジン側がUsageErrorとして報告します。
wicked_pdfのような入れ子のHash(margin: {top: 10})は受け付けません。
wicked_pdf/wkhtmltopdfの数値はmm、こちらのCLIはpx解釈なので、機械的に平坦化すると黙って別の余白になるためです。
margin_top: "10mm"と書いてください。
HTML文字列はheader_html_content: / footer_html_content:で直接渡せます
(CLI: --header-html-content / --footer-html-content)。一時ファイルは不要です。
既存のheader_html: / footer_html:は引き続きファイルパスを受け付けます。
同じ側のパスと文字列を同時に指定するとエラーになります。
プレースホルダ・画像・余白の扱いはファイル指定と同じです。
HTTPサーバでも使用できますが、HTMLはクエリ文字列に含まれるためURLの長さ制限とアクセスログに注意してください。
Ruby側だけのオプション
CLIには無い、gemが解釈するキーです。
| キー | 既定 | 説明 |
|---|---|---|
server_url | なし | 指定するとHTTPサーバモードへ委譲する |
server_open_timeout | 5 | 接続のタイムアウト(秒) |
server_read_timeout | 120 | 応答のタイムアウト(秒) |
chunk_size | 65536 | ブロック付きrenderで1回に渡すバイト数の目安(ローカル変換のみ) |
Railsのレンダラ(render pdf:)では、これに加えてdisposition・filename・status・show_as_htmlを解釈します。
グローバル設定
# config/initializers/sghtmltopdf.rb など
Sghtmltopdf.configure do |c|
c.page_size = "A4"
c.gothic_font = Rails.root.join("vendor/fonts/NotoSansJP-Regular.ttf")
end
マージ順はグローバル設定 → 呼び出し時の引数で、後者が勝ちます。
Sghtmltopdf.reset_config!で空に戻せます(主にテスト用)。
フォント
指定しなければシステムのフォントが使われるため、出力が実行環境に依存します。 コンテナのフォント事情に左右されたくない場合は明示してください。
Sghtmltopdf.configure do |c|
c.gothic_font = "/app/vendor/fonts/NotoSansJP-Regular.ttf" # sans-serif
c.serif_font = "/app/vendor/fonts/NotoSerifJP-Regular.ttf" # serif
c.mono_font = "/app/vendor/fonts/NotoSansMono-Regular.ttf"
end
font系で渡すフォントは、後述のallow_path(ローカル参照の制限)の対象外です。
エラー
すべてSghtmltopdf::Error < StandardErrorを継承します。
メッセージはCLIと同じ文言です。
| クラス | 起きるとき |
|---|---|
Sghtmltopdf::UsageError | オプションの誤り(未知のキー、値の形式、非対応オプション) |
Sghtmltopdf::InputError | 入力や出力ファイルの読み書きに失敗した |
Sghtmltopdf::RenderError | レンダリングに失敗した |
Sghtmltopdf::TimeoutError | 制限時間を超えて打ち切られた |
Sghtmltopdf::InternalError | エンジン内部の想定外の失敗(バグ) |
Sghtmltopdf::ServerError | サーバへ委譲したときの到達不能・過負荷 |
InternalErrorはネイティブ拡張の中でRustがパニックしたときに上がります。
拡張側で捕まえて通常の例外へ変換しているため、他のエラーと同じようにrescueでき、ワーカープロセスは動き続けます。
これが出た場合はエンジンの不具合なので、再現するHTMLを添えて報告してください。
画像やCSSの取得失敗は既定では無視され、警告を出して続行します(load_media_error_handling: "abort"で中断できます)。
Railsで使う
Railsが読み込まれているときだけRailtieが読み込まれるので、素のRuby・Sinatraでの利用には影響しません。
レンダラ
class InvoicesController < ApplicationController
def show
render pdf: "invoice", # ファイル名(.pdfは自動で付く)
template: "invoices/show",
layout: "pdf",
page_size: "A4", margin_top: "20mm"
end
end
オプションは3つに振り分けられます。
| 種類 | キー |
|---|---|
| ビューの描画へ渡す | template partial inline file plain html body layout locals formats variants handlers prefixes object collection assigns action |
| レスポンスの組み立て | disposition(既定inline) filename status |
| デバッグ | show_as_html(PDFにせずHTMLを返す) |
| 上記以外すべて | 変換オプション |
pdf:の値が空ならアクション名がファイル名になります。
filename:があればそちらが勝ち、.pdfは二重に付きません。
アセットのパス解決
PDFのレンダリングはHTTPサーバを介さないため、/assets/…のようなURLは
ローカルファイルとして解決されます。
Railtieが次の既定値を入れます。
| キー | 既定 | 意味 |
|---|---|---|
base_url | Rails.root/public | 絶対パス参照の基準。precompile済みなら素のstylesheet_link_tagがそのまま動く |
allow_path | public/とアセットパイプラインのロードパス | ローカル参照をアプリのアセット配下に限定する |
どちらもSghtmltopdf.configureで上書きできます(イニシャライザの実行順に依存しません)。
allow_pathの既定はテンプレートにユーザー入力が混ざっても文書外のファイルを読ませないためのものなので、アプリの外(例: /usr/share/fonts)を参照している場合は明示的に足してください。
内訳はpublic/とconfig.assets.paths(Propshaft/Sprocketsのロードパス)で、gemやengineが提供するアセットのようにRails.rootの外にあるものも含みます。
config/やdb/は入らないので、Rails.root.join("tmp/chart.png")のようにアセット以外の場所を参照している場合も足してください。
開発環境のようにアセットがまだpublic/へ書き出されていない場合のために、アセットの中身を文書へ埋め込むヘルパがあります。
<%= sghtmltopdf_stylesheet_link_tag "pdf" %>
<%= sghtmltopdf_image_tag "logo.png" %>
<%= sghtmltopdf_asset_path "logo.png" %> <%# 見つからなければnil %>
どちらもアセットの実ファイルをパイプラインのロードパスから引き当てます。
開発環境で素のimage_tagが使えないのはこのためです。
image_tagが出す/assets/logo-abc123.pngはダイジェストもマウント位置もパイプラインが実行時に作る仮想的なもので、対応するファイルはディスク上にありません。
sghtmltopdf_stylesheet_link_tagはCSSの中身を<style>へ展開します。
このとき中身をそのまま写すのではなく、url()の参照先をエンジンが読める形へ指し直し、@importを再帰的に取り込みます。
指し直しが必要なのは、アセットパイプラインがprecompile時にurl()をasset_pathで書き換えるためです。
asset_hostを設定していればHTTPSの絶対URLになり(Sprocketsの場合)、そうでなければダイジェスト付きの/assets/…になります。
PDFのレンダリングはHTTPサーバを介さないので、どちらもそのままでは取得できません。
@font-faceの取得に失敗してもfont-familyの次の候補には進まず、エンジン既定のフォントに落ちるだけなので、気づきにくい形で見た目が変わります。
CSS中のurl() | 指し直し先 |
|---|---|
https://cdn.example.com/assets/x-<digest>.otf | パス部分をローカルのファイルに対応付ける |
/assets/x-<digest>.otf | public/、次にパイプラインのロードパス |
fonts/x.otf | まずCSSファイル自身のディレクトリ、次にパイプラインのロードパス |
data:、#fragmentのみ | そのまま |
| ローカルに見つからないURL | そのまま(CDNのフォントなど本物のリモート参照) |
絶対URLはホスト名をasset_hostと突き合わせません。
asset_hostはProcや%dを含む文字列も取れるため、逆引きの一般解がないからです。
代わりにパス部分がディスク上に実在するかどうかで判定します。
指し直したあとの形はsghtmltopdf_image_tagと同じで、base_urlの下なら相対パス、読める場所にあれば絶対パス、読めなければdata:URIです。
sghtmltopdf_image_tagは画像をパスで指します。
base_urlの下にあれば相対パス、そうでなければ絶対パスです。
エンジンはbase_urlから解決できない絶対パスをファイルシステムのパスとして読むので、開発環境でapp/assetsにあるファイルもそのまま指せます。
パスで指せるのはエンジンがそこを読める場合だけなので、allow_path(未設定ならbase_url)の外にあるファイルはdata:URIとして埋め込みます。
取得の失敗は既定で無視されるため、パスで指したまま読めないと画像が無言で消えてしまうからです。
同じ理由で、server_urlを設定して別プロセスへ委譲する場合も埋め込みます。委譲先が同じファイルシステムを見ているとは限りません。
inline: trueを渡すと常に埋め込みます。
サーバへ委譲する
server_urlを指定すると、変換をHTTPサーバモードで動く
別プロセスへ投げます。
アプリのCPUを使いたくない場合や、gemの対応プラットフォーム外(Windowsなど)で動かす場合に使います。
Sghtmltopdf.configure do |c|
c.server_url = "http://pdf.internal:8080"
end
pdf = Sghtmltopdf.render(html, page_size: "A4") # 委譲される
- URLは1つだけです。負荷分散はLB(nginx・k8s Service)を前段に置く前提です
- 到達できないときはローカルへフォールバックしません(
ServerError)。 サーバ起動時にだけ指定できるフォントが効かず、出力が変わってしまうためです - HTTPのステータスは上のエラー分類へ対応します(400→
UsageError、413→InputError、500→RenderError、その他→ServerError)
サーバでは指定できないオプション
ローカルパスを取るものと出力先・アクセス制御はサーバ起動時にしか設定できません(指定するとUsageError)。
font, font-index, gothic-font, gothic-font-index, serif-font,
serif-font-index, mono-font, mono-font-index,
output, cover, header-html, footer-html, user-style-sheet, base-url,
allow, enable-local-file-access, disable-local-file-access,
allow-remote-assets, log-level, quiet
Railtieが入れるbase_url/allow_pathの既定値は自動的に外れるので、Railsでそのままserver_urlを足しても400にはなりません。
configureで明示的に設定している場合は、サーバ側の起動オプションへ移してください。
チャンクごとに受け取る
ブロックを渡すと、PDF全体を組み立ててから返す代わりにチャンクごとにブロックが呼ばれます(返り値はnil)。
Rackのresponse.streamへ流したり、S3のマルチパートアップロードへ繋いだりするための口です。
Sghtmltopdf.render(html) { |bytes| response.stream.write(bytes) }
ローカル変換でもサーバ委譲でも、PDF全体が組み上がるのを待たずに書き出せます。
ローカルは確定したページから順に、サーバはその?stream=1(chunked transfer encoding)をそのまま流します。
ただし逐次になるのはPDFの書き出しだけで、HTMLのパースとレイアウトは文書全体に対して先に行います。 そのため最初のチャンクが届くのは変換の終盤で、ピークメモリもブロックを渡さない場合と変わりません。 HTMLを読みながらページを確定させたい場合はストリーミングモードと併せて使ってください。
1回に渡すバイト数の目安はchunk_size:で変えられます(既定64KiB、ローカル変換のみ)。
小さくすると細かく届きますが、そのたびにGVLを取り直すのでレンダリングは遅くなります。
Sghtmltopdf.render(html, chunk_size: 8 * 1024) { |bytes| ... }
Thread#killとタイムアウトが効く
ブロックの呼び出しはRubyのメソッド呼び出しなので、その時点で保留中の割り込みが処理されます。
ブロック付きで呼んでいる限り、Thread#killやTimeout.timeout・Rack::Timeoutがチャンク境界で効きます。
Timeout.timeout(10) do
Sghtmltopdf.render(huge_html) { |bytes| io.write(bytes) } # 10秒で中断できる
end
ブロックを渡さないrender/render_to_fileは変換の間まったくRubyへ戻らないため、途中で止められません。
長い変換に上限をかけたい場合はブロック付きで呼んでください。
Railsで逐次返却する
render pdf:のレンダラは、組み上がったPDFをsend_dataで一括返却します。
確定したページから順に返したい場合はActionController::Liveと組み合わせます。
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
途中まで書き出したあとに失敗すると、クライアントには壊れたPDFが届きます(ヘッダは既に送信済みなのでステータスを変えられません)。
サーバモードの?stream=1と同じ性質です。
S3へ直接上げる
gemはS3向けの実装を持ちません(依存を増やさず、書き方も短いためです)。 マルチパートアップロードは最後のパート以外は5MB以上という制約があるので、溜めてから上げます。
s3 = Aws::S3::Client.new
upload = s3.create_multipart_upload(bucket: bucket, key: key, content_type: "application/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
begin
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})
rescue StandardError
s3.abort_multipart_upload(bucket: bucket, key: key, upload_id: upload.upload_id)
raise
end
小さいPDFならput_object(body: Sghtmltopdf.render(html))で十分です。
メモリを抑えたいとき
数万要素規模のHTMLでは、エンジンのストリーミングモードを使うとメモリが大きく減ります(実測: 60,000要素で 228MB → 28MB。メモリと処理時間)。
Sghtmltopdf.render(html, streaming: true)
その代わり、文書全体を見ないと決まらないもの(toc・counter(pages)・<body>より後の<style>など)が使えません。
制約の一覧はストリーミングモードを参照してください。