基礎設施

在純 Go 中將 SVG 算繪為 PNG,以及 oksvg 的極限

在伺服器端使用 oksvg 與 rasterx 生成 OG 圖片,無需 CGo 與無頭瀏覽器。純 Go 的管線,以及 oksvg 出問題的兩個地方。

本文由 AI 模型從英文原文翻譯而來,用字可能與原文有所出入。 閱讀英文原文

這個部落格的每篇文章都需要一張社群分享圖片:一張 1200x630 的 PNG,當有人將連結貼到聊天室或時間軸時會顯示。封面圖是一個行內 SVG,一個帶著主題道具的小吉祥物,而在它進入物件儲存之前,必須在伺服器上變成 PNG。顯而易見的工具,如無頭瀏覽器或 ImageMagick,都會將龐大的原生依賴項拖入容器中。這篇文章是關於如何使用純 Go 搭配 oksvg 和 rasterx 來完成這件事,以及關於 oksvg 不再足夠的兩個特定情況。

簡而言之:純 Go 讓你得到一個確定性、無依賴的光柵化器,它非常適合簡單的形狀藝術,只要你提供十六進位顏色並自己進行裁切。一旦你需要濾鏡、完整的漸層或真正的文字排版,答案就回歸到瀏覽器。

為什麼要在伺服器端渲染 SVG

社群預覽圖片無法在瀏覽器中運算產生,因為讀取它的爬蟲,無論是 Slack、Discord 還是搜尋引擎,絕不會執行你的 JavaScript。它會擷取 HTML、讀取 og:image meta 標籤,並下載該處的任何 URL。所以 PNG 必須在第一個請求之前就以真實檔案的形式存在,這意味著伺服器必須產生它。

這裡的設計限制是,伺服器在 Cloud Run 上執行,它是一個小型的容器,沒有 GPU,也沒有顯示器。我不想將瀏覽器或原生影像處理函式庫打包進那個映像檔中。單單一個無頭 Chrome 層就有數百 MB,並且需要自己的一套系統函式庫。ImageMagick 會為了它接觸到的每種格式而引入代理函式庫。兩者都可行,但都會讓容器變得更大、冷啟動更慢,且建置過程更脆弱。

封面藝術本身很簡單:平面形狀、幾個填充、沒有攝影內容。在這種情況下,使用一個完整的瀏覽器就顯得殺雞用牛刀了。如果藝術作品只是一些路徑和圓形,一個純 Go 的光柵化器就能在一毫秒內將它轉成 PNG,且無需任何外部程序。

一個刻意的選擇塑造了其他的一切:不將任何文字放入 PNG 中。渲染文字意味著要附帶字型,而附帶涵蓋韓文、日文以及兩種中文腳本的字型,則意味著龐大的字型負載以及一個真正的文本塑形引擎。取而代之的是,圖片只承載形狀,而標題則留給其周圍的 HTML。這單一的決定,在伺服器端渲染最困難的部分開始之前,就已經將其移除了。

純 Go 技術棧:oksvg 加上 rasterx

這項工作由兩個套件完成。github.com/srwiley/oksvg 會將 SVG 文件解析為可繪製的圖示。github.com/srwiley/rasterx 是一個光柵化工具,它使用掃描線填充將向量路徑轉換為像素。兩者都是純 Go 語言。沒有 CGo,沒有連結的 C 函式庫,除了編譯好的二進位檔之外,容器中無需安裝任何東西。

這個特性是使用它們的全部理由。純 Go 的建置意味著 Dockerfile 可以是一個基於最小化基礎的靜態二進位檔,建置永遠不會因為系統函式庫移動而中斷,而且跨平台編譯也能正常運作。這也意味著輸出是確定性的。同一個 SVG 字串在每台機器上每次都會產生相同的 PNG 位元組,沒有字型微調或 GPU 驅動程式的差異。確定性的輸出讓快取變得微不足道,並使圖像內容可定址:對位元組進行雜湊,將其儲存在該雜湊值下,您就永遠不需要重新生成或複製。

流程有四個階段,每個階段都是一個簡單的函式:

  1. 將 SVG 建構成一個字串,包含吉祥物和道具。
  2. 使用 oksvg 將其解析為一個圖示。
  3. 使用 rasterx 將圖示光柵化為 image.RGBA
  4. 使用標準函式庫將該圖像編碼為 PNG。

階段 1 和 4 是普通的 Go 操作。階段 2 和 3 則是 oksvg 和 rasterx 發揮作用的地方,也是下面提到的兩個限制出現之處。

oksvg 無法理解 hsl()

第一個難關是顏色。我使用 HSL 顏色來繪製封面藝術,因為 HSL 讓衍生調色盤變得容易:挑選一個色相,然後在保持飽和度固定的情況下,改變亮度來產生陰影和高光。所以 SVG 字串中會有像 hsl(210, 50%, 60%) 這樣的填充設定。

oksvg 不會解析 hsl()。它能處理命名顏色和十六進位碼,但 hsl(...) 的填充設定會無聲無息地套用失敗,導致圖形在渲染時沒有填充或使用預設值。沒有任何錯誤訊息,這讓情況更糟:解析成功、繪圖也執行了,但圖形就是不對。

