インフラ

Pure GoでのSVGからPNGへのレンダリング、そしてoksvgが諦める点

`oksvg`と`rasterx`を使用し、CGoやヘッドレスブラウザなしでサーバーサイドでOG画像を生成する。純粋なGoのパイプラインと、`oksvg`が壊れる2つの箇所。

この記事は英語の原文をAIモデルが翻訳したものです。表現が原文と異なる場合があります。 英語の原文を読む

このブログのすべての投稿にはソーシャルシェア画像が必要です。これは、誰かがチャットやタイムラインにリンクを貼り付けたときに表示される1200x630のPNG画像です。カバーアートはインラインSVGで、ブログ記事のトピックに合わせた小道具を持った小さなマスコットが描かれています。そして、オブジェクトストレージに配置される前に、サーバー上でPNGに変換される必要があります。そのための明白なツールであるヘッドレスブラウザやImageMagickは、どちらもコンテナに大きなネイティブの依存関係を持ち込みます。この記事では、ピュアGoでoksvgrasterxを使ってこれを実現する方法と、oksvgだけでは不十分になる2つの具体的な点について説明します。

要するに、ピュアGoを使えば、hexカラーを与えて自分でクロッピングを行う限り、単純なシェイプアートに最適な、決定論的で依存関係のないラスタライザを手に入れることができます。フィルター、完全なグラデーション、または本格的なテキストレイアウトが必要になった途端、答えはブラウザを使うことに戻ります。

なぜサーバーでSVGをレンダリングするのか

ソーシャルプレビュー画像はブラウザでは計算できません。なぜなら、それを読み取るクローラー(Slack、Discord、または検索エンジン)は、あなたのJavaScriptを実行しないからです。クローラーはHTMLをフェッチし、og:imageメタタグを読み取り、そこにあるURLをダウンロードします。そのため、PNGは最初のリクエストの前に実際のファイルとして存在する必要があり、これはサーバーがそれを生成しなければならないことを意味します。

ここでの設計上の制約は、サーバーがCloud Run上で、1つの小さなコンテナで、GPUもディスプレイもなく動作することでした。私はそのイメージにブラウザやネイティブの画像ライブラリを組み込みたくありませんでした。ヘッドレスChromeレイヤーだけでも数百メガバイトあり、独自のシステムライブラリ一式が必要です。ImageMagickは、それが扱うすべてのフォーマットに対してデリゲートライブラリを取り込みます。どちらも機能しますが、どちらもコンテナを大きくし、コールドスタートを遅くし、ビルドをより脆弱にします。

カバーアート自体はシンプルです。フラットな図形、いくつかの塗りつぶし、写真コンテンツはありません。これは、フルブラウザが過剰スペックとなるケースです。アートが少数のパスと円で構成されている場合、純粋なGoのラスタライザは、外部プロセスなしでミリ秒単位でそれをPNGに変換できます。

1つの意図的な選択が、他のすべてを形作りました。それは、PNGにテキストを入れないということです。テキストをレンダリングするということはフォントを同梱することを意味し、韓国語、日本語、そして両方の中国語書記体系をカバーするフォントを同梱するということは、大きなフォントペイロードと本格的なテキストシェーピングエンジンが必要になることを意味します。その代わり、画像は図形のみを保持し、タイトルはそれを取り巻くHTMLに任されます。その1つの決定が、サーバーサイドレンダリングの最も困難な部分を、それが始まる前に取り除きました。

純粋なGoのスタック:oksvgとrasterx

2つのパッケージがその処理を担います。github.com/srwiley/oksvgはSVGドキュメントを解析し、描画可能なアイコンに変換します。github.com/srwiley/rasterxは、スキャンラインフィルを用いてベクターパスをピクセルに変換するラスタライザーです。両方とも純粋なGoで書かれています。CGoも、リンクされたCライブラリもありません。コンパイル済みのバイナリ以外にコンテナにインストールするものは何もありません。

