Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 で出したものです。

段落が主体の文書:

要素数sghtmltopdfsghtmltopdf(ストリーミング)wkhtmltopdfヘッドレスChrome
5,00026MB / 0.11秒9MB / 0.10秒44MB / 0.49秒543MB / 1.32秒
20,00080MB / 0.46秒14MB / 0.34秒86MB / 2.60秒943MB / 7.45秒
60,000230MB / 1.99秒25MB / 1.31秒199MB / 42.02秒1,525MB / 105.77秒

表が主体の帳票:

行数sghtmltopdfsghtmltopdf(ストリーミング)wkhtmltopdfヘッドレスChrome
5,00049MB / 0.56秒48MB / 0.60秒62MB / 1.55秒1,372MB / 5.12秒
20,000173MB / 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-linuxaarch64-linuxarm64-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/amd64linux/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 UDPGothicBIZ UDPMinchoのRegular・Bold(計4本、SIL Open Font License 1.1)が入っています。 ライセンス全文はイメージ内の/usr/share/doc/sghtmltopdf/fonts/にあります。

CSSの指定使われるフォント
font-family未指定BIZ UDPMincho(明朝)
font-family: sans-serifBIZ UDPGothic(ゴシック)
font-family: serifBIZ 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>A4A3/A4/A5/Letter/Legal(大文字小文字を区別しない)
--page-width <LENGTH>用紙の幅。--page-sizeより優先
--page-height <LENGTH>用紙の高さ。--page-sizeより優先
-O, --orientation <O>PortraitLandscapeは最後に幅と高さを入れ替える
-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-facefont-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>96CSS 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は非対応)。

見出しはh1h6から集めます。 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-insideorphans/widowsなどのページ分割

メモリと処理時間

同じHTMLを両モードで変換した実測値です。 各セルは「ピークメモリ / 処理時間」を表します。

要素数HTMLサイズ通常モード--streaming
1,00046KB11MB / 0.02秒8MB / 0.02秒
5,000233KB26MB / 0.08秒10MB / 0.10秒
20,000946KB81MB / 0.35秒15MB / 0.38秒
60,0002.8MB228MB / 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>301リクエストに与える秒数。キュー待ちと変換の合計(超えると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 /healthzok
GET /versionsghtmltopdf <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
Ruby3.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_timeout5接続のタイムアウト(秒)
server_read_timeout120応答のタイムアウト(秒)
chunk_size65536ブロック付きrenderで1回に渡すバイト数の目安(ローカル変換のみ)

Railsのレンダラ(render pdf:)では、これに加えてdispositionfilenamestatusshow_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_urlRails.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#killTimeout.timeoutRack::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)

その代わり、文書全体を見ないと決まらないもの(toccounter(pages)<body>より後の<style>など)が使えません。 制約の一覧はストリーミングモードを参照してください。

CSSプロパティ対応表

表示・可視性

プロパティ対応備考
display⚠️block/inline/inline-block/list-item/table/table-row/table-cell/table-caption/flex/noneのみ。gridは対応(Grid節)。inline-flex/inline-gridtable-row-group等のテーブル内部値・flow-rootは非対応。<thead>/<tbody>/<tfoot>はUAスタイルでblockのままだが、行収集が透過するのでテーブルとして機能する
visibility⚠️visible/hidden/collapsecollapsehiddenと同一視(テーブル行/列の高さ再計算はしない)。継承プロパティ
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-*でクランプされて比が崩れた場合の再計算は行わない
margin1〜4値ショートハンド。auto(中央寄せ)・負値に対応
margin-top / -right / -bottom / -left隣接兄弟間・親子間のマージン相殺に対応
padding1〜4値ショートハンド
padding-top / -right / -bottom / -leftパーセンテージはcontaining block幅基準(仕様通り)

枠線・角丸・アウトライン・影

プロパティ対応備考
border<width> || <style> || <color>を任意順・任意省略で受け付け、4辺へ適用
border-top / -right / -bottom / -left辺別ショートハンド(値文法はborderと同じ)
border-width / border-style / border-color1〜4値ショートハンド
border-*-width⚠️<length>のみ。thin/medium/thickキーワードは非対応
border-*-style⚠️none/hidden/solid/dashed/dotted/double/groove/ridge/inset/outsethiddennoneと同一視(テーブルの枠線競合解決でも区別しない)。groove/ridge/inset/outsetborder-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-styleborder-styleと同じ値集合。UA依存のautoは非対応
outline-offset常に0固定
box-shadow⚠️none | <shadow>#(カンマ区切りで複数指定可、先頭が最前面)。insetはパースするが描画は非対応。ぼかしは4段階の同心矩形による近似

配置(positioning / float / transform)

プロパティ対応備考
floatnone/left/rightwidth: autoのshrink-to-fitに対応
clearnone/left/right/both
position⚠️static/relative/absolute/fixedstickyは非対応。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/100900。数値は600以上をboldとみなす2値化。太字フォントが無い場合は塗り+縁取りの疑似ボールドで描画
font-style⚠️normal/italic/oblique(obliqueitalicと同一視、傾斜角の指定は不可)。イタリック字形が無い場合はテキスト行列のせん断による疑似イタリック
font(ショートハンド)個別のロングハンドを使う
color継承プロパティ。指定できる色の記法はセレクタ・値・at-ruleを参照
line-heightnormal/<number>/<length>/<percentage>
text-align⚠️left/right/center/justifyjustifyは最終行以外の単語間で余白を配分する。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/ellipsisoverflowvisible以外のときのみ有効。幅方向にはみ出した行のみが対象(ブロック全体のオーバーフローは扱わない)。<string>指定は非対応
word-breaknormal(CJK文字が隣接する境界のみ改行可)/break-all/keep-all。非推奨値break-wordは非対応
overflow-wrap / word-wrap⚠️normal/break-word/anywhere(anywherebreak-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>はグリフ描画で、字形が無ければ描かれない)。positionover/underのみ(right/leftは読み飛ばし)。マーク分だけ行の高さが広がる。句読点をスキップするtext-emphasis-skipは非対応
letter-spacing⚠️normal/<length>。パーセンテージは非対応
word-spacing⚠️normal/<length>
white-space⚠️normal/nowrap/prepre-wrap/pre-line/break-spacesは非対応
vertical-alignbaseline/sub/super/text-top/text-bottom/top/middle/bottom/<length>/<percentage>。テーブルセル文脈ではtop/middle/bottom(とbaseline)が意味を持つ
quotes⚠️noneまたは"開き" "閉じ"のペアの繰り返し。contentopen-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-sizecover/contain/<length-percentage> | autoの1〜2値
background-repeat⚠️repeat/repeat-x/repeat-y/no-repeat。CSS3のround/space、2値構文は非対応
background-attachment⚠️scroll/fixed(スクロールの概念が無いためfixedscrollと同一視)
background-clip / background-origin / background-blend-mode未実装。背景はborder-box基準で描画する

border-radiusbackground-imageを併用した場合、角丸によるクリップは行わない(角丸は背景色の塗りにのみ効く)。

テーブル

プロパティ対応備考
table-layoutauto/fixedautoではセル内容の自然幅を測って列幅を決める(ネストしたテーブル・flexの自然幅測定のみ非対応で0扱い)
border-collapse⚠️separate/collapsecollapseは見た目の枠線統合のみを行う(CSS2.1 §17.6.2の競合解決を「太い方が勝ち、同幅ならスタイル優先順」で簡略化)。継承プロパティ
border-spacing<length>{1,2}border-collapse: collapse時は0として扱う。継承プロパティ
caption-side⚠️top/bottom。縦書き向けのleft/rightは非対応
empty-cellsshow/hideborder-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)/nonecjk-*/hiragana/katakana等は非対応。継承プロパティ
list-style-positionoutside/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-resetnoneまたはname [<integer>]の繰り返し
counter-incrementnoneまたは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 / widows1以上の整数。初期値2
page(名前付きページ)@page introのような名前付きページ自体が非対応