解決方法是在顏色進入 SVG 字串之前,就先將 HSL 轉換為十六進位碼。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)。請注意,該函式會對每個通道進行四捨五入,因此輸出是穩定的,且整個管線都保持確定性。如果你讓兩個呼叫者以不同方式進行四捨五入,就會破壞逐位元組的可重現性,所以請保持單一的轉換路徑。

總結來說:oksvg 實作了 SVG 色彩規格的子集,而安全的假設是,任何超出具名顏色和十六進位碼 (hex) 的東西,都需要先在你自己的程式碼中解析為十六進位碼 (hex)。這包括 hsl()hsla() 和現代的色彩函式。預先解析它們,光柵器就永遠不會看到它無法處理的顏色。

preserveAspectRatio slice 不可靠,因此請自行裁切

第二道難關是長寬比。目標是 1200x630,長寬比約為 1.905。封面圖是在一個近乎方形的 viewBox 中創作的,因為那對吉祥物來說是個很自然的畫布。所以來源和輸出的形狀不一致,必須有所取捨。

在瀏覽器中,你會設定 preserveAspectRatio="xMidYMid slice",然後渲染器會縮放圖片以填滿框線並裁掉溢出的部分,就像 CSS 的 background-size: cover 一樣。oksvg 對 preserveAspectRatio 僅有部分支援,但當來源和目標的比例相差甚遠時,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 原本應該做的工作,但這是在你所控制的程式碼中完成的。

完整的端到端流程

將各個部分組合起來,一個函式會接收 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 下,之後每個使用相同封面藝術的請求都會解析到同一個物件,無需重新產生。

其成本概況正是這項工作所需要的。一個包含幾十個路徑的封面,編碼時間約為一毫秒,配置幾個 RGBA 緩衝區,然後返回。沒有要生成的瀏覽器,沒有要回收的程序,也沒有要序列化存取的共享原生狀態。它可以在請求路徑或擷取工作中順利運行,無需特殊處理。

oksvg 渲染什麼,又捨棄了什麼

坦白說,其界線在於 oksvg 實作了 SVG 規格的一部分,而非全部。對於扁平形狀的藝術作品來說,這已經綽綽有餘。對於任何更豐富的內容,它會悄悄地捨棄某些功能。以下是此使用案例中,哪些功能得以保留,哪些則未能支援的概況。

SVG 功能 oksvg 支援度 備註
Paths, rects, circles, polygons 完整支援 封面藝術的核心,能乾淨地渲染
十六進位與具名填色及描邊 完整支援 使用這些,而非 hsl()
hsl() 與現代色彩函式 先在 Go 中轉換為十六進位
線性漸層 部分支援 簡單的案例可以運作,複雜的色標可能會出錯
preserveAspectRatio slice 不可靠 以原始比例渲染並自行裁切
濾鏡(模糊、陰影) 未實作,會被靜默忽略
具備真實字型排版的文字 避免使用 需要字型與 shaping,設計上刻意排除

這些失敗案例的模式都相同:當 oksvg 不支援某個功能時,它通常不會報錯。它會進行解析、繪製,而缺失的功能就只是不會出現在輸出中。這使得在快速的目視檢查中很容易忽略這些失敗,因此透過「黃金映像」測試來捕捉這些問題就顯得格外重要。渲染你的藝術作品,逐一檢查每個像素,並用位元組雜湊鎖定輸出,這樣後續的編輯若觸發了不支援的功能,就會以差異 (diff) 的形式顯現出來。

對於這裡的封面藝術而言,這些限制很容易接受,因為該藝術作品在設計時就已考量到這些限制。扁平填色、十六進位色彩、無濾鏡、無嵌入文字。如果你的原始藝術作品在繪製時沒有考慮到這些限制,那麼在 oksvg 能夠忠實渲染它之前,你可能需要先將其簡化。

當您真正需要瀏覽器時

當 SVG 很簡單且由您控制時,純 Go 是正確的工具。它在一個明確的界線下就不再是正確的工具,而指明這條界線是值得的,這樣您才不會強迫光柵器超出其極限運作。

當作品使用 SVG 濾鏡、依賴 oksvg 渲染不正確的漸層或遮罩,或包含需要使用字型排版(特別是跨多種書寫系統)的真實文字時,請使用無頭瀏覽器。瀏覽器是一個完整的 SVG 和 CSS 引擎,具有完整的文字塑形堆疊,它會正確地渲染複雜的文件,而 oksvg 可能會遺漏其中一半的內容。其代價正是這整個方法所要避免的:一個龐大的容器、較慢的冷啟動,以及更重的建置成品。當輸出有此要求時,這個代價是值得付出的;而當沒有此要求時,則是浪費。

由此得出的規則是:讓渲染器與作品相匹配。如果封面是沒有文字的平面形狀和十六進位顏色,純 Go 的光柵器能為您提供一個小巧、具確定性、無依賴性的路徑,能在任何 Go 運行的環境中執行。如果作品需要真正的 SVG 規範,就使用實作真正 SVG 規範的引擎,並接受隨之而來的容器。錯誤在於試圖用 oksvg 處理複雜的作品,或是將瀏覽器拖入一個純 Go 只需一毫秒即可完成的工作。