AI

SVGのラスタライズをsipsとresvgとChromeで比べる

  • SVG
  • resvg
  • sips
  • font-weight
  • 図版
  • ベンチマーク

エージェントに書かせたSVGをPNGへ変換し、そのPNGを本人に読ませて崩れを確認するループを、macOS同梱の sips で回していました。<marker> が落ちることは分かっていたので他の機能も測ったところ、font-weight が全滅していました。追試すると sipsfont-weight を無視しているわけではなく、落ちる条件が2つあっただけでした。条件は回避できます。ただし当たったときに何も言わずに落ちるので、resvg への乗り換えを検討しています。確認ループ自体はまだ sips のままです。

ブログClaude Codeに書かせたSVGをsipsでラスタライズして自分で目視させるClaude CodeにSVGで図を描かせる手法は既にありますが、書かせた図が実際にどう見えるかは書いた本人が確認していません。macOS同梱のsipsでPNGに変換し、そのPNGをエージェント自身に読ませて確認させる運用と、sipsが黙って落とす属性の実例をまとめます。

差し替えるとしても1行です。

-sips -s format png fig.svg --out check.png
+resvg fig.svg check.png

3枚のプローブで測ったら、font-weightが全滅した

計測は、レンダラーの挙動を1軸ずつ切り出した3枚のSVGに対して行いました。<marker> とグラデーションとぼかしを並べたもの、font-weight<pattern> を並べたもの、font-family の宣言を10通り並べたものです。見栄えは意図的に捨てて、機能が生き残るかどうかだけを見ています。環境はmacOS 26.6のApple Silicon、Chrome 151.0.7922.108、resvg 0.48.0です。

resvg・sips・Chromeのレンダリング結果を縦に並べた比較画像。resvgとChromeでは700とboldの行が400の行よりはっきり太いのに対し、sipsでは3行とも同じ太さで描画されている。italicを傾けているのはChromeだけで、patternの3つの見本はどのレンダラーでも同じに出ている

400700bold の3行が同じ太さで出ました。標準エラーは0バイトで、--debug を付けてもフォントに関する出力はありません。この時点では「sipsfont-weight を見ていない」と読み、SVG側に回避策もないと判断しています。

font-style: italic については、傾けるのはChromeだけでした。resvgsips も無視します。<pattern>patternTransformlinearGradientfeGaussianBlur は3つとも同じに描いています。

落ちていたのは2つの条件だった

この読みが間違っていました。指定の書き方を変えながら sips へ通し直すと、太字が出る組み合わせがあります。

sipsでラスタライズしたPNG。-apple-system始まりのスタックに700を指定した行とboldキーワードを指定した行は通常の太さのまま、Hiragino Sans始まりのスタックに700・600を指定した行とsans-serifに700を指定した行は太字で描画されている

条件は2つありました。

1つ目は値の形式です。700600 のような数値は効きますが、boldbolder のキーワードは無視されます。Hiragino SansHelveticasans-serif のどれで試しても、キーワードだけが落ちました。ブラウザーと resvg はどちらの形式も同じに扱うので、bold と書いてある図は sips を通したときだけ太字を失います。

2つ目は font-family の先頭です。先頭に書いた名前を sips が解決できたときだけ、font-weight が反映されます。-apple-systemBlinkMacSystemFont、インストールされていない Noto Sans JP を先頭に置くと、グリフ自体はフォールバックしたゴシック体で出るのに、太さの指定だけが落ちます。同じスタックでも 'Hiragino Sans' を先頭へ動かすと効きます。

<!-- 太字にならない -->
<text font-family="-apple-system, 'Hiragino Sans', sans-serif" font-weight="700">
<text font-family="'Hiragino Sans', sans-serif" font-weight="bold">

<!-- 太字になる -->
<text font-family="'Hiragino Sans', -apple-system, sans-serif" font-weight="700">
<text font-family="sans-serif" font-weight="700">

数値指定は属性でもインラインの style でも <style> のクラス経由でも同じに効いたので、書き方の自由度は残ります。上の2つを避けさえすればよい、という話です。

最初のプローブは、その2つに両方当たっていました。ルートの font-family-apple-system 始まりのスタックで、3行のうち1行は bold キーワードだったためです。図版のルールとして自分で決めたフォントスタックがそのまま条件に当たっていたので、全滅に見えていました。

sipsのオプションでは変えられない

SVG側を直す前に、コマンドライン側で渡せるものが無いかを確認しました。レンダリングに触れる指定はありませんでした。

