AI

Claude Codeに書かせたSVGをsipsでラスタライズして自分で目視させる

  • Claude Code
  • SVG
  • 図版
  • sips
  • macOS
  • ブログ運用

Claude Codeに技術記事の図をSVGで描かせるとき、書かせて終わりにすると崩れた画像がそのまま公開されます。SVGとしては妥当で、リンターも通り、ブラウザーでは正しく見えるのに、記事に載る画像だけが崩れている、という状態が起こります。macOS同梱の sips でPNGに変換し、そのPNGをエージェント自身に画像として読ませて確認させる運用に変えました。

sips -s format png public/images/posts/{slug}/{name}.svg --out /tmp/check.png
sips -Z 1400 /tmp/check.png --out /tmp/check-big.png

この2行を挟んで、出力されたPNGをエージェントが自分で開くところまでを1つの作業単位にしています。

ブログsatoriの禁則処理とフォント収録範囲を自前のロジックで直したsatoriはCSSのlineBreakを実装しておらず、Noto Sans JPの収録範囲もメタデータ通りではありませんでした。OGP画像で実際に起きた2つの見落としと、それぞれを自前のロジックで直した記録です。

SVGを書かせる手法自体は既にある

LLMにSVGを書かせるアプローチは新しくありません。paperMoose/claude-svg のようにClaude向けの図版生成スキルが公開されていますし、SVG生成に特化したスキルはマーケットプレイスにも並んでいます。SVGはピクセルではなく座標と図形の記述なので、テキストを書けるモデルがそのまま扱えます。ラスター画像の生成と違って、あとから座標を1つ変えるだけで直せるのも扱いやすい点です。

研究の側でも、生成したSVGをレンダリングして視覚的に検証するループは扱われています。Chat2SVG はLLMと画像拡散モデルを組み合わせてベクター画像を生成する手法で、Rendering-Aware Reinforcement Learning for Vector Graphics Generation はレンダリング結果を報酬に使ってSVG生成を学習させます。Render-in-the-Loop のように、レンダリングした画像をモデルへ戻して自己修正させる枠組みも出ています。

つまり「描かせる」も「レンダリングして戻す」も既にある話です。手元で足りていなかったのは、その2つを日々の記事執筆の作業手順として、追加の依存なしに回せる形にすることでした。

足りないのは描かせることではなく見せること

図を書かせたあと、出来上がったSVGのソースを読んで「問題なさそう」と判断させると、次のようなものが通過します。

  • 矢印の線は引かれているのに、矢頭が描画されていない
  • 日本語だけ明朝体で出ていて、他の図と並ぶと浮く
  • 箱の幅より長い文字列が、箱の外へはみ出している
  • 2本の線が同じ区間を通っていて、後から描いた線が前の線を隠している

いずれもSVGのソースを読むだけでは気づけません。座標の数値は正しく、属性の綴りも合っているためです。崩れているかどうかは、実際にラスタライズした画像を見るまで確定しません。

エージェントがSVGを書き、sipsでPNGへ変換し、そのPNGを画像として読み込んで自分の目で確認し、崩れていれば書き直す。この4つが輪になっている。輪を閉じずにSVGを書いた時点で終わりにすると、座標のずれや文字のはみ出し、レンダラーが無視する属性が残ったまま公開される

ブラウザーを起こさずにラスタライズする

レンダリングの手段としてヘッドレスブラウザーを立てる選択肢もあります。実際、Mermaidをビルド時にSVGへ変換する rehype-mermaid は内部でPlaywrightを使います。ただし図を1枚書くたびにChromiumを起動するのは、確認のループを回す用途には重すぎます。

sips はmacOSに最初から入っているコマンドで、追加のインストールが要りません。SVGを入力に取ってPNGを書き出せます。

# viewBox の寸法どおりに PNG を出す
sips -s format png diagram.svg --out check.png

# 目視用に長辺を 1400px へ拡大する
sips -Z 1400 check.png --out check-big.png