HTML属性data-page-break="before|after|avoid"によるシンタックスシュガーも利用できる。

Flexbox

display: flexのレイアウトはtaffyへ委譲する。 flexコンテナはページ分割上アトミック(display: tableと同じく途中で分割されない)。

プロパティ対応備考
flex-directionrow/row-reverse/column/column-reverse
flex-wrapnowrap/wrap/wrap-reverse
justify-content⚠️flex-start(start)/flex-end(end)/center/space-between/space-around/space-evenlysafe/unsafeオーバーフローキーワードは非対応
align-itemsflex-start(start)/flex-end(end)/center/baseline/stretch
align-contentflex-start(start)/flex-end(end)/center/stretch/space-between/space-around/space-evenly
align-selfauto/flex-start(start)/flex-end(end)/center/baseline/stretch
flex-grow / flex-shrink非負の<number>(負値は無効な宣言として無視)
flex-basis⚠️auto/content/<length-percentage>contentautoと同一視
flex(ショートハンド)noneおよび<grow> [<shrink>] [<basis>]。CSS仕様の既定値規則(flex: 1のbasisは0%flex: <width>のgrow/shrinkは1)を再現
gap / row-gap / column-gap⚠️flexコンテナでのみ有効(多段組みのcolumn-gapとしては機能しない)
ordertaffy 0.12系が未対応のため
place-content / place-items / place-self(ショートハンド)未実装。個別のロングハンドを使う
justify-items / justify-selfflexアイテムには適用されない(下記)

justify-items/justify-selfがflexで効かないのは仕様