sips --formats を見ると、public.svg-image には他の形式に付いている Writable がありません。SVGは読み込み専用の入力で、書き出し先として選べない形式です。sips --helpProperties が並べる画像プロパティも dpiWidthformatformatOptionsdescriptionartist といったラスター画像のメタデータばかりで、テキストやフォントに関わるキーはありません。

実際に叩いた結果も同じでした。

試したもの結果
-s formatOptions best出力がバイト単位で同一
-s dpiWidth 300 -s dpiHeight 300描画は変わらない
-Z 1240 / -z で大きく描かせる同じ行が同じ太さのまま
-s format pdf を経由してからPNGへ同じ
-j のJavaScriptモード同じ

-j のスクリプトから触れる画像オブジェクトは getPropertysetPropertysizeToFitLongestEdgesizeaspectRatioproperties だけで、SVGを入力にしたときに読めるプロパティは PixelHeightPixelWidthtypeIdentifier の3つでした。-s と同じキーの空間を触っているだけで、増えるものはありません。

太字を出したいなら、SVG側を直すしかありません。

それでもresvgを考えている

回避策が分かった以上、sips のままでも図は作れます。実際、確認ループはまだ差し替えていません。それでも乗り換え先として考えているのは3つの理由からです。

<marker> が落ちる問題は残ります。矢頭を <polygon> の実体で描く運用はすでに入れているので致命傷ではありませんが、レンダラー側を替えれば要らなくなる制約です。

条件に当たったときに何も言わないのも大きいところです。矢頭が消えれば「矢印のはずのものが罫線になっている」と目で分かりますが、太字が効いていない図は、ただ強調のない図として成立してしまいます。並べて比べるまで気づけません。

そして、当たりやすい条件でした。図版のルールに書いたフォントスタックは -apple-system 始まりで、これは明朝体へ落ちるのを防ぐために決めたものです。その指定を守るかぎり、sips では太字が必ず消えます。噛み合っていないルールどうしを、レンダラーを替えるだけで一度に解けます。フォントスタックの規約を直す手もありますが、そちらは既存の図版を全部見直すことになります。

ヘッドレスChromeは、乗り換えたとしても確認先として残す想定です。フィルターを多用した図で挙動が疑わしいときに、本物のブラウザーで確かめる先が要ります。1枚1.5秒は毎回のループには重すぎますが、最後の1回だけなら許せます。

OS同梱のsipsより別バイナリのresvgが速い

bench.sh 3 で1枚あたりの実時間を測った結果です。

ms/枚
resvg23
sips51
ヘッドレスChrome1576

resvg は3.3MBの別バイナリを持ち込むことになりますが、OSに最初から入っている sips の半分以下の時間で終わります。確認ループを回す用途では、追加インストールが要る点を差し引いても速いほうが効きます。

Chromeの1.5秒はレンダリングではなく起動

ヘッドレスChromeの1576msは、フラグの調整では動きませんでした。何も描かない about:blank を開かせても1551msかかります。つまり測っているのはほぼプロセスの起動時間で、レンダリング自体は誤差に埋もれています。

--disable-extensions--disable-background-networking--disable-sync--disable-component-update といった軽量化のフラグを一式足しても1546msで、有意な差は出ませんでした。--headless=old もChrome 151でまだ動きますが、1582msで変わりません。削る対象がレンダリング側に無い以上、当然の結果です。

罠が1つありました。プロファイルを分離するつもりで --user-data-dir に新しいディレクトリを渡すと、Chromeが10分以上ハングします。スクリーンショットは1枚も書かれず、残留プロセスも無く、ただ終わりません。

短縮する唯一の手は、1つのブラウザープロセスを複数の画像にまたがって生かしておくことです。これはCDPを喋るということで、つまりPuppeteerかPlaywrightが要ります。puppeteer-core なら既にインストール済みのChromeへ接続できるので、Chromiumの再ダウンロードだけは避けられます。図を1枚書き直すたびに回すループには、いずれにせよ重すぎます。

ピクセル差分率は判断材料にならない

当初は「Chromeの出力にどれだけ近いか」を数値で決められると考えていました。3枚のプローブで測った差分率がこれです。

図版resvg vs sipsresvg vs Chromesips vs Chrome
marker + gradient + filter5.6%5.6%3.3%
font-weight + pattern11.0%11.1%8.6%
font-family解決9.7%10.2%7.4%

