sghtmltopdf とは
HTML をそのまま PDF として出力するための変換器/レンダラーです。 Rust で書かれており、Chromium/WebKit/Geckoのヘッドレスブラウザのインストールを必要とせず PDF を出力できます。
使い方は、CLI・HTTPサーバ・ライブラリの3種類から選択でき、どれも同じエンジンを同じオプションで動かします。 ライブラリについては、現時点で Rubygems に対応しています。
PDFエンジンは、html5ever/Stylo/Taffy といった Servo が提供している Rust クレートをベースに構築しています。
wkhtmltopdf/WickedPDF への感謝
作者が初めてWebアプリ上で PDF を出力する機能を実装することになったとき、当時の開発環境は Ruby on Rails だったのですが、wkhtmltopdf と Railsから使うための wicked_pdf gem を使っていました。
それまで何となく PDF は難しそうで敬遠していたのですが、HTML で見た目を確認してそのまま PDF に出力できるという快適な開発フローに感動したのを覚えています。
しかし、wkhtmltopdf は依存していた QtWebKit がメンテナンス終了になったため、それに伴い2023年1月にアーカイブされてしまいました。
現在は、Headless Chrome等のヘッドレスブラウザを使って PDF を出力する場面が多いと思います。
個人的にwkhtmltopdf のアプローチがとても好きだったので、使っていて感じた課題を解消させてモダナイズした wkhtmltopdf を作ろうと思いました。
sghtmltopdf の「sg」は Second Generation の略で、wkhtmltopdf にお世話になった敬意を込めて「第2世代」とつけました。
wkhtmltopdf(QtWebKit)やヘッドレスブラウザを使ったPDF出力に感じていた課題
wkhtmltopdf に特有のもの:
- QtWebKit が古く、CSS3 への対応が限定的(Flexbox・Grid・カスタムプロパティに非対応)
- Webfont を使うと、読み込みを待たずに PDF 出力されてしまうことがある
- 表(Table)の最中で改ページした場合、改ページ後のページに表のヘッダーが出せない
ヘッドレスブラウザにも共通するもの:
- Webアプリとは別にバイナリをインストールする必要があり、AWS Lambda などで環境構築に手間がかかる
- 巨大な HTML が渡ってくると、出力にかかる時間やメモリ消費コストが一気に増える
これらを解決するために、sghtmltopdf では以下のような対応を入れました。
- Flexbox・Grid・カスタムプロパティを含む CSS3 に対応(
!importantやグラデーションなど、対応していないものはプロパティ対応表を参照) - Webfont(
@font-face)を、非同期の読み込み待ちなしに決定論的なタイミングで解決。汎用ファミリー名の実体は--serif-font/--gothic-font/--mono-fontで個別に指定可能 - 表(Table)の途中で改ページが起きても、
<thead>の行を次のページ以降の先頭に繰り返す - 追加のランタイムなしに動く実行ファイル1つ、または公式Dockerイメージで配布。Ruby からはネイティブ拡張として呼べるので、ブラウザプロセスを起動せずWebアプリのプロセスに同居できる
- ブラウザエンジンを載せ替えるのではなく、PDF出力に特化したレンダリングエンジンを実装。画面描画やスクリプト実行のための仕組みを持たないぶん、文書が大きいほど処理時間の差が開く(60,000要素の文書で wkhtmltopdf の約21倍、ヘッドレスChromeの約53倍。パフォーマンス比較)
- HTML をチャンク単位で読み、確定したページから書き出すストリーミングモードを用意。段落が主体の文書ではメモリ消費量を大きく抑えられる(書き出しの区切りが
<body>直下の要素単位のため、巨大な表が1つだけの文書では効果がない)
wkhtmltopdf / ヘッドレスChromeとのパフォーマンス比較
同じHTMLを同じ用紙設定で変換したときの実測値です。
用紙設定は「用紙A4・余白10mm」を@pageで指定し、フォントは@font-faceで同じファイルを参照しています。
比較対象は、アーカイブ時点の最終版である wkhtmltopdf 0.12.6.1 と、Google Chrome 151 のヘッドレスモードです。 各セルは「ピークメモリ / 処理時間」を表します。
この比較は cargo run --release --example compare_engines で出したものです。
段落が主体の文書:
| 要素数 | sghtmltopdf | sghtmltopdf(ストリーミング) | wkhtmltopdf | ヘッドレスChrome |
|---|---|---|---|---|
| 5,000 | 26MB / 0.11秒 | 9MB / 0.10秒 | 44MB / 0.49秒 | 543MB / 1.32秒 |
| 20,000 | 80MB / 0.46秒 | 14MB / 0.34秒 | 86MB / 2.60秒 | 943MB / 7.45秒 |
| 60,000 | 230MB / 1.99秒 | 25MB / 1.31秒 | 199MB / 42.02秒 | 1,525MB / 105.77秒 |
表が主体の帳票:
| 行数 | sghtmltopdf | sghtmltopdf(ストリーミング) | wkhtmltopdf | ヘッドレスChrome |
|---|---|---|---|---|
| 5,000 | 49MB / 0.56秒 | 48MB / 0.60秒 | 62MB / 1.55秒 | 1,372MB / 5.12秒 |
| 20,000 | 173MB / 2.44秒 | 173MB / 2.36秒 | 163MB / 14.60秒 | 6,222MB / 39.94秒 |
処理時間は文書が大きくなるほど差が開きます。 60,000要素では wkhtmltopdf の約21倍、ヘッドレスChromeの約53倍の速さです。 20,000行の帳票では、それぞれ約6.0倍・約16倍になります。
メモリは、wkhtmltopdfとはほぼ同等です。
60,000要素と20,000行の帳票では sghtmltopdf のほうがやや多く、文書サイズに比例して増える点も変わりません。
ストリーミングモードを使うと、段落主体の文書では25MBまで下がります。
一方、巨大な表が1つだけの帳票ではストリーミングの効果がありません。
確定したページを書き出す区切りが<body>直下の要素単位のため、表が1つしかない文書では表の最後を書き出すまでメモリを解放できないからです。
ヘッドレスChromeとは桁が違い、20,000行の帳票では6.2GBに達します。 ヘッドレスChromeはブラウザとして必要な機能が全て含まれているため、PDF出力に特化した sghtmltopdf の方がメモリ効率が良くなります。
全体の構成
CLI・HTTPサーバモード・Ruby(ネイティブ拡張 / HTTPサーバへの委譲)の4つの経路は、すべて同じオプション定義(cli/options.rs)と同じエンジン(sghtmltopdf-core)を通ります。
違うのは呼び出し方と、変換後のPDFバイト列をどこへ書き出すか(Sink)だけです。
flowchart TD
subgraph Entry["エントリーポイント (core/src)"]
CLI["CLI<br/>sghtmltopdf"]
Server["HTTPサーバモード<br/>sghtmltopdf server<br/>(tiny_http)"]
FFI["Rubyネイティブ拡張<br/>(magnus + rb-sys)<br/>同一プロセス内でFFI呼び出し"]
end
CallCLI["ターミナル / CI"] --> CLI
CallHTTP["他言語・curl"] -->|"POST /pdf?options"| Server
CallRuby["Rails / Rubyアプリ<br/>(gem sghtmltopdf)"] -->|"Sghtmltopdf.render"| FFI
CallRuby -->|"server_url 設定時"| Delegate["ServerClient"]
Delegate -->|"POST /pdf?options<br/>(別プロセス・別ホストへHTTP委譲)"| Server
Options["共通オプションパーサ<br/>cli/options.rs (clap)"]
CLI --> Options
Server --> Options
FFI --> Options
Engine["sghtmltopdf-core Engine<br/>HTML解析 → スタイル計算 → レイアウト → ページ分割 → PDF書き出し"]
Options --> Engine
Engine -->|"FileSink / StdoutSink"| OutCLI["PDFファイル / 標準出力"]
Engine -->|"MemorySink"| OutServer["HTTPレスポンス<br/>(?stream=1でchunked転送)"]
Engine -->|"MemorySink / FileSink / CallbackSink"| OutFFI["PDFバイト列 / ファイル / Rubyブロックへ逐次"]
Rubyのネイティブ拡張はサブプロセスを起動せず、WebアプリのプロセスにFFIとして同居します(変換中はGVLを解放するため他のスレッドは止まりません)。
server_urlを設定した場合だけ、変換は別プロセスの sghtmltopdf server へHTTPで委譲されます(同じホストの別プロセスでも、ネットワーク越しの別ホストでも構いません)。
Engine内部(HTML解析からPDF書き出しまで)をページ単位で流す仕組みはストリーミングモードを参照してください。
インストール
使い方に応じて3通りあります。
| 使い方 | 入れるもの |
|---|---|
| HTTPサーバを常駐させる | Dockerイメージghcr.io/waka/sghtmltopdf |
| Ruby・Railsから使う | gem sghtmltopdf |
| 手元のコマンドラインで変換する | 実行ファイルsghtmltopdf(ソースからビルド) |
素の実行ファイル(GitHub Releasesのtarballやhomebrew)は配布していません。 サーバはイメージの中に、FFIから使う場合はgemの中にそれぞれ実行ファイル相当が入っているためです。 CLIを手元で試したい場合は下のソースビルドを使ってください。
Docker
docker pull ghcr.io/waka/sghtmltopdf:latest
docker run --rm -p 8080:8080 ghcr.io/waka/sghtmltopdf
日本語フォント(BIZ UDPGothic / BIZ UDPMincho)を同梱しているので、フォントを用意しなくても日本語のPDFが出ます。 詳しくはDockerを参照してください。
ソースからビルド
必要なのはRustのstableツールチェインだけです。 C言語のライブラリやシステムパッケージへの依存はありません。
git clone https://github.com/waka/sghtmltopdf.git
cd sghtmltopdf
cargo build --release
実行ファイルはtarget/release/sghtmltopdfにできます。
パスの通った場所へ置くか、そのまま呼び出してください。
./target/release/sghtmltopdf --version
HTTPサーバモードが要らない場合は、featureを削って小さくできます。
cargo build --release --no-default-features --features cli
Ruby / Rails
# Gemfile
gem "sghtmltopdf"
ビルド済み(precompiled)のgemを配布する方針のため、利用側にRustのツールチェインは要りません。
対応はx86_64-linux・aarch64-linux・arm64-darwin(glibc)と、Ruby 3.2以上です。
外部プロセスは起動せず、ネイティブ拡張(magnus + rb-sys)として同じプロセスの中で変換します。 重い処理の間はGVLを解放するので、Pumaの他のスレッドは止まりません。
使い方はRuby / Railsを参照してください。
フォントについて
--fontを指定しない場合、システムにインストールされているフォントが使われます。
日本語を含む文書では、フォントファイルを明示するか、フォントを同梱したDockerイメージを使うことを推奨します。
sghtmltopdf invoice.html \
--font NotoSansJP-Regular.ttf \
--gothic-font NotoSansJP-Regular.ttf
詳しくはフォントを参照してください。
Docker
日本語フォントを同梱した公式イメージをghcr.ioで配布しています。
サーバモードを常駐させる場合はこれが一番手軽です。
docker pull ghcr.io/waka/sghtmltopdf:latest
対応プラットフォームはlinux/amd64とlinux/arm64(どちらもglibc。Alpine等のmuslは対象外)です。
同じタグでどちらも引けます。
サーバとして使う
引数なしで起動するとHTTPサーバになります。
docker run --rm -p 8080:8080 ghcr.io/waka/sghtmltopdf
curl --data-binary @invoice.html \
'http://127.0.0.1:8080/pdf?page-size=A4' \
-o invoice.pdf
コンテナの中では--listen 0.0.0.0:8080で待ち受けます。
サーバモードの既定は127.0.0.1(何も設定しないまま外部公開されるのを防ぐため)ですが、それだとコンテナの外から届かないので、イメージ側のCMDで明示しています。
起動オプションを変えたいときは、serverから書き直します。
docker run --rm -p 8080:8080 ghcr.io/waka/sghtmltopdf \
server --listen 0.0.0.0:8080 --workers 4 --max-body-size 52428800
認証とTLSは持ちません。 外部へ公開する場合はリバースプロキシを前段に置いてください。
docker compose
services:
pdf:
image: ghcr.io/waka/sghtmltopdf:0.1
ports: ["8080:8080"]
healthcheck:
test: ["CMD", "sghtmltopdf", "--version"]
interval: 30s
curlはイメージに入っていないので、ヘルスチェックは--versionで代用するか、外側(ロードバランサ等)からGET /healthzを叩いてください。
CLIとして使う
ENTRYPOINTが実行ファイルそのものなので、引数を渡せばCLIとして動きます。
docker run --rm -v "$PWD:/work" -w /work --user "$(id -u):$(id -g)" \
ghcr.io/waka/sghtmltopdf invoice.html -o invoice.pdf
コンテナはrootでは動きません(UID 10001)。
ホストのディレクトリへPDFを書き出すときは、上のように--userでホスト側の所有者に合わせてください。
同梱しているフォント
BIZ UDPGothicとBIZ UDPMinchoのRegular・Bold(計4本、SIL Open Font License 1.1)が入っています。
ライセンス全文はイメージ内の/usr/share/doc/sghtmltopdf/fonts/にあります。
| CSSの指定 | 使われるフォント |
|---|---|
font-family未指定 | BIZ UDPMincho(明朝) |
font-family: sans-serif | BIZ UDPGothic(ゴシック) |
font-family: serif | BIZ UDPMincho |
font-family: monospace | 等幅フォントは同梱していないため、BIZ UDPMinchoにフォールバック |
font-weight: bold | 各書体のBold(合成太字ではありません) |
フォントが固定されているので、同じHTMLからは同じPDFが出ます(PDF内の作成日時を除く)。 ホストのフォント構成に出力が左右されないのがイメージを使う利点のひとつです。
別のフォントを使いたい場合は、マウントして--fontで渡してください。
同梱フォントより優先されます。
docker run --rm -v "$PWD:/work" -w /work --user "$(id -u):$(id -g)" \
ghcr.io/waka/sghtmltopdf invoice.html -o invoice.pdf \
--font fonts/YourFont-Regular.ttf --gothic-font fonts/YourFont-Regular.ttf
イメージの中身
| パス | 内容 |
|---|---|
/usr/local/bin/sghtmltopdf | 実行ファイル |
/usr/share/fonts/truetype/sghtmltopdf/*.ttf | 同梱フォント |
/usr/share/doc/sghtmltopdf/fonts/OFL-*.txt | フォントのライセンス |
/work | 既定の作業ディレクトリ |
ベースはdebian:bookworm-slimで、追加のシステムパッケージはありません(TLSのルート証明書は実行ファイルに埋め込まれているためca-certificatesも不要です)。
はじめてのPDF
インストールが済んでいる前提で、1枚出すところから改ページまでを順に試します。
1. 変換する
hello.htmlを用意します。
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: sans-serif; }
h1 { border-bottom: 2px solid #333; padding-bottom: 8px; }
.total { text-align: right; font-size: 1.2em; font-weight: bold; }
</style>
</head>
<body>
<h1>請求書</h1>
<p>いつもお世話になっております。下記のとおりご請求申し上げます。</p>
<p class="total">合計 ¥12,000</p>
</body>
</html>
変換します。
sghtmltopdf hello.html -o hello.pdf
出力先(-o)を省略すると、入力の拡張子を.pdfにしたファイル名になります。
-を使うと標準入力から読み、標準出力へ書けます。
cat hello.html | sghtmltopdf - -o - > hello.pdf
2. 用紙と余白を決める
sghtmltopdf hello.html -o hello.pdf \
--page-size A4 --margin-top 20mm --margin-bottom 20mm
単位はmm/cm/in/pt/pxが使え、省略するとmmです。
同じことはCSSの@pageでも書けます。
両方書いた場合はCSSが勝ちます(CLIオプションは初期値として扱われます)。
wkhtmltopdfと逆なので、移行時はwkhtmltopdfからの移行を確認してください。
@page {
size: A4;
margin: 20mm;
}
3. 改ページする
「ここから次のページ」は、CSSのbreak-beforeで指定します。
<style>
.page-break { break-before: page; }
</style>
<h1>請求書</h1>
<p>1ページ目です。</p>
<div class="page-break">
<h1>明細</h1>
<p>2ページ目です。</p>
</div>
break-after(この要素の後で改ページ)、break-inside: avoid(この要素を分割しない)も使えます。
見出しだけが行末に取り残されるのを防ぐorphans/widowsもあります。
詳しくはページ分割を参照してください。
4. ヘッダーとフッターを付ける
sghtmltopdf hello.html -o hello.pdf \
--header-center "請求書" \
--footer-right "[page] / [topage]" \
--header-line
[page]は現在のページ番号、[topage]は総ページ数に置き換わります。
JavaScriptは実行しないため、この置換で表現します。
HTMLでヘッダーを作りたい場合は--header-htmlが使えます。
5. フォントを固定する
ここまでの例はシステムのフォントを使っています。 サーバやCIで出力を環境に依存させたくない場合は、フォントファイルを明示してください。
sghtmltopdf hello.html -o hello.pdf \
--font NotoSansJP-Regular.ttf \
--gothic-font NotoSansJP-Regular.ttf
CLIリファレンス
sghtmltopdfコマンドの全オプション。
sghtmltopdf [OPTIONS] <INPUT.HTML>
sghtmltopdf server [OPTIONS]
上が変換、下がHTTPサーバです。
wkhtmltopdfのオプションとの対応(非対応にしたものを含む全一覧)はwkhtmltopdfオプション対応表を参照してください。
基本
# もっとも単純な使い方(フォントはシステムのものが使われる)
sghtmltopdf invoice.html -o invoice.pdf
# 出力先を省略すると入力の拡張子を .pdf にしたもの
sghtmltopdf invoice.html
# 標準入力から読み、標準出力へ書く
cat invoice.html | sghtmltopdf - -o - > invoice.pdf
入出力
| オプション | 既定 | 説明 |
|---|---|---|
<INPUT.HTML> | (必須) | 入力HTML。-で標準入力 |
-o, --output <PATH> | 入力の拡張子を.pdfに | 出力先。-で標準出力。標準入力から読む場合は省略できない |
--base-url <URL|DIR> | 入力HTMLのあるディレクトリ | 相対参照の解決基準。http(s)のURLを渡すと<base href>の既定値になる(HTML内の<base href>が優先) |
--encoding <NAME> | 自動判定 | 入力の文字エンコーディング。判定順は BOM > --encoding > <meta charset> > UTF-8 |
--streaming | オフ | ストリーミングモードで処理する |
出力ファイルは一時ファイルへ書いてからrenameされるため、失敗したときに壊れたPDFが残ることはありません。
ページ設定
| オプション | 既定 | 説明 |
|---|---|---|
-s, --page-size <SIZE> | A4 | A3/A4/A5/Letter/Legal(大文字小文字を区別しない) |
--page-width <LENGTH> | 用紙の幅。--page-sizeより優先 | |
--page-height <LENGTH> | 用紙の高さ。--page-sizeより優先 | |
-O, --orientation <O> | Portrait | Landscapeは最後に幅と高さを入れ替える |
-T, --margin-top <LENGTH> | 1in (96px) | 上マージン |
-B, --margin-bottom <LENGTH> | 1in | 下マージン |
-L, --margin-left <LENGTH> | 1in | 左マージン |
-R, --margin-right <LENGTH> | 1in | 右マージン |
長さの単位はmm/cm/in/pt/px。
単位を省略するとmmです(wkhtmltopdf互換)。
CSSの
@pageとの関係これらのオプションは初期値であり、HTMLのCSSに
@page { size: … }や@page { margin: … }が書かれていればそちらが勝ちます(プロパティ単位)。 wkhtmltopdfとは逆なので注意してください。
フォント
| オプション | 説明 |
|---|---|
--font <PATH> | 使うフォント(複数指定可)。省略するとシステムフォントを使う |
--font-index <N> | 直前の--fontに対する、TrueType Collection(.ttc)内のフェイス番号 |
--gothic-font <PATH> (+--gothic-font-index) | font-family: sans-serifの実体 |
--serif-font <PATH> (+--serif-font-index) | font-family: serifの実体 |
--mono-font <PATH> (+--mono-font-index) | font-family: monospaceの実体 |
フォントの解決順は「--font → @font-face → font-family名でのシステム探索」。
それでも1つも見つからない場合だけ、システムのsans-serif候補が既定フォントになります。
--fontを指定しないと出力が実行環境のフォントに依存します。 サーバ運用やCIで出力を安定させたい場合は--font(または@font-face)を明示してください。 詳しくはフォントを参照。
PDFの出力形式・メタデータ
| オプション | 既定 | 説明 |
|---|---|---|
--title <TEXT> | HTMLの<title> | PDFの/Title |
--author / --subject / --keywords <TEXT> | Info辞書の各項目(sghtmltopdf独自) | |
-d, --dpi <DPI> | 96 | CSS pxを何dpiとして解釈するか。72にすると1px=1pt |
--zoom <FACTOR> | 1.0 | 拡大率。--dpiの係数に掛かる |
-g, --grayscale | オフ | 塗り・線をグレースケール化(sRGB相対輝度) |
--no-pdf-compression | オフ | PDFオブジェクトのFlate圧縮を止める(画像データは対象外) |
/Producerと/CreationDateは常に書かれます。
グレースケールの限界
JPEG(
/DCTDecodeのパススルー)とCMYK画像はデコーダを持たないため、変換されずカラーのまま残ります。
コンテンツの挙動
| オプション | 説明 |
|---|---|
--no-images | <img>とCSSbackground-imageを読み込まない |
--no-background | 要素の背景(色・画像)を描かない |
--user-style-sheet <PATH> | ユーザーオリジンのCSS(複数指定可)。UAスタイルより強く、著者CSSより弱い |
--minimum-font-size <PX> | 算出font-sizeの下限 |
--disable-external-links | 外部リンク(http(s))のPDF注釈を作らない |
--disable-internal-links | 内部リンク(#id)のPDF注釈を作らない |
--keep-relative-links | 相対URLの外部リンクを絶対化せずそのまま書く |
--load-media-error-handling <ignore|abort> | 画像・CSS・フォントの取得失敗時の挙動(既定ignore) |
ヘッダー/フッター
2つの方法があります。
同じ側に両方を指定した場合は--header-htmlが優先されます。
1. テキストで指定する
@pageのmargin boxへマップされます。
sghtmltopdf report.html \
--header-center "四半期レポート" \
--footer-right "[page] / [topage]" \
--header-line
| オプション | 説明 |
|---|---|
--header-left / --header-center / --header-right <TEXT> | ヘッダーの3分割位置 |
--footer-left / --footer-center / --footer-right <TEXT> | フッターの3分割位置 |
--header-font-name / --header-font-size | ヘッダーのフォント(footerも同様) |
--header-line / --footer-line | 罫線を引く |
--header-spacing / --footer-spacing <MM> | 本文との間隔。その分だけマージンが増える |
--default-header | タイトルとページ番号の既定ヘッダー |
--replace <NAME=VALUE> | 任意の[NAME]を値へ置換(複数指定可) |
プレースホルダは[page](現在ページ)・[topage](総ページ数)・[frompage]・[title]/[doctitle]・[date]・[time]、および--replaceで定義した名前です。
[section]/[subsection]と[webpage]/[sitepage]/[sitepages]は非対応です。
2. HTMLで指定する
sghtmltopdf report.html --header-html header.html --footer-html footer.html
各ページの余白領域へ、別のHTMLをレンダリングして合成します。 プレースホルダはHTMLのテキストとして置換されます(JavaScriptは実行しません)。
- 余白に入りきらない分はクリップされます(マージンは自動で広がりません)
- 外部リソースを取得しません。使えるのはインラインの
<style>・テキスト・枠線・背景色までで、<img>と外部CSSは非対応です - ヘッダー/フッターHTML内の
@font-faceは読み込みません。そこでしか使わないフォントは--fontで明示してください
表紙と目次
sghtmltopdf report.html --cover cover.html --toc --footer-center "[page]"
書き出される順は 表紙 → 目次 → 本文 です。
| オプション | 既定 | 説明 |
|---|---|---|
--cover <PATH> | 表紙にするHTML。ページ番号に数えず、ヘッダー/フッターも出さない | |
--toc | オフ | 目次を本文の前に挿入する(ストリーミングモードでは使えない) |
--toc-header-text <TEXT> | Table of Contents | 目次の<h1> |
--toc-level-indentation <WIDTH> | 1em | 階層1段ごとのインデント |
--toc-text-size-shrink <REAL> | 0.8 | 階層1段ごとの文字サイズ比 |
--disable-dotted-lines | (引く) | 項目の破線の下線を引かない |
--disable-toc-links | (張る) | 目次から見出しへのリンクを張らない |
--enable-toc-back-links | (張らない) | 見出しから目次へ戻るリンクを張る |
--page-offset <N> | 0 | ページ番号の起点をずらす |
目次のHTML構造と既定スタイルはwkhtmltopdfの既定TOC XSLの出力に合わせてあります(階層は入れ子の<ul>、各項目は<div><a>見出し</a><span>ページ番号</span></div>)。
見た目を変えたい場合は--user-style-sheetでCSSを当ててください(XSLTは非対応)。
見出しはh1〜h6から集めます。
idが無い見出しには自動で宛先名が振られます。
アクセス制御
| オプション | CLIの既定 | サーバの既定 |
|---|---|---|
--enable-local-file-access / --disable-local-file-access | 許可 | 禁止 |
--allow <PATH> | 制限なし | 制限なし |
--allow-remote-assets | 禁止 | 禁止 |
--allowを1つ以上指定すると、ローカル参照はそのディレクトリ配下だけに限定されます。
<img src>・外部CSS・@font-faceのすべてに効きます。
判定は実パス(シンボリックリンクを辿った後のパス)で行います。 指定したディレクトリが存在しない場合は起動時にエラーになります。 黙って無視すると、許可した範囲と実際に効く範囲がずれるためです。
基準ディレクトリの外への参照
ローカル参照は既定で基準ディレクトリ(--base-url、既定は入力HTMLのあるディレクトリ)の中に閉じます。
../で外へ出ようとする参照はエラーになります。
信頼できないHTMLを変換したときに<img src="../../../../etc/passwd">のような参照で任意のファイルを読み出されるのを防ぐためです。
assets/../images/logo.pngのように基準ディレクトリの中で完結する../は従来どおり使えます。
外のファイルを意図的に参照する場合は--allowで範囲を明示してください。
--allowを指定した場合は、基準ディレクトリではなく許可したディレクトリが境界になります。
$ sghtmltopdf pages/index.html -o out.pdf
エラー: ../images/logo.png: 基準ディレクトリ(pages)の外を参照しています。
外部のファイルを読む場合は --allow でディレクトリを明示してください
$ sghtmltopdf pages/index.html --allow . -o out.pdf
判定はパス文字列に対して行うため、基準ディレクトリ配下のシンボリックリンクは辿ります。
シンボリックリンクの先まで含めて閉じたい場合は--allowを使ってください(こちらは実パスで判定します)。
入力の大きさの制限
要素数がおよそ50万ノードを超えるHTMLはエラーになります。 算出スタイル・ボックスツリー・レイアウト結果がノード数に比例して積み上がるためで、実測では1ノードあたり472B〜1210Bでした。
数千ページ規模の文書でも数十万ノードなので、実在の文書がここに当たることはまずありません。 当たった場合は文書を分割するか、ストリーミングモードを使ってください。 ストリーミングモードでは処理済みの部分が随時解放されるため、総量が上限を超えていても変換できます。
テキストの量に比例するメモリはこの上限では抑えられません(要素3個でも10MiBのテキストなら約1.7GiB使います)。
HTTPサーバモードでは--max-body-sizeがその役割を担います。
ログとexit code
--log-level <none|error|warn|info>(既定info)、-q/--quiet(=--log-level none)。
| code | 意味 |
|---|---|
| 0 | 成功 |
| 1 | 使用法エラー(不明なオプション、値の形式不正、非対応オプションの指定) |
| 2 | 入力/リソースエラー(ファイルが無い、フォントが読めない、abort指定での取得失敗) |
| 3 | レンダリングエラー(ストリーミングモードの制約違反など) |
| 4 | 制限時間超過(HTTPサーバモードの--timeoutのみ。CLIには時間制限がないため出ません) |
ストリーミングモード
--streamingを付けると、HTMLをチャンク単位で読みながら、ページが確定したそばからPDFを書き出して、そのページ分のメモリを手放します。
sghtmltopdf big.html -o big.pdf --streaming
数万要素規模のHTMLで効果があります(メモリと処理時間)。 その代わり、文書全体を見ないと決まらないものが使えなくなります。
使えないもの(エラーになる)
指定するとexit code 3で終了します。
| 使えないもの | 理由 |
|---|---|
counter(pages) / ヘッダー・フッターの[topage] | 総ページ数は1パスでは決まらない |
--toc | 同上(目次には本文のページ番号が要る) |
<html>/<body>自身の背景色・枠線 | 複数ページにまたがる装飾の再現が必要になる |
<body>より後の<style> / <link rel="stylesheet"> | 既に書き出したページへ遡って適用できない |
警告のうえ続行するもの
結果が変わるため、黙って進めずに警告を出します。
| 制約 | 挙動 |
|---|---|
font-family名からのシステムフォント自動探索 | 既定のフォントで描画する。--font/--gothic-font/--serif-font/--mono-fontか@font-faceで明示すれば解決できる |
| 文字を描画できるフォントのシステム探索 | 文書全体を先読みできないため行わない。フォントを1つも指定しなかった場合だけ、CJK用のフォントを1本先回りで読み込む(フォント)。描画できない文字が出たら文字ごとに警告する |
:last-child・:nth-last-child・:last-of-type・:nth-last-of-type・:only-child・:only-of-type・:empty | 常に非マッチになる(親の子リストが完結するまで判定できないため) |
後方参照のセレクタが効かないのは、要素を読んだ時点でスタイルを確定させる必要があるためです。
「最後の行だけ罫線を消す」といった指定は、クラスを振って.last { … }のように書き換えてください。
使えるもの
以下はストリーミングモードでも通常どおり使えます。
--cover(表紙)- ヘッダー/フッター(
[page]まで。[topage]は不可) - ページ設定・PDFメタデータ
--grayscale/--dpi/--zoom- コンテンツ挙動のオプション(
--no-images等) break-before/break-after/break-inside・orphans/widowsなどのページ分割
メモリと処理時間
同じHTMLを両モードで変換した実測値です。 各セルは「ピークメモリ / 処理時間」を表します。
| 要素数 | HTMLサイズ | 通常モード | --streaming |
|---|---|---|---|
| 1,000 | 46KB | 11MB / 0.02秒 | 8MB / 0.02秒 |
| 5,000 | 233KB | 26MB / 0.08秒 | 10MB / 0.10秒 |
| 20,000 | 946KB | 81MB / 0.35秒 | 15MB / 0.38秒 |
| 60,000 | 2.8MB | 228MB / 1.05秒 | 28MB / 1.07秒 |
通常モードのピークメモリは文書サイズにほぼ比例して増えますが、--streamingではほとんど増えません。
60,000要素では約8分の1になります。
--streamingでもわずかに増えるのは、PDFの相互参照表(全オブジェクトの位置)と使用グリフの集計を最後まで保持するためです。
処理時間は両モードでほぼ変わりません。
--streamingのほうがわずかに遅く出ますが、差はどの規模でも0.03秒以内で、文書が大きくなるほど相対差は縮まります(60,000要素では2%)。
メモリを大きく減らす代わりに時間を損なう、という関係にはなっていません。
測定条件は次のとおりです。
- sghtmltopdf 0.1.0のreleaseビルド、Intel Core Ultra 7 258V / メモリ16GB / WSL2(Linux 5.15)
- 高さ60pxの
<p>を要素数ぶん並べただけのHTML。フォントは--fontで明示 - ピークメモリはプロセスの最大常駐セットサイズ(RSS)。2回実行して良いほうの値
いつ使うか
- 数千ページ規模の帳票を1本のHTMLから出す
- メモリの上限が厳しい環境(コンテナのメモリ制限、サーバレス)で動かす
逆に、数十ページの請求書のような文書では通常モードで十分です。 制約を負ってまで使うものではありません。
サーバモードでは、クエリにstreamingを付けると同じモードになります。
?stream=1(chunkedで返す)と組み合わせると、入力・レンダリング・出力のすべてが逐次処理になります。
curl --data-binary @big.html 'http://127.0.0.1:8080/pdf?stream=1&streaming' -o out.pdf
ストリーミング時の処理の流れ
flowchart TD
A["HTMLチャンク"] --> B["ストリーミングパーサ"]
B --> C["スタイルカスケード"]
C --> D["レイアウト + ページ分割"]
D --> E["PDFライター"]
E --> F["PDF出力"]
D -. "ページが確定するたび" .-> E
E -. "メモリを解放して読み進む" .-> B
ページの区切りが確定した時点でPDFを書き出し、そのページ分のメモリを解放して先へ読み進めることで、大きな文書でも消費メモリを増やさず処理を可能にしています。
HTTPサーバモード
常駐させて、HTTP経由で変換を受け付けるモードです。 アプリケーションからプロセスを起動する必要がなくなり、負荷分散もロードバランサに任せられます。
sghtmltopdf server --listen 127.0.0.1:8080 --font NotoSansJP-Regular.ttf
# → 標準出力に `listening on 127.0.0.1:8080` が出る
curl --data-binary @invoice.html \
'http://127.0.0.1:8080/pdf?page-size=A4&margin-top=20mm&toc' \
-o invoice.pdf
常駐させるなら、日本語フォントを同梱したDockerイメージが手軽です(引数なしでこのサーバとして起動します)。
docker run --rm -p 8080:8080 ghcr.io/waka/sghtmltopdf
起動オプション
| オプション | 既定 | 説明 |
|---|---|---|
--listen <ADDR:PORT> | 127.0.0.1:8080 | 待ち受けアドレス。:0で空きポートを自動割り当て |
--workers <N> | CPUコア数 | 同時に変換するワーカースレッド数 |
--max-queue <N> | ワーカー数×4 | 受理待ちの上限。超えると503 |
--max-body-size <BYTES> | 4194304 (4MiB) | リクエストボディの上限 |
--timeout <SECS> | 30 | 1リクエストに与える秒数。キュー待ちと変換の合計(超えると504) |
--font <PATH> ほかフォント指定 | リクエストからは変更できない | |
--enable-local-file-access / --allow <PATH> / --allow-remote-assets | すべて禁止 | 明示的に許可する場合のみ |
認証とTLSは持ちません。 外部へ公開する場合はリバースプロキシを前段に置いてください。
エンドポイント
| メソッド・パス | 説明 |
|---|---|
POST /pdf | ボディのHTMLをPDFへ変換して返す(application/pdf) |
POST /pdf?stream=1 | 同上。chunked transfer encodingでページが確定したそばから流す |
GET /healthz | ok |
GET /version | sghtmltopdf <version> |
クエリパラメータ
CLIのロングオプションから--を取った名前がそのまま使えます。
値の解釈もCLIと同一です(同じパーサへ通しているため)。
指定できるのは許可リストに載っているものだけです。
ページの体裁・PDFのメタデータ・ヘッダー/フッターの文字列・目次の見た目など、リクエストごとに変わってよく、かつサーバのファイルシステムにもネットワークにも触れないオプションが対象です。
それ以外は400を返します。
| クエリ | 相当するCLIオプション |
|---|---|
?page-size=A4 | --page-size A4 |
?margin-top=20mm | --margin-top 20mm |
?toc | --toc(値なしは真) |
?grayscale=1 / =true | --grayscale |
?grayscale=0 / =false | 指定なしと同じ |
値はパーセントエンコードできます(%XXと+)。
各オプションの意味はCLIリファレンスを参照してください。
サーバ起動時にだけ指定できるオプション
以下は許可リストに含まれず、指定すると400を返します。
ローカルパスを取るもの・出力先・アクセス制御・ログ設定はサーバ起動時にだけ設定できます。
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
ステータスコード
| コード | 状況 |
|---|---|
| 200 | 成功(Content-Type: application/pdf) |
| 400 | 未知/禁止のクエリキー、値の形式不正、ボディが空 |
| 404 | 未知のパス |
| 405 | 使えないメソッド |
| 413 | ボディが--max-body-size超過 |
| 500 | レンダリング失敗 |
| 503 | キュー溢れ(--max-queue超過) |
| 504 | --timeout超過(キュー待ち、または変換が長すぎる) |
ストリーミング
- 入力: リクエストボディは読み切らずに、64KiBずつエンジンへ流します。
- 大きなHTMLを丸ごとメモリに載せません
- 出力: 既定はバッファ返却(
Content-Length付き)。?stream=1を付けるとchunked transfer encodingで、ページが確定したそばから流します
curl --data-binary @big.html 'http://127.0.0.1:8080/pdf?stream=1' -o out.pdf
エンジン側のストリーミングモード(?streaming)と併用すると、入力・レンダリング・出力のすべてが逐次処理になります。
メモリの見積もり
変換に必要なメモリは、入力の大きさにほぼ比例します。 実測(最適化ビルド)では次のとおりでした。
| 要因 | 単価 | 抑えているもの |
|---|---|---|
| 要素の数 | 472B〜1210B/ノード | ノード数上限(50万) |
| テキストの量 | 約185MiB/入力1MiB | --max-body-size |
どちらの上限も、既定では最悪およそ600〜750MiBに収まる値にしてあります。
ワーカーは同時に変換するので、プロセス全体では「ワーカー数 × この値」が必要です。
既定(--workersはCPUコア数)のまま8コアの機械で動かすと最悪6GiB程度になるため、コンテナのメモリ制限に合わせて--workersか--max-body-sizeを調整してください。
要素数が上限を超えると400を返します。
?streamingを付けると処理済みの部分が随時解放されるため、要素数の上限には当たりにくくなります。
既知の限界
--timeoutはキュー待ちと変換の合計に効きます。変換の打ち切り判定はチャンク投入ごと・トップレベル要素ごと・ページ書き出しごとに行うため、超過に気づくのは最大でその1区間ぶん遅れます。レイアウトの1回の呼び出しの内側までは見ません- 実測では、10MiBの重いHTMLに
--timeout 2を指定した場合の実際の応答は2.1〜5.2秒でした(打ち切り後に大きなDOMを破棄する時間も含みます)。指定より早く返ることはありません ?stream=1のとき、ヘッダ送信後に失敗してもステータスは200のままになります(パイプが閉じ、クライアントには不完全なPDFが届きます)。クエリの不正・空ボディ・サイズ超過はヘッダ送信前に検出するので400/413で返ります?stream=1のときは入力を先に読み切ります(HTTPライブラリの制約で、ボディ読み取りと応答が排他のため)。入力と出力のストリーミングは同時には使えません
Railsから使う
Ruby gemには、変換をこのサーバへ委譲するserver_url設定があります。
Ruby / Railsを参照してください。
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 |
| Ruby | 3.2以上 |
Linuxはglibc(Debian/Ubuntu系)とmusl(Alpine)の両方があり、gem installが環境に合うほうを選びます。
Windows・Intel Macは対象外で、これらの環境ではインストールできません。
サーバへ委譲するという手があります。
基本
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: ["/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"と書いてください。
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(ローカル参照の制限)の対象外です。
エラー
すべて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 | [Rails.root] | ローカル参照をアプリ配下に限定する |
どちらもSghtmltopdf.configureで上書きできます(イニシャライザの実行順に依存しません)。
allowの既定はテンプレートにユーザー入力が混ざっても文書外のファイルを読ませないためのものなので、アプリの外(例: /usr/share/fonts)を参照している場合は明示的に足してください。
開発環境のようにアセットがまだpublic/へ書き出されていない場合のために、CSSの中身を<style>へ展開するヘルパがあります。
<%= sghtmltopdf_stylesheet_link_tag "pdf" %>
<%= sghtmltopdf_image_tag "logo.png" %>
<%= sghtmltopdf_asset_path "logo.png" %> <%# 見つからなければnil %>
サーバへ委譲する
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の既定値は自動的に外れるので、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>など)が使えません。
制約の一覧はストリーミングモードを参照してください。
CSSプロパティ対応表
- 表示・可視性
- ボックスモデル
- 枠線・角丸・アウトライン・影
- 配置(positioning / float / transform)
- フォント・テキスト
- 背景
- テーブル
- リスト
- 生成コンテンツ・カウンタ
- ページ分割(CSS Fragmentation)
- Flexbox
- Grid
- 置換要素(画像)
- 非対応プロパティ一覧
表示・可視性
| プロパティ | 対応 | 備考 |
|---|---|---|
display | ⚠️ | block/inline/inline-block/list-item/table/table-row/table-cell/table-caption/flex/noneのみ。gridは対応(Grid節)。inline-flex/inline-grid・table-row-group等のテーブル内部値・flow-rootは非対応。<thead>/<tbody>/<tfoot>はUAスタイルでblockのままだが、行収集が透過するのでテーブルとして機能する |
visibility | ⚠️ | visible/hidden/collapse。collapseはhiddenと同一視(テーブル行/列の高さ再計算はしない)。継承プロパティ |
overflow | ⚠️ | visible以外(hidden/scroll/auto)は区別せず一律クリップ。スクロールバーの概念は無い。overflow-x/overflow-yは非対応 |
opacity | ⚠️ | <number>/<percentage>を0〜1にクランプ。PDFの透明グループ+ExtGStateで実装。要素単位の合成で、mix-blend-mode等のブレンドは非対応 |
z-index | ⚠️ | auto/<integer>。position: relativeの要素にのみ効く(仕様では他のpositioned要素にも効く)。同じ親を持つ兄弟間の描画順のみを制御し、スタッキングコンテキストの分離は非対応。絶対配置要素は常に通常フローの上に描かれる |
box-sizing | ⚠️ | content-box/border-box。標準外のpadding-boxは非対応 |
ボックスモデル
| プロパティ | 対応 | 備考 |
|---|---|---|
width / height | ⚠️ | auto/<length>/<percentage>/calc()。min-content/max-content/fit-contentは非対応。heightのパーセンテージはcontaining block高さ不定として無視される |
min-width / min-height | ⚠️ | <length>/<percentage>/calc()(初期値0)。auto/min-content等のキーワードは非対応。min-heightのパーセンテージは無視される |
max-width / max-height | ⚠️ | none/<length>/<percentage>/calc()(初期値none)。min > maxのときはminが勝つ(仕様通り)。max-heightのパーセンテージは無視される |
aspect-ratio | ⚠️ | auto | <ratio> | auto <ratio>。「幅確定→高さ導出」が基本で、「高さ確定→幅導出」はfloat/inline-block/絶対配置/<img>のshrink-to-fit文脈のみ(通常フローのブロックのwidth: autoはstretch優先、仕様通り)。min-*/max-*でクランプされて比が崩れた場合の再計算は行わない |
margin | ✅ | 1〜4値ショートハンド。auto(中央寄せ)・負値に対応 |
margin-top / -right / -bottom / -left | ✅ | 隣接兄弟間・親子間のマージン相殺に対応 |
padding | ✅ | 1〜4値ショートハンド |
padding-top / -right / -bottom / -left | ✅ | パーセンテージはcontaining block幅基準(仕様通り) |
枠線・角丸・アウトライン・影
| プロパティ | 対応 | 備考 |
|---|---|---|
border | ✅ | <width> || <style> || <color>を任意順・任意省略で受け付け、4辺へ適用 |
border-top / -right / -bottom / -left | ✅ | 辺別ショートハンド(値文法はborderと同じ) |
border-width / border-style / border-color | ✅ | 1〜4値ショートハンド |
border-*-width | ⚠️ | <length>のみ。thin/medium/thickキーワードは非対応 |
border-*-style | ⚠️ | none/hidden/solid/dashed/dotted/double/groove/ridge/inset/outset。hiddenはnoneと同一視(テーブルの枠線競合解決でも区別しない)。groove/ridge/inset/outsetはborder-colorから2階調の陰影を算出して描画 |
border-*-color | ✅ | 初期値はcurrentcolor |
border-radius | ⚠️ | 1〜4値+/区切りの楕円構文に対応。パーセンテージ指定は非対応(<length>のみ)。4辺の太さ・スタイル・色が揃っていない場合は角丸を諦めて直線4辺へフォールバックする。groove/ridge/inset/outsetとの併用も同様にフォールバック |
border-top-left-radiusほか3隅 | ⚠️ | <length>{1,2}(水平/垂直半径)。制約はborder-radiusと同じ |
outline | ✅ | <width> || <style> || <color>。border-boxの外側に描画し、レイアウトには影響しない |
outline-width / outline-style / outline-color | ⚠️ | outline-styleはborder-styleと同じ値集合。UA依存のautoは非対応 |
outline-offset | ❌ | 常に0固定 |
box-shadow | ⚠️ | none | <shadow>#(カンマ区切りで複数指定可、先頭が最前面)。insetはパースするが描画は非対応。ぼかしは4段階の同心矩形による近似 |
配置(positioning / float / transform)
| プロパティ | 対応 | 備考 |
|---|---|---|
float | ✅ | none/left/right。width: autoのshrink-to-fitに対応 |
clear | ✅ | none/left/right/both |
position | ⚠️ | static/relative/absolute/fixed。stickyは非対応。absolute/fixedには後述の制約あり |
top / right / bottom / left | ⚠️ | absolute/fixedではbottom単独指定による下端揃えが非対応(高さの循環参照を避けるためtop基準に解決する)。relativeではオフセットとして機能する |
inset(ショートハンド) | ❌ | 個別のtop/right/bottom/leftを使う |
transform | ⚠️ | translate/translateX/translateY/scale/scaleX/scaleY/rotate/skew/skewX/skewY/matrix。3D系(translate3d/rotate3d/perspective()等)は非対応。PDFのCTM変換で実装するため、変換後の内容はページ分割の判定に影響しない |
transform-origin | ⚠️ | background-positionと同じ値文法(キーワード/長さ/パーセンテージの1〜2値)。初期値50% 50%。3値目(Z軸)は非対応 |
position: absolute/fixedの既知の制約:
- 絶対配置要素は「通常フローから外し、確定したページへ後付けするオーバーレイ」として配置する
- containing blockになれるのは、単一ページに収まっているpositioned祖先(またはページ領域)
- インラインフォーマッティングコンテキストの内側(テキストの途中)にある
absolute、テーブルセル内・flexアイテム内のabsoluteは非対応 - 絶対配置要素自身がページを跨ぐ分割は非対応(1ページにbest-effortで置く)
- ストリーミングモード(
Mode::Streaming)では絶対配置を無視する
フォント・テキスト
| プロパティ | 対応 | 備考 |
|---|---|---|
font-family | ⚠️ | カンマ区切りリスト。汎用family名はserif/sans-serif/monospaceをシステムフォントから解決する(cursive/fantasyは解決しない)。sans-serifの解決先はCLIの--gothic-fontで決定的に上書きできる |
font-size | ⚠️ | <length>(px/em/rem)のみ。smaller/larger/medium等のキーワード、パーセンテージ指定は非対応 |
font-weight | ⚠️ | normal/bold/100〜900。数値は600以上をboldとみなす2値化。太字フォントが無い場合は塗り+縁取りの疑似ボールドで描画 |
font-style | ⚠️ | normal/italic/oblique(obliqueはitalicと同一視、傾斜角の指定は不可)。イタリック字形が無い場合はテキスト行列のせん断による疑似イタリック |
font(ショートハンド) | ❌ | 個別のロングハンドを使う |
color | ✅ | 継承プロパティ。指定できる色の記法はセレクタ・値・at-ruleを参照 |
line-height | ✅ | normal/<number>/<length>/<percentage> |
text-align | ⚠️ | left/right/center/justify。justifyは最終行以外の単語間で余白を配分する。start/endは非対応(direction自体が非対応のため) |
text-indent | ⚠️ | <length>/<percentage>。hanging/each-lineは非対応 |
text-transform | ⚠️ | none/uppercase/lowercase/capitalize(語頭のみ変換)。full-width/full-size-kanaは非対応 |
text-decoration / text-decoration-line | ⚠️ | none/underline/line-through(併記可)。overline/blinkは非対応。ショートハンドのtext-decoration-color/-style/-thickness部分も非対応。祖先から子孫への伝播は「継承プロパティとして扱う」簡略実装 |
text-shadow | ⚠️ | none | <shadow>#(<offset-x> <offset-y> <blur>? <color>?)。PDFにぼかしフィルタが無いため、blurはアルファを下げた多重描画による近似。継承プロパティ |
text-overflow | ⚠️ | clip/ellipsis。overflowがvisible以外のときのみ有効。幅方向にはみ出した行のみが対象(ブロック全体のオーバーフローは扱わない)。<string>指定は非対応 |
word-break | ✅ | normal(CJK文字が隣接する境界のみ改行可)/break-all/keep-all。非推奨値break-wordは非対応 |
overflow-wrap / word-wrap | ⚠️ | normal/break-word/anywhere(anywhereはbreak-wordと同一視)。改行機会は増やさず、行頭に置いても収まらない語だけを文字単位で割る |
hyphens | ⚠️ | none/manual/auto。soft hyphen(U+00AD)でのみ分割し、分割時に行末へハイフンを表示する。autoは辞書を持たないためmanualと同じ挙動(自動ハイフネーションはしない) |
text-emphasis / -style / -color / -position | ⚠️ | dot/circle/double-circle/triangle/sesame(filled/open)と<string>。キーワードのマークはPDFのパスで描くためフォントの字形に依存しない(<string>はグリフ描画で、字形が無ければ描かれない)。positionはover/underのみ(right/leftは読み飛ばし)。マーク分だけ行の高さが広がる。句読点をスキップするtext-emphasis-skipは非対応 |
letter-spacing | ⚠️ | normal/<length>。パーセンテージは非対応 |
word-spacing | ⚠️ | normal/<length> |
white-space | ⚠️ | normal/nowrap/pre。pre-wrap/pre-line/break-spacesは非対応 |
vertical-align | ✅ | baseline/sub/super/text-top/text-bottom/top/middle/bottom/<length>/<percentage>。テーブルセル文脈ではtop/middle/bottom(とbaseline)が意味を持つ |
quotes | ⚠️ | noneまたは"開き" "閉じ"のペアの繰り返し。contentのopen-quote/close-quoteと組で使う。継承プロパティ |
背景
| プロパティ | 対応 | 備考 |
|---|---|---|
background(ショートハンド) | ⚠️ | <color>/<image>/<repeat>/<attachment>/<position>[ / <size>]を任意順で受け付ける。指定しなかったロングハンドは仕様通り初期値へリセットされる。background-clip/-origin(padding-box等のキーワード)を含むとパースエラーになり宣言ごと無視される点に注意 |
background-color | ✅ | アルファ付きの色はExtGStateで透過描画 |
background-image | ⚠️ | none | url(...)のみ。linear-gradient()等のグラデーション関数、カンマ区切りの複数背景は非対応。既定ではintrinsicサイズでタイル配置 |
background-position | ✅ | キーワード(left/center/right/top/bottom)と長さ/パーセンテージの1〜2値。3〜4値構文(right 10px bottom 20px)は非対応 |
background-size | ✅ | cover/contain/<length-percentage> | autoの1〜2値 |
background-repeat | ⚠️ | repeat/repeat-x/repeat-y/no-repeat。CSS3のround/space、2値構文は非対応 |
background-attachment | ⚠️ | scroll/fixed(スクロールの概念が無いためfixedはscrollと同一視) |
background-clip / background-origin / background-blend-mode | ❌ | 未実装。背景はborder-box基準で描画する |
border-radiusとbackground-imageを併用した場合、角丸によるクリップは行わない(角丸は背景色の塗りにのみ効く)。
テーブル
| プロパティ | 対応 | 備考 |
|---|---|---|
table-layout | ✅ | auto/fixed。autoではセル内容の自然幅を測って列幅を決める(ネストしたテーブル・flexの自然幅測定のみ非対応で0扱い) |
border-collapse | ⚠️ | separate/collapse。collapseは見た目の枠線統合のみを行う(CSS2.1 §17.6.2の競合解決を「太い方が勝ち、同幅ならスタイル優先順」で簡略化)。継承プロパティ |
border-spacing | ✅ | <length>{1,2}。border-collapse: collapse時は0として扱う。継承プロパティ |
caption-side | ⚠️ | top/bottom。縦書き向けのleft/rightは非対応 |
empty-cells | ✅ | show/hide。border-collapse: separateでのみ意味を持つ。継承プロパティ |
vertical-align(セル) | ✅ | 上記フォント・テキストを参照 |
<colgroup>/<col>のwidth属性・CSSwidthによる列幅指定、rowspan/colspan、<thead>のページまたぎ繰り返しに対応。
rowspan="0"は1として扱う。
セルのmin-width/max-widthは列幅アルゴリズムに反映される。
table-layout: autoでは列の自然幅をクランプする形で効くため、表を紙幅に収める比例縮尺の後はmin-widthが保証されない。
table-layout: fixedでは1行目のセルの指定幅をクランプし、width: autoかつmin-widthのみの指定はその値を列幅として使う。
リスト
| プロパティ | 対応 | 備考 |
|---|---|---|
list-style(ショートハンド) | ✅ | type/position/imageを任意順・任意省略で受け付ける |
list-style-type | ⚠️ | disc/circle/square/decimal/decimal-leading-zero/lower-roman/upper-roman/lower-alpha(lower-latin)/upper-alpha(upper-latin)/none。cjk-*/hiragana/katakana等は非対応。継承プロパティ |
list-style-position | ✅ | outside/inside。継承プロパティ |
list-style-image | ⚠️ | none | url(...)をパースするが描画には使わない(常にlist-style-typeのテキストマーカーへフォールバック) |
生成コンテンツ・カウンタ
| プロパティ | 対応 | 備考 |
|---|---|---|
content | ⚠️ | ::before/::after/::first-letterおよび@pageのmargin box用。文字列リテラル・attr()・counter()/counters()・open-quote/close-quote/no-open-quote/no-close-quoteの連結に対応。url()による画像挿入は非対応。ブロック子を持つ要素の::before/::afterは生成されない(簡略化) |
counter-reset | ✅ | noneまたはname [<integer>]の繰り返し |
counter-increment | ✅ | noneまたはname [<integer>]の繰り返し(値省略時は1) |
counter-set | ❌ | 未実装 |
counter(page)/counter(pages)によるページ番号は@pageのmargin box内で使える(counter(pages)はストリーミングモードでは総ページ数が確定しないためエラーになる)。
ページ分割(CSS Fragmentation)
| プロパティ | 対応 | 備考 |
|---|---|---|
break-before / break-after | ⚠️ | auto/avoid(avoid-page/avoid-columnも同義)/always(pageも同義)。left/right/recto/verso(見開き制御)、多段組み関連の値は非対応 |
break-inside | ⚠️ | auto/avoid(avoid-page/avoid-columnも同義) |
page-break-before / page-break-after / page-break-inside | ✅ | 上記break-*のエイリアス(wkhtmltopdf/wicked_pdf資産からの移行用) |
orphans / widows | ✅ | 1以上の整数。初期値2 |
page(名前付きページ) | ❌ | @page introのような名前付きページ自体が非対応 |
HTML属性data-page-break="before|after|avoid"によるシンタックスシュガーも利用できる。
Flexbox
display: flexのレイアウトはtaffyへ委譲する。
flexコンテナはページ分割上アトミック(display: tableと同じく途中で分割されない)。
| プロパティ | 対応 | 備考 |
|---|---|---|
flex-direction | ✅ | row/row-reverse/column/column-reverse |
flex-wrap | ✅ | nowrap/wrap/wrap-reverse |
justify-content | ⚠️ | flex-start(start)/flex-end(end)/center/space-between/space-around/space-evenly。safe/unsafeオーバーフローキーワードは非対応 |
align-items | ✅ | flex-start(start)/flex-end(end)/center/baseline/stretch |
align-content | ✅ | flex-start(start)/flex-end(end)/center/stretch/space-between/space-around/space-evenly |
align-self | ✅ | auto/flex-start(start)/flex-end(end)/center/baseline/stretch |
flex-grow / flex-shrink | ✅ | 非負の<number>(負値は無効な宣言として無視) |
flex-basis | ⚠️ | auto/content/<length-percentage>。contentはautoと同一視 |
flex(ショートハンド) | ✅ | noneおよび<grow> [<shrink>] [<basis>]。CSS仕様の既定値規則(flex: 1のbasisは0%、flex: <width>のgrow/shrinkは1)を再現 |
gap / row-gap / column-gap | ⚠️ | flexコンテナでのみ有効(多段組みのcolumn-gapとしては機能しない) |
order | ❌ | taffy 0.12系が未対応のため |
place-content / place-items / place-self(ショートハンド) | ❌ | 未実装。個別のロングハンドを使う |
justify-items / justify-self | — | flexアイテムには適用されない(下記) |
justify-items/justify-selfがflexで効かないのは仕様
CSS Box Alignmentではjustify-items/justify-selfはGrid・ブロックレイアウト用のプロパティで、flexアイテムには適用されない(主軸方向のアイテム個別の配置はjustify-contentとmargin: autoで表現する、という設計)。
ブラウザも無視する。
レイアウトを委譲しているtaffyでも、これらを参照するのはGridのアルゴリズムだけで、flexboxのアルゴリズムは一切参照しない。
したがってこのエンジンでパースに対応しても見た目は変わらないため、意図的に実装していない。
flexで特定のアイテムだけを寄せたい場合はmargin: autoを使う(こちらは対応済み):
.item { margin-left: auto; } /* justify-self: end 相当(主軸の終端へ) */
.item { margin-left: auto; margin-right: auto; } /* justify-self: center 相当 */
Grid
display: gridのレイアウトはFlexboxと同じくtaffyへ委譲する。
Flexboxと違い、1ページに収まらないグリッドは行単位でページ分割される(テーブルと同じ方針。複数行にまたがるアイテムがある境界では分割しない)。
| プロパティ | 対応 | 備考 |
|---|---|---|
grid-template-columns / grid-template-rows | ✅ | none/<length>/<percentage>/fr/auto/min-content/max-content/minmax()/fit-content()/repeat(<整数>|auto-fill|auto-fit)/[name](ライン名)。トラックサイズにcalc()は非対応 |
grid-template-areas | ✅ | 文字列マトリクス。列数の不一致・非矩形のエリアは不正な値として宣言ごと無視する(仕様通り)。.は名前なしセル |
grid-auto-columns / grid-auto-rows | ✅ | <track-size>+ |
grid-auto-flow | ✅ | row/column/dense(併記可) |
grid-row-start / -end / grid-column-start / -end | ✅ | auto/<integer>/span <integer>/<custom-ident>/span <custom-ident> |
grid-row / grid-column / grid-area | ✅ | /区切りのショートハンド。grid-area: <name>で名前付きエリアを指定できる |
justify-items / justify-self | ✅ | Gridでのみ意味を持つ(flexアイテムには適用されない、上記) |
align-items / align-self / justify-content / align-content / gap | ✅ | Flexboxと共有(値の範囲はFlexbox節を参照) |
grid / grid-template(ショートハンド) | ❌ | 個別のロングハンドを使う。トラック定義とエリア定義を1つの構文へ詰め込む複雑な文法のため非対応 |
display: inline-grid | ❌ | inline-flexと同じ理由で非対応 |
| subgrid / masonry | ❌ | 未実装 |
置換要素(画像)
| プロパティ | 対応 | 備考 |
|---|---|---|
object-fit | ✅ | fill/contain/cover/none/scale-down。<img>にのみ意味を持つ |
object-position | ✅ | background-positionと同じ値文法。初期値50% 50% |
<img>はインライン配置・width/height属性/CSSによるサイズ指定に対応。
対応フォーマットはPNG/JPEG/WebP。
CSSでwidth/heightの片方だけを指定した場合は内在アスペクト比でもう一方を導出する(aspect-ratio、)。
非対応プロパティ一覧
以下は宣言ごと無視される(パースエラー)。
実装が無いだけで、意図的に永久除外と決めたものだけではない。
カテゴリ表の❌行も参照。
- ショートハンド:
font/inset/place-content/place-items/place-self/text-decorationの色・線種部分 - 論理プロパティ:
inline-size/block-size/margin-inline/padding-block/border-inline等すべて - 書字方向:
direction/unicode-bidi/writing-mode/text-orientation/text-combine-upright - テキスト詳細:
text-decoration-color/-style/-thickness/text-underline-offset/text-emphasis-skip/tab-size/ruby-*/text-justify/line-break - フォント詳細:
font-variant/font-stretch/font-feature-settings/font-variation-settings/font-kerning/font-display - 多段組み:
columns/column-count/column-width/column-rule/column-span/column-fill - 視覚効果:
filter/backdrop-filter/mix-blend-mode/background-blend-mode/clip/clip-path/mask/isolation - 3D/アニメーション:
perspective/transform-style/backface-visibility/translate/rotate/scale(個別プロパティ版)/transition-*/animation-*/will-change - 枠線・背景の拡張:
border-image-*/background-clip/background-origin/outline-offset - オーバーフロー:
overflow-x/overflow-y/resize/scroll-*/overscroll-behavior - UI/対話:
cursor/pointer-events/user-select/caret-color/accent-color/appearance - その他:
all/content-visibility/counter-set/page(名前付きページ)/speak等の音声メディア系/zoom
セレクタ・値・at-rule対応表
セレクタ
マッチングはServo由来のselectorsクレートに委譲しているため、CSS3セレクタは概ねそのまま使える。
| セレクタ | 対応 | 備考 |
|---|---|---|
タイプ(p)・ユニバーサル(*) | ✅ | |
クラス(.foo)・ID(#foo) | ✅ | |
属性([a]/[a=v]/[a^=v]/[a$=v]/[a*=v]/[a~=v]/[a|=v]) | ✅ | 大文字小文字を無視するiフラグも使える |
子孫(空白)・子(>) | ✅ | |
隣接兄弟(+)・一般兄弟(~) | ✅ | |
セレクタリスト(,) | ✅ | |
名前空間(ns|E) | ❌ | @namespace自体が非対応 |
擬似クラス
| 擬似クラス | 対応 | 備考 |
|---|---|---|
:root | ✅ | |
:first-child / :last-child / :only-child | ✅ | ストリーミングモードでは:last-childが常に非マッチ(下記参照) |
:nth-child() / :nth-last-child() | ✅ | 同上(:nth-last-child()はストリーミングモードで非マッチ) |
:first-of-type / :last-of-type / :only-of-type / :nth-of-type() / :nth-last-of-type() | ✅ | 同上 |
:empty | ✅ | 同上 |
:not() | ✅ | |
:is() / :where() / :has() | ❌ | パースエラー(セレクタごと無視される) |
:hover / :active / :focus / :focus-within / :focus-visible / :target / :enabled / :disabled / :checked / :visited | ⚠️ | パースは通るが常に非マッチ。対話状態を持たない静的なPDF出力では意味を持たないため |
:link / :any-link | ✅ | hrefを持つ<a>にマッチする |
非対応の擬似クラスがセレクタに含まれるとルール全体が捨てられる(:is()等)。
一方:hoverのようにパースが通るものは、ルールとしては生き残った上でマッチしない。
擬似要素
| 擬似要素 | 対応 | 備考 |
|---|---|---|
::before / ::after | ⚠️ | contentによる生成テキストのみ。ホスト要素の計算スタイルをそのまま流用して描画し、擬似要素専用のボックススタイル(margin/padding/display等)は持たない。ブロック子を持つ要素では生成されない |
::first-letter | ⚠️ | font-family/font-size/font-weight/font-style/color/text-decoration-line/text-transformのみ上書きできる(float・box model系は非対応) |
::first-line | ❌ | パースエラー |
::marker / ::selection / ::placeholder | ❌ | パースエラー |
値・単位・関数
長さ
| 単位 | 対応 |
|---|---|
px / em / rem | ✅ |
mm / cm / in / pt / pc / Q | ✅ |
%(パーセンテージを取るプロパティで) | ✅ |
単位なしの0 | ✅ |
ex / ch / vw / vh / vmin / vmax / lh | ❌ |
絶対単位は1インチ = 96pxとして解釈する(10mmは37.795px)。
印刷向けに寸法を実寸で書けるので、@page { size: 210mm 297mm; margin: 15mm; }のような指定がそのまま通る。
@pageのsizeだけは例外的にページサイズのキーワード(a4/letter等)とlandscape/portraitを受け付ける。
角度(transform専用)
deg / rad / grad / turn、および単位なしの0に対応 ✅。
関数
| 関数 | 対応 | 備考 |
|---|---|---|
calc() | ⚠️ | +/-/*//と括弧のネスト。項に使えるのは長さ(絶対単位・em/rem)・%・数値のみ。長さ・パーセンテージを取るプロパティ全般で使える |
min() / max() / clamp() | ❌ | |
var() | ⚠️ | カスタムプロパティ(--foo)をパース前のテキスト置換で解決する。フォールバック(var(--x, 10px))・カスタムプロパティ同士の参照に対応。カスケードや継承には従わず、文書全体で「最後に書かれた宣言が勝つ」単純な解決になる点が本来の仕様と異なる |
url() | ✅ | background-image/list-style-image/@font-faceのsrc/@import。相対URLは<base href>または入力元を基準に解決する |
attr() | ⚠️ | contentの中でのみ使える |
counter() / counters() | ✅ | contentの中。第2引数のスタイルはlist-style-typeの値集合 |
linear-gradient()等のグラデーション | ❌ | |
env() / image-set() / element() | ❌ |
色
| 記法 | 対応 |
|---|---|
名前付き色(red等のCSS色キーワード) | ✅ |
#rgb / #rgba / #rrggbb / #rrggbbaa | ✅ |
rgb() / rgba()(カンマ区切り・スペース区切りとも) | ✅ |
hsl() / hsla() / hwb() | ✅ |
lab() / lch() / oklab() / oklch() | ✅ (sRGBへ変換して描画) |
currentcolor / transparent | ✅ |
color()(color(display-p3 ...)等) | ❌ |
color-mix() / 相対色構文(rgb(from ...)) | ❌ |
アルファ付きの色は塗り・背景ともPDFのExtGStateで透過描画する。
at-rule
| at-rule | 対応 | 備考 |
|---|---|---|
@media | ⚠️ | メディアタイプのみを評価する。screen(およびnot screen以外の否定)はブロックごと無視し、print/all/タイプ省略は適用する。(min-width: ...)のような特性クエリは評価せず読み飛ばす(=タイプさえ合致すれば中身が適用される) |
@page | ⚠️ | size(キーワード/<length>{1,2}/landscape/portrait)とmargin系に対応。:first/:left/:right擬似クラス単体に対応し、名前付きページ(@page intro)・:blank・複合擬似クラス(:first:left)は非対応 |
@page内のmargin box | ⚠️ | @top-left-corner/@top-left/@top-center/@top-right…の16種すべて。contentのテキスト描画のみで、背景色・枠線等の装飾は非対応。counter(page)/counter(pages)でページ番号を出力できる(counter(pages)はストリーミングモードでは非対応) |
@font-face | ⚠️ | font-family/src/unicode-range/font-weight/font-styleディスクリプタに対応(srcのlocal()・format()/tech()付きurl()も受理)。font-display等その他のディスクリプタは無視。フォントファイルはTTF/OTFのみで、WOFF/WOFF2は非対応 |
@import | ✅ | ネスト(深さ上限16、超過分はその1件だけ無視)・循環参照の検出に対応。@import url(...) screen;のようなメディアクエリ条件は評価せず常に取り込む |
@charset | ❌ | 入力はUTF-8前提 |
@supports / @keyframes / @namespace / @counter-style / @layer / @container / @property | ❌ | ブロックごと無視される |
ストリーミングモード固有の制約
Mode::Batch(一括処理)ではDOM全体が揃っているため下記の制約は無い。
Mode::Streamingでのみ以下が適用される。
- 後方参照セレクタは常に非マッチ:
:last-child/:last-of-type/:nth-last-child()/:nth-last-of-type()/:empty(対象要素の親の子リストが完結するまで原理的に判定できないため) <body>開始後の<style>タグはエラー:EngineError::UnsupportedInStreamingModeを返す。 黙って見た目が崩れるのを避けるため、<style>は<head>に集約するposition: absolute/fixedは無視されるcounter(pages)(総ページ数)は使えない
フォント
PDFは文書の中にフォントを埋め込みます。 ブラウザと違って「見る人の環境にあるフォントで表示する」ということができないため、どのフォントを使うかは変換時に決まります*
フォントが決まる順番
- CLIの
--font(および--gothic-font/--serif-font/--mono-font) - CSSの
@font-face font-familyに書かれた名前でのシステムフォント探索- 文書中の文字を描画できるフォントのシステム探索
どれでも1つも見つからなかった場合だけ、システムのsans-serif候補が既定フォントになります。
4番目は、font-familyをどこにも書いていない日本語文書のように、名前が手掛かりにならない場合の網です。
1〜3で集めたフォントで描画できない文字が文書に含まれていれば、その文字を持つシステムフォント(日本語ならNoto Sans CJK JPなど)を探して追加します。
この探索はウェイト・スタイルごとに行うので、太字と通常が混在する文書では両方の面が追加されます(通常の文字が太字の面で描かれてしまうのを防ぐためです)。
それでも描画できない文字が残る場合は、豆腐(□)になる前に警告を出します。
警告: 文字 "ไ" を描画できるフォントがありません(豆腐になります)。
--font/--gothic-font か @font-face でフォントを明示してください
サーバやCIでは
--fontを明示してください。 指定しないと出力が実行環境のフォント構成に依存します。 同じHTMLが開発機と本番で違う見た目になる、という事故はここから起きます。
汎用ファミリー名
serif / sans-serif / monospaceはシステムフォントから解決されます(cursive / fantasyは解決しません)。
日本語では、この解決を環境任せにすると本文の書体が変わってしまうので、CLIから決定的に指定できます。
sghtmltopdf invoice.html \
--gothic-font NotoSansJP-Regular.ttf \ # font-family: sans-serif の実体
--serif-font NotoSerifJP-Regular.ttf \ # font-family: serif の実体
--mono-font NotoSansMono-Regular.ttf # font-family: monospace の実体
TrueType Collection(.ttc)を使う場合は、直前の--font系オプションに対して--font-indexでフェイス番号を指定します。
@font-face
@font-face {
font-family: "MyFont";
src: url("fonts/MyFont-Regular.ttf");
font-weight: 400;
font-style: normal;
}
body { font-family: "MyFont", sans-serif; }
対応するディスクリプタはfont-family / src / unicode-range / font-weight / font-styleです。
srcのlocal()と、format()/tech()付きのurl()も受け付けます。
font-displayなどその他のディスクリプタは無視されます。
フォントファイルはTTF/OTFのみです。WOFF/WOFF2は非対応なので、Webで配信しているwebfontをそのまま指すとエラーになります。 元のTTF/OTFを使ってください。
読み込みの待ち合わせはありません。
headless Chromeで必要だったdocument.fonts.ready待ちのような処理は不要で、フォントが未解決のままPDF化されることはありません。
unicode-range
文字の範囲ごとにフォントを切り替えられます。 英数字は欧文フォント、日本語は和文フォント、という典型的な構成がそのまま書けます。
@font-face {
font-family: "Mixed";
src: url("fonts/Latin.ttf");
unicode-range: U+0-24F, U+1E00-1EFF;
}
@font-face {
font-family: "Mixed";
src: url("fonts/JP.ttf"); /* 上の範囲外はこちら */
}
- 単一コードポイント・範囲・ワイルドカード(
U+4??)・カンマ区切りの複数指定に対応します - 宣言された範囲はハードフィルタとして働きます。範囲外の文字には、そのフォントが実際にグリフを持っていても使いません
unicode-rangeを書かなかったフォント(local()・--font・システム探索を含む)は全域をカバーします- 範囲が重なった場合は、CSSの中で先に宣言されたほうが優先されます
太字と斜体
| 指定 | 挙動 |
|---|---|
font-weight | normal/bold/100〜900。数値は600以上をboldとみなす2値化。太字のフォントが無い場合は、塗りに縁取りを足した疑似ボールドで描画します |
font-style | normal/italic/oblique(obliqueはitalicと同一視)。イタリック字形が無い場合は、テキスト行列のせん断による疑似イタリックになります |
fontショートハンドは非対応です。
font-size・font-familyなどのロングハンドを個別に書いてください。
サブセット化
埋め込まれるのは実際に使ったグリフだけです。 日本語フォントを丸ごと指定しても、PDFのサイズは文書に出てくる文字の分にしかなりません。
ストリーミングモードでの注意
ストリーミングモードでは、文書全体を一度に持たないため
上の3・4のシステムフォント探索が行われません(警告を出して既定フォントで描画します)。
--font系オプションか@font-faceで明示すれば、ストリーミングでも意図どおりのフォントになります。
例外として、フォントを1つも指定しなかった場合だけは、既定フォント(ラテン)に加えてCJKを描画できるフォントを1本先回りで読み込みます。 日本語の文書を何も指定せずストリーミングで変換しても豆腐にならないのはこのためです。 CJK以外のスクリプトは警告の対象になります。
画像
<img>とCSSのbackground-imageで画像を埋め込めます。
| 対応フォーマット | PNG / JPEG / WebP |
|---|---|
srcに書けるもの | ローカルの相対パス・絶対パス、http(s)のURL、data: URI |
SVGとGIFは非対応です。
<img>
<img src="logo.png" width="120">
<img src="https://example.com/chart.png" alt="売上推移">
<img src="data:image/png;base64,iVBORw0…">
<img>はインラインの置換要素として行に載ります。独立した行にしたい場合はdisplay: blockを指定してくださいwidth/height属性とCSSのwidth/heightに対応します。どちらも無指定なら画像の内在サイズを使い、片方だけ指定すればアスペクト比を保って他方を導出します- 取得やデコードに失敗した画像は、その要素だけ空として扱い、文書全体の生成は止めません(
--load-media-error-handling abortで中断させることもできます) - 同じ画像を何度使っても、取得・デコード・PDFへの埋め込みは初回の1回だけです
object-fit / object-position
指定した枠に対して画像をどう収めるかを制御します。
img.thumb {
width: 120px;
height: 80px;
object-fit: cover; /* fill | contain | cover | none | scale-down */
object-position: 50% 50%;
}
背景画像
.watermark {
background-image: url("stamp.png");
background-position: center;
background-size: contain;
background-repeat: no-repeat;
}
background-imageに書けるのはurl()だけです。
linear-gradient()などのグラデーション関数と、カンマ区切りの複数背景は非対応です。
既定では画像の内在サイズでタイル配置されます。
border-radiusと背景画像を併用した場合、角丸によるクリップは行われません(角丸は背景色の塗りにのみ効きます)。
リモート画像の取得
既定では無効です。
--allow-remote-assetsで明示的に有効化します。
sghtmltopdf report.html --allow-remote-assets
有効にした場合も、グローバルに到達可能でない宛先へのリクエストは常にブロックされます。 判定は「グローバルなユニキャストだけを通す」方針で、次を拒否します。
| 種別 | 範囲 |
|---|---|
| ループバック | 127.0.0.0/8、::1 |
| プライベート | 10/8、172.16/12、192.168/16、fc00::/7 |
| リンクローカル | 169.254/16(クラウドのメタデータ169.254.169.254を含む)、fe80::/10 |
| CGNAT | 100.64.0.0/10(クラウドの内部ロードバランサ等) |
| その他の非グローバル | 0.0.0.0/8、192.0.0.0/24、198.18.0.0/15、240.0.0.0/4、マルチキャスト、ドキュメント用 |
| IPv6の特殊用途 | Teredo 2001::/32、2001:db8::/32、ORCHIDv2 2001:20::/28、100::/64 |
IPv4を埋め込むIPv6表記(IPv4-mapped ::ffff:a.b.c.d、IPv4-compatible ::a.b.c.d、NAT64 64:ff9b::/96、6to4 2002::/16)は、埋め込まれたIPv4側で判定します。
これらを素通しするとIPv4側のフィルタを迂回できてしまうためです。
判定は名前解決の結果に対して行うため、DNSリバインディングやリダイレクト経由の迂回も同じ仕組みで防いでいます。
ポート番号は制限しません。
内部サービスはプライベートIP上にあり、そこは上の判定で塞がっています。
公開IPに対する非標準ポート(CDNやAPIの8080など)は正当な用途があるため、塞ぐと実用を損なうわりに得るものがありません。
信頼できないHTMLを変換する場合は、--allowでローカル参照の範囲も併せて絞ってください。
sghtmltopdf untrusted.html --allow /var/app/assets
JPEGはそのまま埋め込まれる
JPEGはデコードせず、サイズ情報だけを読んでPDFへそのまま(DCTDecodeとして)埋め込みます。 再エンコードしないので画質は落ちず、変換も速くなります。
その代わり、--grayscaleを指定してもJPEGとCMYK画像はカラーのまま残ります(デコーダを持たないため)。
グレースケール化が必要な場合は、変換前の画像をグレースケールにしておいてください。
PNGとWebPはフルデコードし、アルファチャンネルがあれば透過画像として埋め込みます。
画像を一切読み込まない
sghtmltopdf invoice.html --no-images
<img>とCSSのbackground-imageの両方を読み込まなくなります。
ページ分割
sghtmltopdfはCSS Fragmentationのプロパティでページ分割を制御します。
明示的な改ページ
.chapter { break-before: page; } /* この要素の前で改ページ */
.summary { break-after: page; } /* この要素の後で改ページ */
.card { break-inside: avoid; } /* この要素をページ境界で割らない */
| プロパティ | 受け付ける値 |
|---|---|
break-before / break-after | auto / avoid(avoid-page・avoid-columnも同義) / always(pageも同義) |
break-inside | auto / avoid(同上) |
古いpage-break-before / page-break-after / page-break-insideもエイリアスとして受け付けます。
wkhtmltopdfやwicked_pdf向けに書いた資産をそのまま持ち込めます。
left/right/recto/verso(見開き制御)と多段組み関連の値は非対応です。
CSSを書かずに指定する
HTML属性でも書けます。
<div data-page-break="before">…</div>
<div data-page-break="after">…</div>
<div data-page-break="avoid">…</div>
こちらは弱い優先度のヒントとして扱われるので、スタイルシートのルールで個別に上書きできます。
段落が泣き別れないようにする
p {
orphans: 3; /* ページ末尾に最低3行は残す */
widows: 3; /* 次ページの先頭に最低3行は送る */
}
1以上の整数で、初期値はどちらも2です。 指定を満たせない場合は段落ごと次のページへ送られます。
@page — 用紙とページ余白
@page {
size: A4;
margin: 20mm;
@top-center { content: "請求書"; }
@bottom-center { content: counter(page) " / " counter(pages); }
}
@page :first {
@bottom-center { content: "表紙"; }
}
sizeはページサイズのキーワード(A4/Letter等)・<length>{1,2}・landscape/portraitを受け付けます- CLIのページ設定オプションより
@pageが優先されます(CLI側は初期値) - margin box(
@top-left-corner〜@bottom-right-cornerの16種)にはcontentでテキストを置けます。背景色や枠線などの装飾は非対応です counter(page)は現在のページ番号、counter(pages)は総ページ数です
@pageの制約
size/marginはページごとに変えられません。:first/:left/:right付きのsize/margin宣言はパースされますが適用されず、これらの擬似クラスは margin boxの 内容の出し分けにだけ使えます- 名前付きページ(
@page introとpage: intro)は非対応です - margin boxの寸法は簡略化しています。4隅は縦横マージンの交差部分で固定、残り12個は各辺を3等分した均等割りです(
width指定は無視されます) counter(pages)はストリーミングモードでは使えません(総ページ数が1パスでは決まらないため)
CLIの--header-centerなどのオプションは、内部的にこの@pageのmargin boxへマップされます。
両方書いた場合はCSSが勝ちます。
テーブルのページ分割
1ページに収まらないテーブルは行単位で分割され、複数ページへ流れます。
<thead>の行は2ページ目以降の先頭に繰り返されます。複数行の見出しにも対応し、1ページに収まる表では複製しません<tfoot>はソース順に関わらずテーブル末尾へ移動しますが、各ページ下端への繰り返しは行いません(最終ページに1回だけ出ます)captionはcaption-sideに従って、最初(top)または最後(bottom)の断片に付きます- 各断片はテーブル自身の背景・枠線を引き継ぎ、
border-collapse: collapseの枠線統合もページ内でそのまま効きます
既知の限界として、rowspanが分割点をまたぐセルは開始行の断片に属し、下部がページからはみ出します(クリップされません)。
行単位のbreak-inside: avoidとorphans/widows相当も未対応です。
FlexboxとGridの扱い
| レイアウト | ページ分割 |
|---|---|
display: flex | アトミック。途中で分割せず、収まらなければ次ページへ送る |
display: grid | 行単位で分割する(複数行にまたがるアイテムがある境界では分割しない) |
display: table | 行単位で分割する(上記) |
大きなカードを並べる用途では、flexコンテナが丸ごと次ページへ飛ぶことがあります。 分割してほしい場合はGridかテーブルを使ってください。
よく使う書き方
見出しが単独でページ末尾に残らないようにする:
h2, h3 {
break-after: avoid; /* 見出しの直後で改ページしない */
break-inside: avoid;
}
明細の1件が2ページに割れないようにする:
.line-item { break-inside: avoid; }
章ごとに必ず改ページする:
section.chapter + section.chapter { break-before: page; }
Note
ストリーミングモードでは
:last-childなどの後方参照セレクタが常に非マッチになります。 上の例の+(隣接兄弟)は使えます。
wkhtmltopdfからの移行
オプション名の多くはwkhtmltopdfと同じです。 ただし同じ名前でも結果が違うものがいくつかあるので、確認してください。
全オプションの対応状況はwkhtmltopdfオプション対応表にあります。 Railsでwicked_pdf経由で使っている場合はwicked_pdfからの移行を参照してください。
挙動が違うところ
| wkhtmltopdf | sghtmltopdf | |
|---|---|---|
CLIオプションとCSSの@page | CLIが勝つ | @pageが勝つ(CLIは初期値) |
| マージンの既定値 | 左右10mm | 四辺1in(96px) |
| 表紙・目次の指定 | 位置引数(cover a.html toc) | --cover <PATH> / --toc |
| 複数HTMLの結合 | できる | できない(入力は1つ) |
| ヘッダー/フッターのページ変数 | JavaScriptで差し込み | プレースホルダ置換(JSは実行しない) |
| フォント | システムフォント | 同じ(--fontで明示もできる) |
| 非対応オプション | 黙って無視されることがある | 理由を示してexit 1で止まる |
@pageが勝つ
もっとも引っかかりやすい違いです。
CSSに@page { margin: 0 }と書いてあると、--margin-top 20mmは無視されます。
@page { size: A4; margin: 20mm; } /* こちらが勝つ */
CLIオプションは「CSSに書かれていなかったときの初期値」として働きます。
CLIで制御したい場合は、HTMLから@pageの該当プロパティを外してください。
マージンの既定値
wkhtmltopdfは左右10mm、sghtmltopdfは四辺1インチ(96px = 25.4mm)です。 指定なしで変換すると余白が変わるので、既存の見た目を保つには明示します。
sghtmltopdf in.html -o out.pdf \
--margin-top 10mm --margin-bottom 10mm --margin-left 10mm --margin-right 10mm
表紙と目次
wkhtmltopdf cover cover.html toc page.html out.pdf # wkhtmltopdf
sghtmltopdf --cover cover.html --toc page.html -o out.pdf # sghtmltopdf
目次の見た目はwkhtmltopdfの既定TOC XSLの出力に合わせてあります。
XSLTは非対応なので、変更は--user-style-sheetのCSSで行います。
ヘッダー/フッターのページ番号
wkhtmltopdfは--header-htmlのURLにクエリ(?page=1&topage=5)を付け、ページ側のJavaScriptで差し込む方式でした。
JSを実行しないので、sghtmltopdfはプレースホルダの文字列置換に置き換えています。
sghtmltopdf report.html --footer-center "[page] / [topage]"
HTMLでヘッダーを作る場合も、HTMLのテキストとして[page]が置換されます。
非対応のオプションは黙って無視しない
指定すると理由と代替手段を示してexit 1で終了します。
移行時に「オプションが効いていないことに気づかない」事故を避けるためです。
主なものは以下です。
- JavaScript関連:
--enable-javascript・--javascript-delay・--run-script・--window-status・--debug-javascript・--stop-slow-scripts(JS実行は設計上の非目標) - PDFアウトライン:
--outline・--outline-depth・--dump-outline - XSLT:
--xsl-style-sheet・--dump-default-toc-xsl(目次は内蔵テンプレート + CSSで代替) - 画像の再エンコード:
--image-quality・--image-dpi - ネットワーク:
--proxy・--cookie・--custom-header・--username/--password・--ssl-* - WebKit固有:
--disable-smart-shrinking・--viewport-size・--lowquality・--print-media-type(常に印刷メディア扱い) - PDFフォーム:
--enable-forms
HTML/CSS側で必要になる調整
エンジンが別物なので、CSSの対応範囲も違います。 移行時によく当たるのは次の3つです。
!importantが使えません。付いた宣言は無視されますinherit/initial/unsetが使えません- ビューポート単位(
vw/vh等)とex/ch/lhが使えません。長さはpx/em/remと絶対単位(mm/cm/in/pt/pc/Q)で書いてください
詳しくはセレクタ・値・at-ruleを参照してください。
移行できたか確かめる
まずは--log-level info(既定)のまま変換し、警告が出ないことを確認します。
非対応オプションはexit 1で止まるので、変換が通った時点でオプションはすべて解釈されています。
あとは出力を目視で比べて、余白・改ページ位置・フォントを確認してください。
wkhtmltopdfオプション対応表
wkhtmltopdf 0.12.6の--extended-help(公式マニュアルhttps://wkhtmltopdf.org/usage/wkhtmltopdf.txt)に載る全オプションの対応状況です。
セクション区切りと並びは公式マニュアルに合わせてあります。
| 記号 | 意味 |
|---|---|
| ✅ 対応 | wkhtmltopdfと同じ名前・同じ意味で使える |
| ❌ 非対応 | 実装しない。指定するとと代替メッセージを出力してexit 1で終了する(黙って無視しない) |
移行時に引っかかりやすい挙動の違いはwkhtmltopdfからの移行にまとめてあります。
コマンドライン形式の違い
wkhtmltopdfは表紙と目次を位置引数で指定します。
wkhtmltopdf cover cover.html toc page.html out.pdf # wkhtmltopdf
sghtmltopdf --cover cover.html --toc page.html -o out.pdf # sghtmltopdf
sghtmltopdfは入力を1つのHTMLに限定し、表紙・目次は--cover <path>・--tocオプションで指定します。
複数HTMLの結合(wkhtmltopdfが位置引数を並べてできること)には対応していません。
そのため、位置引数由来の--exclude-from-outline/--include-in-outline(入力ページ単位で目次から除外する)も対象外です。
Global Options
| オプション | 方針 | 備考 |
|---|---|---|
--collate / --no-collate | ❌ 非対応 | 印刷時の丁合。PDF生成に意味を持たない |
--cookie-jar <path> | ❌ 非対応 | 認証付きフェッチはスコープ外 |
--copies <number> | ❌ 非対応 | 同上(印刷用) |
-d, --dpi <dpi> | ✅ 対応 | 既定96 |
-H, --extended-help | ❌ 非対応 | --helpに一本化する |
-g, --grayscale | ✅ 対応 | |
-h, --help | ✅ 対応 | clapが生成 |
--htmldoc / --manpage / --readme / --license | ❌ 非対応 | ドキュメントはdocs/とREADMEで提供する |
--image-dpi <integer> | ❌ 非対応 | 画像のリサンプリングを行わないため |
--image-quality <integer> | ❌ 非対応 | JPEGデコーダ/エンコーダを持たず、そのまま埋め込むため |
--log-level <level> | ✅ 対応 | none/error/warn/info |
-l, --lowquality | ❌ 非対応 | WebKitのラスタライズ品質設定に相当するものが無い |
-B, --margin-bottom <unitreal> | ✅ 対応 | |
-L, --margin-left <unitreal> | ✅ 対応 | 既定値が違う(下記) |
-R, --margin-right <unitreal> | ✅ 対応 | 既定値が違う(下記) |
-T, --margin-top <unitreal> | ✅ 対応 | |
-O, --orientation <orientation> | ✅ 対応 | Portrait/Landscape |
--page-height <unitreal> | ✅ 対応 | |
-s, --page-size <Size> | ✅ 対応 | A4/A3/A5/Letter/Legal |
--page-width <unitreal> | ✅ 対応 | |
--no-pdf-compression | ✅ 対応 | 現状は常時Flate圧縮 |
-q, --quiet | ✅ 対応 | --log-level noneと同義 |
--read-args-from-stdin | ❌ 非対応 | stdinはHTML入力に使うため衝突する |
--title <text> | ✅ 対応 | PDF Info辞書。未指定時は<title>を採用 |
--use-xserver | ❌ 非対応 | Xサーバに依存しない |
-V, --version | ✅ 対応 |
マージンの既定値: wkhtmltopdfは左右10mm・上下未指定だが、sghtmltopdfの現在の既定は四辺96px(=1in=25.4mm)。
既存の出力を変えないため既定値は変更しない。
移行時は--margin-*を明示すること。
Outline Options
PDFのアウトライン(ブックマーク)自体が非対応のため、このセクションはすべて非対応です。
文書内の目次は--tocで作れます。
| オプション | 方針 | 備考 |
|---|---|---|
--outline / --no-outline | ❌ 非対応 | PDFブックマーク未実装 |
--outline-depth <level> | ❌ 非対応 | 同上 |
--dump-outline <file> | ❌ 非対応 | 同上 |
--dump-default-toc-xsl | ❌ 非対応 | XSLTを使わない |
Page Options
| オプション | 方針 | 備考 |
|---|---|---|
--allow <path> | ✅ 対応 | ローカル読み込みを許可するディレクトリ。サーバモードでは特に重要 |
--background / --no-background | ✅ 対応 | |
--bypass-proxy-for <value> | ❌ 非対応 | プロキシ非対応 |
--cache-dir <path> | ❌ 非対応 | フェッチキャッシュは持たない(必要になれば別途) |
--checkbox-checked-svg / --checkbox-svg / --radiobutton-checked-svg / --radiobutton-svg | ❌ 非対応 | SVG描画が非対応。フォーム要素の見た目は内蔵の描画で再現する |
--cookie <name> <value> | ❌ 非対応 | 認証付きフェッチはスコープ外 |
--custom-header <name> <value> / --custom-header-propagation | ❌ 非対応 | 同上 |
--debug-javascript / --no-debug-javascript | ❌ 非対応 | JS非対応 |
--default-header | ✅ 対応 | ページ名+番号の既定ヘッダ。簡易オプションのショートカット |
--encoding <encoding> | ✅ 対応 | 判定順は BOM > --encoding > <meta charset> > UTF-8 |
--disable-external-links / --enable-external-links | ✅ 対応 | リンク注釈 |
--disable-forms / --enable-forms | ❌ 非対応 | 入力可能なPDFフォーム(AcroForm)は作らない |
--images / --no-images | ✅ 対応 | |
--disable-internal-links / --enable-internal-links | ✅ 対応 | |
-n, --disable-javascript / --enable-javascript | ❌ 非対応 | JS実行は設計上の非目標 |
--javascript-delay <msec> | ❌ 非対応 | 同上 |
--keep-relative-links / --resolve-relative-links | ✅ 対応 | リンク注釈のURL解決。<base href>と併せて実装 |
--load-error-handling <handler> | ✅ 対応 | abort/ignore。skipは入力が1つなので無し |
--load-media-error-handling <handler> | ✅ 対応 | 画像・フォント・CSSの取得失敗 |
--disable-local-file-access / --enable-local-file-access | ✅ 対応 | 既存--allow-remote-assetsと整理 |
--minimum-font-size <int> | ✅ 対応 | |
--exclude-from-outline / --include-in-outline | ❌ 非対応 | 入力が1つなので意味を持たない |
--page-offset <offset> | ✅ 対応 | ページ番号の起点 |
--password / --username | ❌ 非対応 | HTTP認証はスコープ外 |
--disable-plugins / --enable-plugins | ❌ 非対応 | プラグイン機構が無い |
--post <name> <value> / --post-file <name> <path> | ❌ 非対応 | URL入力時のPOSTはスコープ外 |
--print-media-type / --no-print-media-type | ❌ 非対応 | 常に印刷メディア扱い |
-p, --proxy <proxy> / --proxy-hostname-lookup | ❌ 非対応 | プロキシ非対応 |
--run-script <js> | ❌ 非対応 | JS非対応 |
--disable-smart-shrinking / --enable-smart-shrinking | ❌ 非対応 | WebKit固有の縮小戦略 |
--ssl-crt-path / --ssl-key-password / --ssl-key-path | ❌ 非対応 | クライアント証明書はスコープ外 |
--stop-slow-scripts / --no-stop-slow-scripts | ❌ 非対応 | JS非対応 |
--disable-toc-back-links / --enable-toc-back-links | ✅ 対応 | 見出し→目次への逆リンク |
--user-style-sheet <path> | ✅ 対応 | ユーザーオリジンのCSS |
--viewport-size <size> | ❌ 非対応 | ビューポート概念が無い |
--window-status <status> | ❌ 非対応 | JS非対応 |
--zoom <float> | ✅ 対応 |
Headers And Footer Options
このセクションはすべて対応しています。
JSを実行しないため、wkhtmltopdfが--header-htmlのURLにクエリ(?page=1&topage=5)を付けてJSで差し込んでいたページ変数は、プレースホルダの文字列置換で実現します。
| オプション | 方針 | 備考 |
|---|---|---|
--header-left / --header-center / --header-right | ✅ 対応 | @pageのmargin boxへマップ |
--footer-left / --footer-center / --footer-right | ✅ 対応 | 同上 |
--header-html <url> / --footer-html <url> | ✅ 対応 | 別のHTMLをレンダリングして余白へ合成する |
--header-line / --no-header-line | ✅ 対応 | |
--footer-line / --no-footer-line | ✅ 対応 | |
--header-spacing <real> / --footer-spacing <real> | ✅ 対応 | mm |
--header-font-name / --header-font-size | ✅ 対応 | |
--footer-font-name / --footer-font-size | ✅ 対応 | |
--replace <name> <value> | ✅ 対応 | ヘッダ/フッタ内の[name]を置換。sghtmltopdfのプレースホルダ方式と直接対応する |
wkhtmltopdfの組み込みプレースホルダのうち、使えるのは[page]・[frompage]・[topage]・[date]・[time]・[title]/[doctitle]です。
[section]/[subsection](直近の見出し)と[webpage]/[sitepage]/[sitepages](複数入力向け)は非対応です。
--replaceで任意の名前を定義できます。
TOC Options
生成される目次のHTML構造と既定スタイルは、wkhtmltopdfの既定TOC XSLの出力に合わせてあります。
階層は入れ子の<ul>、各項目は<li><div><a>見出し</a><span>ページ番号</span></div></li>です。
| オプション | 方針 | 備考 |
|---|---|---|
--toc-header-text <text> | ✅ 対応 | 既定“Table of Contents“。<h1>のテキスト |
--toc-level-indentation <width> | ✅ 対応 | 既定1em。ul { padding-left } |
--toc-text-size-shrink <real> | ✅ 対応 | 既定0.8。ul ul { font-size: 80% } |
--disable-dotted-lines | ✅ 対応 | divのborder-bottom: dashedを出さない |
--disable-toc-links | ✅ 対応 | 目次→見出しのリンク(<a href>)を出さない |
--xsl-style-sheet <file> | ❌ 非対応 | XSLT非対応。見た目の変更は--user-style-sheetで行う |
sghtmltopdf独自のオプション(wkhtmltopdfに無い)
| オプション | 内容 |
|---|---|
--font <path> / --font-index <N> | フォントの明示指定(複数可、任意)。省略時はシステムフォントを使う |
--gothic-font / --mono-font / --serif-font(+-index) | 汎用family名(sans-serif/monospace/serif)の実体指定 |
--allow-remote-assets | http(s)絶対URLのフェッチ許可 |
--streaming | ストリーミングモードで処理する |
--base-url <url|dir> | stdin入力時などの相対解決の基準 |
--author / --subject / --keywords | PDF Info辞書(wkhtmltopdfは--titleのみ) |
--cover <path> / --toc | 表紙・目次(wkhtmltopdfは位置引数) |
server サブコマンド | HTTPサーバモード |
wicked_pdfからの移行ガイド
wicked_pdf(+ wkhtmltopdf)を使っているRailsアプリを、sghtmltopdfのgemへ移すための対応表と注意点。
wkhtmltopdfのオプションの対応状況はwkhtmltopdfオプション対応表とwkhtmltopdfからの移行を参照してください。 ここでは「Rails/Rubyから見た違い」だけを扱います。
最小の置き換え
# Gemfile
- gem "wicked_pdf"
- gem "wkhtmltopdf-binary"
+ gem "sghtmltopdf"
コントローラはそのまま動くはずです。
def show
respond_to do |format|
format.pdf { render pdf: "invoice", template: "invoices/show", layout: "pdf" }
end
end
外部プロセスの起動が無くなるので、wicked_pdfのexe_path(wkhtmltopdfのバイナリの場所)の設定は不要になる。
設定の置き場所
# config/initializers/sghtmltopdf.rb
Sghtmltopdf.configure do |c|
c.page_size = "A4"
c.margin_top = "20mm"
c.gothic_font = Rails.root.join("vendor/fonts/NotoSansJP-Regular.ttf")
end
wicked_pdfのWickedPdf.config = {...}に相当する。
マージ順は
グローバル設定 → renderの引数で、後者が勝ちます。
オプション名の対応
wicked_pdfはネストしたHash(margin: {top: 10})を使うが、sghtmltopdfは
CLIのフラグ名をそのままキーにした平坦なHashを使う(_が-に対応する。page_size: → --page-size)。
オプションの定義はRust側の1箇所に集約されていて、Ruby側はホワイトリストを持ちません。
| wicked_pdf | sghtmltopdf | 備考 |
|---|---|---|
pdf: "name" | 同じ | ファイル名(.pdfは自動で付く) |
template: / layout: / locals: / formats: | 同じ | Railsのビュー描画へそのまま渡る |
disposition: / filename: / status: | 同じ | 既定のdispositionはinline |
show_as_html: true | 同じ | PDFにせずHTMLを返すデバッグ用 |
page_size: "A4" | page_size: "A4" | |
page_height: / page_width: | 同じ | 単位付きの文字列("210mm")で渡す |
orientation: "Landscape" | 同じ | |
margin: {top: 10, bottom: 10} | margin_top: "10mm", margin_bottom: "10mm" | wicked_pdfの数値はmm。単位を明示する |
dpi: / zoom: | 同じ | |
grayscale: true | 同じ | |
background: false | no_background: true | |
encoding: "UTF-8" | 同じ | |
title: | 同じ | PDFのメタデータ |
user_style_sheet: | 同じ | パスの配列も可 |
no_pdf_compression: true | 同じ | |
cover: "shared/cover" | cover: <ファイルパス> | テンプレート名ではなくHTMLファイルのパス(後述) |
toc: {} | toc: true | 見た目はtoc_header_text:などで調整 |
header: {left:, center:, right:} | header_left: / header_center: / header_right: | |
header: {html: {template: "..."}} | header_html: <ファイルパス> | 同上 |
header: {line: true, spacing: 5, font_name:, font_size:} | header_line: true, header_spacing: 5, header_font_name:, header_font_size: | footerも同様 |
outline: {} | — | PDFアウトラインは非対応 |
disable_javascript / javascript_delay / window_status | — | JSは実行しない(設計上の非目標) |
print_media_type | — | 常にprintメディア扱い |
lowquality / viewport_size / disable_smart_shrinking | — | WebKit固有 |
exe_path / wkhtmltopdf | — | 外部プロセスを使わない |
extra | — | 生のコマンドライン文字列は受けない。個別のキーで指定する |
対応していないキーを渡すと、レンダリング時にSghtmltopdf::UsageErrorが理由付きで上がる(黙って無視はしない)。
表紙・ヘッダー・フッターのHTML
wicked_pdfはRailsのテンプレート名を受け取って内部で描画するが、sghtmltopdfの--cover/--header-html/--footer-htmlはファイルのパスを取ります(CLIと同じ経路に合流させるため)。
Railsのテンプレートを使いたい場合は、自分で描画して一時ファイルへ書き出してください。
def show
header = Tempfile.new(["header", ".html"])
header.write(render_to_string(template: "invoices/header", layout: false))
header.flush
render pdf: "invoice", template: "invoices/show", header_html: header.path
ensure
header&.close!
end
ビューヘルパ
| wicked_pdf | sghtmltopdf |
|---|---|
wicked_pdf_stylesheet_link_tag | sghtmltopdf_stylesheet_link_tag |
wicked_pdf_image_tag | sghtmltopdf_image_tag |
wicked_pdf_asset_path | sghtmltopdf_asset_path(見つからなければnil) |
wicked_pdf_javascript_include_tag | — (JSを実行しないので不要) |
wicked_pdf_asset_base64 | — (ローカルファイルを直接読めるので不要) |
素のstylesheet_link_tag/image_tagも、アセットがpublic/配下へprecompileされていればそのまま動きます。
PDFのレンダリングはHTTPサーバを介さないので、/assets/…のようなURLは--base-url(Railsでの既定はRails.root/public)を基準にローカルファイルとして解決される。
開発環境のようにアセットがまだpublic/に無い場合は、CSSの中身を<style>へ展開するsghtmltopdf_stylesheet_link_tagを使う。
<%= sghtmltopdf_stylesheet_link_tag "pdf" %>
既定値の違い
- マージン: wkhtmltopdfは左右10mm。sghtmltopdfは四辺1in(96px)。
同じ見た目にしたければ
margin_*を明示する - CLIオプションとCSSの
@page: wkhtmltopdfはCLIが勝つが、sghtmltopdfは@pageが勝つ(オプションは初期値) - ローカルファイルの参照範囲: Railsでは
--allowの既定がRails.rootになる。アプリの外(例:/usr/share/fonts)のファイルを<img>や@font-faceのurl()で参照している場合は、Sghtmltopdf.configure { |c| c.allow = [Rails.root.to_s, "/usr/share/fonts"] }のように明示する。--font系(gothic_fontなど)で渡すフォントはこの制限を受けない - リモート取得:
http(s)のアセット取得は既定で無効。必要ならallow_remote_assets: true
フォント
wkhtmltopdfはシステムのフォント設定に依存するが、sghtmltopdfはgothic_font/serif_font/mono_fontで指定できる。
日本語を出す場合は、コンテナのフォント事情に左右されないよう明示するのが安全。
Sghtmltopdf.configure do |c|
c.gothic_font = Rails.root.join("vendor/fonts/NotoSansJP-Regular.ttf")
end
別プロセスへ逃がす(wicked_pdfには無い選択肢)
wicked_pdfはリクエストごとにwkhtmltopdfのプロセスを起動するが、sghtmltopdfのgemはアプリのプロセス内で変換する(重い処理の間はGVLを解放するのでPumaの他スレッドは止まらない)。
それでもアプリのCPUを使いたくない場合は、server_urlで別プロセス(sghtmltopdf server)へ委譲できる。
Sghtmltopdf.configure { |c| c.server_url = "http://pdf.internal:8080" }
負荷分散はLB(nginx・k8s Service)を前段に置く前提で、URLは1つだけ受ける。
到達できないときはSghtmltopdf::ServerErrorになり、ローカル変換へはフォールバックしない。
サーバモードではbase_url・allow・フォント指定などローカルパスを取るオプションはリクエストから指定できない(サーバ起動時にだけ設定できる)。
Railtieが入れる既定値は自動的に外れるが、configureで明示的に設定している場合は400(UsageError)になるので、サーバ側の起動オプションへ移す。
まだ無いもの
- PDFの結合・アウトライン: 非対応
なお逐次出力(ストリーミング)はwicked_pdfには無い機能で、ブロック付きのrenderで使える。
ActionController::Liveと組み合わせれば、確定したページから順にレスポンスへ流せます → Ruby / Rails
対応していないこと
個別のCSSプロパティの可否はプロパティ対応表を、wkhtmltopdfのオプション単位の可否はオプション対応表を参照してください。
JavaScriptを実行しない
<script>は読み飛ばされます。
そのため次のような使い方はできません。
- JSでDOMを組み立ててからPDF化する(SPAのページをそのまま出す等)
- Chart.jsなどでクライアント描画したグラフを含める
- JSでページ番号やヘッダーを差し込む(→ プレースホルダを使ってください)
グラフを載せたい場合は、サーバ側で画像(PNG)を生成して<img>で埋め込むか、CSSで描ける範囲の表現に置き換えてください。
入力は1つのHTML
複数のHTMLファイルを並べて1つのPDFへ結合することはできません(wkhtmltopdfの位置引数に相当する機能がありません)。
表紙は--cover、目次は--tocで個別に指定します。
既存のPDF同士の結合・分割・ページ抽出も対象外です。
PDFの機能
| 機能 | 状況 |
|---|---|
| アウトライン(しおり・ブックマーク) | 非対応。文書内の目次は--tocで作れます |
| 入力可能なフォーム(AcroForm) | 非対応。<input>等は見た目だけ描画します |
| 暗号化・パスワード・電子署名 | 非対応 |
| PDF/A・PDF/X などの規格準拠 | 非対応 |
| タグ付きPDF(アクセシビリティ) | 非対応 |
| 添付ファイル・注釈(リンク以外) | 非対応。リンク注釈のみ対応 |
リンク(<a href>)は、外部URL・文書内の#idともPDFの注釈になります。
画像・フォントの形式
- 画像はPNG / JPEG / WebPのみ。SVGとGIFは非対応です
- フォントはTTF / OTFのみ。WOFF / WOFF2は非対応です
--grayscaleを指定しても、JPEGとCMYK画像はカラーのまま残ります
CSSの主な制限
機能単位では以下が非対応です。
- 縦書き(
writing-mode/text-orientation)と論理プロパティ(margin-inline等)、directionによる右横書き - 多段組み(
columns/column-count) - グラデーション(
linear-gradient()等)と複数背景 - アニメーション・トランジション・
filter(静的な出力のため) position: sticky、display: inline-flex/inline-grid、subgrid:is()/:where()/:has()、::first-line、::marker
ストリーミングモード固有の制限
--streamingを使う場合は、総ページ数(counter(pages)・[topage])や目次(--toc)などが使えなくなります。
詳細はストリーミングモードを参照してください。
今後について
JavaScriptエンジンの組み込みは、必要性が出てきた段階での検討事項として残してあります。 上記のうちPDFのアウトラインなど、設計上の非目標ではないものは将来対応する可能性があります。