在純 Go 中將 SVG 算繪為 PNG,以及 oksvg 的極限
在伺服器端使用 oksvg 與 rasterx 生成 OG 圖片,無需 CGo 與無頭瀏覽器。純 Go 的管線,以及 oksvg 出問題的兩個地方。
這個部落格的每篇文章都需要一張社群分享圖片:一張 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 驅動程式的差異。確定性的輸出讓快取變得微不足道,並使圖像內容可定址:對位元組進行雜湊,將其儲存在該雜湊值下,您就永遠不需要重新生成或複製。
流程有四個階段,每個階段都是一個簡單的函式:
- 將 SVG 建構成一個字串,包含吉祥物和道具。
- 使用 oksvg 將其解析為一個圖示。
- 使用 rasterx 將圖示光柵化為
image.RGBA。 - 使用標準函式庫將該圖像編碼為 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 只需一毫秒即可完成的工作。