3枚とも、矢頭と太字を落としている sips のほうが resvg よりChromeに近いという結果になりました。数値が測っているのは文字のラスタライズ差です。resvg は独自エンジン、sips はCoreGraphics、ChromeはSkiaと、3つとも別のエンジンでヒンティングも字間もアンチエイリアスも違うので、そのノイズが機能の欠落より大きく出ます。

実際の記事に使っている図版40枚超でも同じ方向の結果でした。Chromeに対する平均差分は resvg が4.2%、sips が4.5%で、resvg のほうが近かったのは6割強にとどまります。0.3ポイントの差はレンダラーを選ぶ根拠になりません。

差分率は出力サイズが揃っているかの確認に使い、判断は縦に並べた画像を見て決める、という位置づけに変えました。今回の font-weight の件も、差分率では何も見えていません。

resvgではfont-family忘れが見えなくなる

乗り換えれば素直に良くなる話ばかりでもありません。font-family の宣言を10通り並べたプローブの結果です。

同じ3つのレンダリング結果を縦に並べた比較画像。resvgでは10行すべてが同じゴシック体で描画されている。sipsとChromeでは、指定なし・serif・Noto Sans JP・Meiryo・存在しない名前の行が明朝体に落ちている

resvg は10行すべてを同じゴシック体で描きます。指定なしの行も、serif を指定した行も、存在しないフォント名を書いた行も同じです。resvg の既定フォントは Times New Roman で、日本語のグリフを持たないため Arial Unicode MS へフォールバックし、結果として何を指定しても同じ顔になります。

前の記事では「ルートの <svg> にフォントスタックを書く。書き忘れると明朝に落ちる」というルールを作り、書き忘れた図が4枚見つかったのは、既存の図をまとめてラスタライズし直して目で見たときでした。resvg に替えると、この見落としは目視では拾えませんsips とChromeでは明朝に落ちて見えるものが、resvg では正しく見えてしまいます。

代わりに resvg はstderrへ警告を出します。

Warning (in usvg::text:183): Fallback from Times New Roman to Arial Unicode MS.

目で気づくより機械で拾うほうが確実なので、乗り換えるならこれを見ることになります。

resvg fig.svg out.png 2>&1 >/dev/null | grep -q Fallback && echo "unresolved font"

なお、フォントの解決結果は入っているフォントに依存します。検証したマシンには Hiragino Sans はありましたが Noto Sans JPMeiryo はなく、その2つを名指しした宣言は素通りしています。手元の状況は resvg --list-fonts で確認できます。

テキスト実装を総入れ替えしても出力は変わっていなかった

resvg 0.48.0(2026-07-31)は、テキスト周りの両輪を入れ替えたリリースです。CHANGELOGによれば、シェーピングが rustybuzz から harfrust へ、フォントのパースが ttf-parser から skrifa(fontations)へ移っています。グリフの送り幅の計算や、既定値のままの font-weightwght 座標を設定してしまう問題の修正も入っています。

これだけテキストに触っているので、0.47.0(2026-02-05)との出力を比べました。

図版差分ピクセル
marker + gradient + filter0.00%(変化した領域なし)
font-weight + pattern0.00%(なし)
font-family解決0.00%(なし)

バイト単位で同一でした。どちらのバージョンに固定しても図版の見た目は変わらないことになります。ただしこれはプローブが触っている範囲の話で、可変フォント・複雑な字形・双方向テキストは確かめていません。

速度のほうは動きました。03-font-family-resolution.svg を20回、2パス測って、0.47.0が27.3ms・26.6ms、0.48.0が23.3ms・22.4msです。おおよそ15%速く、2パスとも同じ方向に出ました。skrifa への移行が効いている可能性はありますが、そこまでは切り分けていません。

プローブの書き方でレンダラーの評価が変わる

今回いちばん効いたのは、sips の挙動そのものより、それを測ったプローブの書き方でした。1枚のSVGにルートの font-family を1つだけ置いて全行に継承させる作りだったので、そのスタックが当たった条件が全行に乗り、レンダラーの側の性質として見えていました。

機能ごとに1軸を切り出したつもりでも、切り出せていない軸が背後で効いていることがあります。プローブに残っている共通の前提は、次から先に列挙してから測ります。

同じ疑いは font-style: italic にもかかります。あの行もルートのスタックを継承していたので、font-weight と同じ理由で落ちていた可能性がありました。こちらは測り直しています。先頭を Helvetica'Hiragino Sans' にしても、Helvetica-Oblique を直接名指ししても、sips の出力は1行も傾きませんでした。font-style のほうは条件付きではなく、そのまま無視されています。