qlmanage -t でもサムネイルを生成できますが、アスペクト比によっては見切れます。sips -s format pngviewBox の寸法をそのまま出すので、はみ出しの判定にはこちらが向いています。1枚あたり数十ミリ秒で終わるため、1つの図を10回書き直しても待ち時間になりません。

sips でSVGをPNGへ変換する使い方自体は以前から知られているもので、目新しさはありません。エージェントの確認ループに組み込んだ点が、手元での運用上の違いです。

sipsは<marker>を黙って落とす

軽いレンダラーを使う代償として、対応していない機能があります。最初に踏んだのが矢頭でした。

SVGで矢印を描く教科書的な方法は <marker> です。<defs> に矢頭の形を定義して、線に marker-end で参照します。

<defs>
  <marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5"
          markerWidth="6" markerHeight="6" orient="auto-start-reverse">
    <path d="M 0 0 L 10 5 L 0 10 z" fill="#475569" />
  </marker>
</defs>
<path d="M 152 60 L 264 60" stroke="#475569" stroke-width="2" marker-end="url(#arrow)" />

ブラウザーで開けば矢印が出ます。同じファイルを sips に通すと、こうなります。

sipsでラスタライズしたPNG。2つの箱をつなぐ線は引かれているが、marker-endで指定した矢頭が描画されず、両端が切りっぱなしの直線になっている

線だけが残り、矢頭が消えます。エラーも警告も出ません。矢印のつもりで引いた線が、ただの罫線として記事に載ります。

矢頭を <polygon> の実体として描くと、意図どおりに出ます。

<path d="M 152 60 L 262 60" stroke="#475569" stroke-width="2" fill="none" />
<polygon points="276 60 262 55 262 65" fill="#475569" />

sipsでラスタライズしたPNG。polygonで実体として描いた三角形が矢頭として表示され、2つの箱をつなぐ矢印になっている

座標を2箇所に分けて書く必要があり、<marker> より手間がかかります。それでも、レンダラーに依存せず必ず出る形を選びました。

ルートにfont-familyを書かないと明朝に落ちる

2つ目は書体です。SVGでフォントを指定しないと、レンダラーの既定の書体が使われます。ブラウザーで開けばページ側のCSSを継承するので気づきませんが、外部ファイルとして単体でラスタライズすると継承する相手がいません。

<!-- ルートに font-family がない -->
<svg viewBox="0 0 420 120" xmlns="http://www.w3.org/2000/svg">
  <text x="24" y="46" font-size="15">責任プロセスが切り離される</text>
</svg>

sipsでラスタライズしたPNG。日本語が明朝体、英数字がセリフ体で描画されており、サイト上の他の図と書体が揃っていない

日本語が明朝体、英数字がセリフ体になります。1枚だけ見ると気づきにくく、他の図と並べて初めて浮きます。ルートの <svg> にフォントスタックを書くと揃います。

<svg viewBox="0 0 420 120" xmlns="http://www.w3.org/2000/svg"
     font-family="-apple-system, BlinkMacSystemFont, 'Hiragino Sans',
                  'Hiragino Kaku Gothic ProN', 'Noto Sans JP', Meiryo, sans-serif">

sipsでラスタライズしたPNG。指定したフォントスタックが効いてゴシック体で描画され、日本語と英数字の書体が揃っている

スタックの先頭に system-ui を置くのは避けています。レンダラーによっては日本語が全て文字化けします。

自動縮小がないので文字は箱からはみ出す

3つ目は幅です。HTMLとCSSなら、要素の幅に応じて折り返しや縮小が効きます。SVGの <text> にはそれがありません。座標に置いた文字列は、指定した位置からそのまま伸びます。

エージェントは箱の幅を width="180" のように数値で決め、その中に入れる文字列を別に決めます。両者の関係を計算しないまま書くと、こうなります。