その特性こそが、これらを使用する全ての理由です。純粋なGoでのビルドは、Dockerfileが最小限のベース上の静的バイナリで済むこと、システムライブラリの移動によってビルドが壊れることがないこと、そしてクロスコンパイルが問題なく機能することを意味します。また、出力が決定論的であることも意味します。同じSVG文字列は、フォントヒンティングやGPUドライバーの差異なしに、どのマシンでも毎回同じPNGバイトを生成します。決定論的な出力はキャッシュを容易にし、画像をコンテンツアドレス可能にします。つまり、バイトをハッシュ化し、そのハッシュ値で保存すれば、再生成や重複は決して起こりません。

フローは4つのステージで構成され、それぞれが単純な関数です。

  1. マスコットとプロップを含むSVGを文字列として構築する。
  2. oksvgでそれを解析し、アイコンに変換する。
  3. rasterxでアイコンをラスタライズし、image.RGBAに変換する。
  4. 標準ライブラリでその画像をPNGにエンコードする。

ステージ1と4は通常のGoの処理です。ステージ2と3がoksvgとrasterxが使われる部分であり、後述する2つの制限が現れる場所です。

oksvgはhsl()を理解しない

最初の壁は色です。私はHSLカラーを使ってカバーアートを作成します。というのも、HSLを使うとパレットを簡単に派生させることができるからです。色相を選び、彩度を固定したまま、シャドウとハイライトのために明度を変化させるのです。そのため、SVG文字列には hsl(210, 50%, 60%) のような塗りつぶしが含まれていました。

oksvgは hsl() をパースしません。名前付きカラーとhexには対応していますが、hsl(...) の塗りつぶしはサイレントに適用失敗し、図形は塗りつぶしなし、またはデフォルトでレンダリングされます。エラーが出ないため、さらに厄介です。パースは成功し、描画は実行されますが、図形がただ間違っているだけなのです。

修正方法は、色がSVG文字列に到達する前にHSLをhexに変換することです。HSLからRGBへの変換は、短く、明確に定義された関数なので、私はGoでそれを計算し、#rrggbb を出力します:

package cover

import (
	"fmt"
	"math"
)

// hslToHex converts an HSL color to the #rrggbb string oksvg accepts.
// h is in [0, 360), s and l are in [0, 1].
func hslToHex(h, s, l float64) string {
	c := (1 - math.Abs(2*l-1)) * s
	hp := h / 60
	x := c * (1 - math.Abs(math.Mod(hp, 2)-1))

	var r, g, b float64
	switch {
	case hp < 1:
		r, g, b = c, x, 0
	case hp < 2:
		r, g, b = x, c, 0
	case hp < 3:
		r, g, b = 0, c, x
	case hp < 4:
		r, g, b = 0, x, c
	case hp < 5:
		r, g, b = x, 0, c
	default:
		r, g, b = c, 0, x
	}

	m := l - c/2
	ri := uint8(math.Round((r + m) * 255))
	gi := uint8(math.Round((g + m) * 255))
	bi := uint8(math.Round((b + m) * 255))
	return fmt.Sprintf("#%02x%02x%02x", ri, gi, bi)
}

そのヘルパーを使うと、パレットはGoコード内では扱いやすいHSLのままとなり、SVGには常にhex形式のみが渡されます。なお、この関数は各チャンネルを丸めるため、出力は安定し、パイプライン全体の決定性が保たれます。2つの呼び出し元で異なる丸め方をさせると、バイト単位の再現性が損なわれます。そのため、変換パスは1つに保ってください。

一般的な教訓は次のとおりです。oksvgはSVGの色仕様のサブセットを実装しているため、名前付きの色とhex形式を超えるものは、まず自分のコードでhex形式に解決する必要があると考えるのが安全です。これにはhsl()hsla()、および最新の色関数が含まれます。これらを事前に解決すれば、ラスタライザーが処理できない色に遭遇することはありません。

preserveAspectRatio sliceは信頼できないため、自分でクロップする

2つ目の壁はアスペクト比です。ターゲットは1200x630で、アスペクト比は約1.905です。カバーアートは正方形に近いviewBoxで作成されています。なぜなら、それがマスコットにとって自然なキャンバスだからです。そのため、ソースと出力の形状が異なり、何かを犠牲にする必要があります。