CSS Box Alignmentではjustify-items/justify-selfはGrid・ブロックレイアウト用のプロパティで、flexアイテムには適用されない(主軸方向のアイテム個別の配置はjustify-contentmargin: 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-rowsnone/<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-flowrow/column/dense(併記可)
grid-row-start / -end / grid-column-start / -endauto/<integer>/span <integer>/<custom-ident>/span <custom-ident>
grid-row / grid-column / grid-area/区切りのショートハンド。grid-area: <name>で名前付きエリアを指定できる
justify-items / justify-selfGridでのみ意味を持つ(flexアイテムには適用されない、上記)
align-items / align-self / justify-content / align-content / gapFlexboxと共有(値の範囲はFlexbox節を参照)
grid / grid-template(ショートハンド)個別のロングハンドを使う。トラック定義とエリア定義を1つの構文へ詰め込む複雑な文法のため非対応
display: inline-gridinline-flexと同じ理由で非対応
subgrid / masonry未実装

置換要素(画像)

プロパティ対応備考
object-fitfill/contain/cover/none/scale-down<img>にのみ意味を持つ
object-positionbackground-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-linkhrefを持つ<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; }のような指定がそのまま通る。

@pagesizeだけは例外的にページサイズのキーワード(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-facesrc/@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ディスクリプタに対応(srclocal()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は文書の中にフォントを埋め込みます。 ブラウザと違って「見る人の環境にあるフォントで表示する」ということができないため、どのフォントを使うかは変換時に決まります*

フォントが決まる順番

  1. CLIの--font(および--gothic-font/--serif-font/--mono-font)
  2. CSSの@font-face
  3. font-familyに書かれた名前でのシステムフォント探索
  4. 文書中の文字を描画できるフォントのシステム探索

どれでも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です。 srclocal()と、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-weightnormal/bold/100900。数値は600以上をboldとみなす2値化。太字のフォントが無い場合は、塗りに縁取りを足した疑似ボールドで描画します
font-stylenormal/italic/oblique(obliqueitalicと同一視)。イタリック字形が無い場合は、テキスト行列のせん断による疑似イタリックになります

fontショートハンドは非対応です。 font-sizefont-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/8172.16/12192.168/16fc00::/7
リンクローカル169.254/16(クラウドのメタデータ169.254.169.254を含む)、fe80::/10
CGNAT100.64.0.0/10(クラウドの内部ロードバランサ等)
その他の非グローバル0.0.0.0/8192.0.0.0/24198.18.0.0/15240.0.0.0/4、マルチキャスト、ドキュメント用
IPv6の特殊用途Teredo 2001::/322001:db8::/32、ORCHIDv2 2001:20::/28100::/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-afterauto / avoid(avoid-pageavoid-columnも同義) / always(pageも同義)
break-insideauto / 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 intropage: intro)は非対応です
  • margin boxの寸法は簡略化しています。4隅は縦横マージンの交差部分で固定、残り12個は各辺を3等分した均等割りです(width指定は無視されます)
  • counter(pages)ストリーミングモードでは使えません(総ページ数が1パスでは決まらないため)

CLIの--header-centerなどのオプションは、内部的にこの@pageのmargin boxへマップされます。 両方書いた場合はCSSが勝ちます。

テーブルのページ分割

1ページに収まらないテーブルは行単位で分割され、複数ページへ流れます。

  • <thead>の行は2ページ目以降の先頭に繰り返されます。複数行の見出しにも対応し、1ページに収まる表では複製しません
  • <tfoot>はソース順に関わらずテーブル末尾へ移動しますが、各ページ下端への繰り返しは行いません(最終ページに1回だけ出ます)
  • captioncaption-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からの移行を参照してください。

挙動が違うところ

wkhtmltopdfsghtmltopdf
CLIオプションとCSSの@pageCLIが勝つ@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つです。

  1. !importantが使えません。付いた宣言は無視されます
  2. inherit/initial/unsetが使えません
  3. ビューポート単位(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/ignoreskipは入力が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>✅ 対応

このセクションはすべて対応しています。 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✅ 対応divborder-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-assetshttp(s)絶対URLのフェッチ許可
--streamingストリーミングモードで処理する
--base-url <url|dir>stdin入力時などの相対解決の基準
--author / --subject / --keywordsPDF 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_pdfexe_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_pdfsghtmltopdf備考
pdf: "name"同じファイル名(.pdfは自動で付く)
template: / layout: / locals: / formats:同じRailsのビュー描画へそのまま渡る
disposition: / filename: / status:同じ既定のdispositioninline
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: falseno_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_statusJSは実行しない(設計上の非目標)
print_media_type常にprintメディア扱い
lowquality / viewport_size / disable_smart_shrinkingWebKit固有
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_pdfsghtmltopdf
wicked_pdf_stylesheet_link_tagsghtmltopdf_stylesheet_link_tag
wicked_pdf_image_tagsghtmltopdf_image_tag
wicked_pdf_asset_pathsghtmltopdf_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-faceurl()で参照している場合は、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_urlallow・フォント指定などローカルパスを取るオプションはリクエストから指定できない(サーバ起動時にだけ設定できる)。 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: stickydisplay: inline-flex/inline-grid、subgrid
  • :is()/:where()/:has()::first-line::marker

ストリーミングモード固有の制限

--streamingを使う場合は、総ページ数(counter(pages)[topage])や目次(--toc)などが使えなくなります。 詳細はストリーミングモードを参照してください。

今後について

JavaScriptエンジンの組み込みは、必要性が出てきた段階での検討事項として残してあります。 上記のうちPDFのアウトラインなど、設計上の非目標ではないものは将来対応する可能性があります。