sipsでラスタライズしたPNG。幅180の箱の中央に置いた長い識別子が箱の左右へ大きくはみ出し、隣の箱の手前まで到達している

responsibility_spawnattrs_setdisclaim() は44文字あり、12pxでも約280px必要です。幅180の箱には収まりません。文字数と font-size から必要な幅を見積もって、箱を広げるか、文字を短くするか、行を分けるかを決めます。全角文字を正方形、半角文字をその半分として概算すると、実用上は足ります。

この見積もりが近似である以上、ずれは残ります。だからこそ、書いたあとに実際の出力を見る工程が要ります。

目視で見つからない種類の食い違いもある

ラスタライズして見れば分かるのは、視覚的な崩れだけです。図に書いてある内容が正しいかどうかは別の問題で、こちらは目視では捕まりません。

手元で実際に起きたものを挙げます。

種類実例目視で分かるか
描画の崩れ矢頭が消える、文字がはみ出す分かる
図の中の矛盾境界線は縦線なのにラベルが「ここから下」図とラベルを突き合わせれば分かる
記事との矛盾図が「アプリ本体の許可を引き継ぐ」と書いているが、記事の別の節は「権限はターミナルに付く」と書いている分からない
代替テキストとの矛盾<desc> は案A、可視ラベルと本文は案B分からない

3つ目と4つ目は、記事の全文と図を並べて読み直すまで残ります。図を1枚ずつ見ている限り、どれも整合して見えます。<desc> の食い違いはとくに厄介で、スクリーンリーダーの利用者だけが違う説明を受け取る状態になり、見た目からは絶対に分かりません。

対策として、図に書く値・API名・識別子を1つずつ列挙し、それぞれの根拠を実際のソースコードやタスクの記録に当てる工程を別に置いています。目視のループとは分けて回します。

スキルに書いて固定したこと

同じ失敗を繰り返さないよう、記事執筆のスキルに図版のルールを分けて置いています。要点は次の通りです。

  • 図を入れる前に、その図がなくても記事が成立するかを確かめる。判断の基準は「その図がないと主張が伝わらなくなるか」
  • 分岐のない一本道でノードが10個程度までならMermaidでよい。分岐・対比・循環・階層・範囲はSVGで描く
  • <marker> を使わず、矢頭は <polygon> の実体で描く
  • ルートの <svg> に指定のフォントスタックを書く。system-ui を先頭に置かない
  • アクセント色は、その図で見てほしい1点にだけ使う。同じ強さの色を2つ置くと強調が打ち消し合う
  • <title><desc> を付け、role="img"aria-labelledby で紐付ける。本文・<desc>・可視キャプションの3者が同じことを言っているか確認する
  • ファイルを書いて終わりにしない。sips でPNGにして、自分の目で見る

書いた本人がルールを守れているかも、結局は出力を見ないと分かりません。フォントスタックを書き忘れた図が4枚見つかったのは、既存の図をまとめてラスタライズし直したときでした。

残っている面倒なところ

ラスタライズして目視するループは、視覚的な崩れにはよく効きます。一方で、確認のたびにPNGを画像として読み込むので、図の枚数が増えるとやり取りの量が増えます。1つの記事に図を5枚入れて、それぞれ3回ずつ書き直すと、15枚のPNGを読むことになります。

崩れの検出を機械的にできる範囲は、まだ広げられます。文字列の推定幅と箱の width を突き合わせてはみ出しを検出する、<marker> の使用を検出する、ルートの font-family の有無を検出する、といった項目はレンダリングせずに判定できます。機械で落とせるものを先に落として、目視を「機械では判定できない崩れ」だけに絞る形へ寄せていく予定です。

sips のレンダリング結果とブラウザーの表示が完全に一致するわけではない点も残ります。手元では <marker> 以外に目立つ差は出ていませんが、フィルターやグラデーションを多用すると差が出る可能性があります。図の表現を、両方のレンダラーで同じに出る範囲へ意図的に狭めて運用しています。