ブラウザでは、preserveAspectRatio="xMidYMid slice"を設定すると、レンダラーはアートを拡大縮小してボックスを覆い、はみ出した部分をクリップします。これはCSSのbackground-size: coverのような動作です。oksvgpreserveAspectRatioを部分的にサポートしていますが、ソースとターゲットのアスペクト比が大きく異なる場合、sliceモードは信頼できません。実際には、アートが引き伸ばされて円が楕円になったり、間違った場所でクリップされたりしました。その動作は一貫性がなく、信頼するのをやめました。

信頼できる方法は、oksvgに何もフィットさせないことです。viewBoxのアスペクト比でレンダリングし、ターゲットを完全に覆うように拡大してから、その結果を標準ライブラリでターゲットの正確なサイズに中央でクロップします。クロップは単純なピクセルコピーであり、oksvgは関与しないため、間違うことはありません。

まず、ターゲットボックスを覆うレンダリングサイズを計算します。これはCSSのcoverが使用するのと同じ計算です。

// coverSize scales the source viewBox so it fully covers the target box,
// matching CSS background-size: cover. The result is >= target on both axes.
func coverSize(vbW, vbH, targetW, targetH float64) (int, int) {
	scale := math.Max(targetW/vbW, targetH/vbH)
	return int(math.Ceil(vbW * scale)), int(math.Ceil(vbH * scale))
}

そのサイズでラスタライズ:

import (
	"fmt"
	"image"
	"strings"

	"github.com/srwiley/oksvg"
	"github.com/srwiley/rasterx"
)

func rasterize(svg string, w, h int) (*image.RGBA, error) {
	icon, err := oksvg.ReadIconStream(strings.NewReader(svg), oksvg.WarnErrorMode)
	if err != nil {
		return nil, fmt.Errorf("parse svg: %w", err)
	}
	icon.SetTarget(0, 0, float64(w), float64(h))

	img := image.NewRGBA(image.Rect(0, 0, w, h))
	scanner := rasterx.NewScannerGV(w, h, img, img.Bounds())
	raster := rasterx.NewDasher(w, h, scanner)
	icon.Draw(raster, 1.0)
	return img, nil
}

次に、正確なターゲットに合わせて中央を切り抜きます。

import (
	"image"
	"image/draw"
)

// centerCrop copies the middle target-sized rectangle out of a larger image.
func centerCrop(src *image.RGBA, targetW, targetH int) *image.RGBA {
	b := src.Bounds()
	x0 := b.Min.X + (b.Dx()-targetW)/2
	y0 := b.Min.Y + (b.Dy()-targetH)/2

	dst := image.NewRGBA(image.Rect(0, 0, targetW, targetH))
	draw.Draw(dst, dst.Bounds(), src, image.Pt(x0, y0), draw.Src)
	return dst
}

coverSizeは、レンダリングされる画像が両軸でターゲット以上の大きさになることを保証するため、クロップオフセットが負になることはなく、コピーには常に十分なソースピクセルがあります。アートは均一に拡大縮小されるため、何も引き伸ばされません。そして、クロップはマスコットが位置する中央部分を切り取ります。これは、sliceが行うはずだった処理を、あなたが制御するコードで実行します。

エンドツーエンドのパイプライン全体

これまでの要素を組み合わせると、1つの関数がSVG文字列とターゲットサイズを受け取り、PNGのバイトデータを返します:

import (
	"bytes"
	"image/png"
)

// RenderPNG rasterizes an SVG at the viewBox aspect, crops to the target box,
// and encodes to PNG. The output is deterministic for a given input.
func RenderPNG(svg string, vbW, vbH, targetW, targetH int) ([]byte, error) {
	rw, rh := coverSize(float64(vbW), float64(vbH), float64(targetW), float64(targetH))

	full, err := rasterize(svg, rw, rh)
	if err != nil {
		return nil, err
	}

	cropped := centerCrop(full, targetW, targetH)

	var buf bytes.Buffer
	if err := png.Encode(&buf, cropped); err != nil {
		return nil, fmt.Errorf("encode png: %w", err)
	}
	return buf.Bytes(), nil
}

外部プロセス、一時ファイル、ネットワーク呼び出しはありません。この関数は純粋な計算であるため、テストは簡単です。固定のSVGを入力し、バイトをハッシュ化し、そのハッシュをアサートします。パス全体が決定論的であるため、そのテストはマシンやGoのバージョンを問わずグリーン(成功)のままです。また、この同じ特性により、呼び出し元はコンテンツハッシュをストレージキーとして使用できます。一度生成し、og/{hash}.png の下に保存すれば、それ以降の同じカバーアートに対するリクエストはすべて、再生成なしで同じオブジェクトに解決されます。

コストプロファイルは、このジョブに求められるものです。数十のパスを持つカバーは、約1ミリ秒でエンコードされ、いくつかのRGBAバッファを割り当てて、リターンします。生成するブラウザも、回収するプロセスも、アクセスをシリアライズするための共有ネイティブ状態もありません。リクエストパスや取り込みジョブの内部で、特別な処理なしに問題なく実行されます。

oksvgがレンダリングするもの、しないもの

率直に言うと、oksvgはSVG仕様の一部を実装しており、すべてではありません。フラットなシェイプのアートには十分すぎるほどです。それよりリッチなものについては、機能を静かにドロップします。このユースケースで何が維持され、何がされなかったのか、その概要は以下の通りです。

SVGの機能 oksvgのサポート 注意点
パス、矩形、円、多角形 完全 カバーアートの中核であり、きれいにレンダリングされます
16進数および名前付きの塗りと線 完全 hsl()ではなく、これらを使用してください
hsl()と最新の色関数 なし 最初にGoで16進数に変換してください
線形グラデーション 部分的 単純なケースは機能しますが、複雑なストップはズレることがあります
preserveAspectRatio slice 信頼性低い ネイティブの比率でレンダリングし、自分でトリミングしてください
フィルター(ぼかし、ドロップシャドウ) なし 実装されておらず、静かに無視されます
実際のフォントレイアウトを持つテキスト 回避 フォントとシェーピングが必要であり、設計上除外されています

失敗のパターンは一貫しています。oksvgが何かをサポートしていない場合、通常はエラーを返しません。パースして描画しますが、欠落している機能は単に出力からなくなります。そのため、簡単な目視チェックでは失敗を見逃しやすく、ゴールデンイメージテストで検出することが重要になります。アートをレンダリングし、一度すべてのピクセルを確認し、バイトハッシュで出力をロックすることで、後の編集でサポートされていない機能に抵触した場合に差分として現れるようにします。

ここでのカバーアートの場合、アートが制約に合わせてデザインされていたため、制約を受け入れるのは簡単でした。フラットな塗り、16進数の色、フィルターなし、埋め込みテキストなしです。もし元のアートがこれらの制限を念頭に置かずに描かれている場合、oksvgが忠実にレンダリングする前に、それを単純化する必要があるでしょう。

実際にブラウザが必要になるとき

SVGがシンプルで、かつそれを制御できる場合、Pure Goは適切なツールです。しかし、ある明確な一線を越えると、それはもはや適切なツールではなくなります。ラスタライザーをその限界を超えて酷使しないように、その一線を明示しておく価値があります。

アートがSVGフィルターを使用する場合、oksvgが誤ってレンダリングするグラデーションやマスクに依存する場合、またはフォントでレイアウトする必要がある実際のテキスト(特に複数の書記体系にまたがるもの)を含む場合は、ヘッドレスブラウザに頼りましょう。ブラウザは、完全なテキストシェーピングスタックを備えた完全なSVGおよびCSSエンジンであり、oksvgがその半分を欠落させてしまうような複雑なドキュメントも正しくレンダリングします。その代償は、このアプローチ全体が避けようとしていたもの、つまり、大きなコンテナ、遅いコールドスタート、そして重いビルドです。その代償は、出力がそれを要求する場合には支払う価値がありますが、そうでない場合には無駄になります。

ここから導き出されたルールは、「レンダラーをアートに合わせる」ということです。もしカバーがテキストなしのフラットな図形と16進数カラーで構成されているなら、Pure Goのラスタライザーは、Goが実行される場所ならどこでも動作する、小さく、決定的で、依存関係のないパスを提供します。もしアートが真のSVG仕様を必要とするなら、真のSVG仕様を実装するエンジンを使用し、それに付随するコンテナを受け入れましょう。間違いは、複雑なアートをoksvgで無理やり処理しようとすることや、Pure Goでミリ秒単位で完了できたはずのジョブにブラウザを引きずり込むことです。