在纯 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 元标签,然后下载该标签中的 URL。因此,PNG 文件必须在第一次请求前就以实体文件的形式存在,这意味着服务器必须生成它。
这里的设计约束是服务器运行在 Cloud Run 上,它是一个小型容器,没有 GPU,也没有显示设备。我不想将浏览器或原生图像处理库打包到该镜像中。仅一个无头 Chrome 层就有数百兆字节,并且需要自己的一套系统库。ImageMagick 会为它所接触的每种格式引入委托库。这两种方法都可行,但都会使容器变得更大、冷启动更慢、构建过程也更脆弱。
封面艺术本身很简单:扁平的形状、几种填充色,没有照片内容。在这种情况下,使用一个完整的浏览器就属于杀鸡用牛刀了。如果艺术作品只是一些路径和圆形,一个纯 Go 的光栅化工具可以在一毫秒内将其转换为 PNG,且无需任何外部进程。
一个刻意的选择决定了其他所有事情:不在 PNG 中放入任何文本。渲染文本意味着要附带字体文件,而要附带能覆盖韩语、日语以及两种中文字体的字体文件,则意味着巨大的字体负载和一个真正的文本塑形引擎。取而代之的是,图片只包含形状,而标题则留给其周围的 HTML 来处理。这一个决定就在服务器端渲染开始之前,移除了其中最困难的部分。
纯 Go 技术栈:oksvg 与 rasterx
有两个包完成了这项工作。github.com/srwiley/oksvg 将 SVG 文档解析为可绘制的图标。github.com/srwiley/rasterx 是一个光栅化器,它使用扫描线填充将矢量路径转换为像素。两者都是纯 Go 实现。没有 CGo,没有链接的 C 库,除了编译好的二进制文件外,无需在容器中安装任何东西。
这个特性是使用它们的全部原因。纯 Go 构建意味着 Dockerfile 可以是一个基于最小化基础镜像的静态二进制文件,构建过程不会因为系统库的移动而中断,并且交叉编译也能直接成功。这也意味着输出是确定性的。同一个 SVG 字符串在任何时候、任何机器上都会生成相同的 PNG 字节,没有字体微调(font-hinting)或 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 支持情况 | 说明 |
|---|---|---|
| 路径、矩形、圆形、多边形 | 完全支持 | 封面艺术的核心,渲染效果清晰 |
| 十六进制和具名填充与描边 | 完全支持 | 请使用这些,而不是 hsl() |
| hsl() 和现代颜色函数 | 不支持 | 先在 Go 中转换为十六进制 |
| 线性渐变 | 部分支持 | 简单情况可用,复杂的色标可能会有偏差 |
| preserveAspectRatio slice | 不可靠 | 按原生比例渲染并自行裁剪 |
| 滤镜(模糊、投影) | 不支持 | 未实现,会被静默忽略 |
| 带有真实字体布局的文本 | 避免使用 | 需要字体和字形塑造,设计上就排除了 |
这些失败案例的模式是相同的:当 oksvg 不支持某个功能时,它通常不会报错。它会解析、绘制,而缺失的功能只是在输出中不存在。这使得在快速目视检查中很容易漏掉这些失败,因此通过“黄金镜像”测试来捕获它们就显得尤为重要。渲染你的艺术作品,逐一检查每个像素,并用字节哈希锁定输出,这样后续编辑触发了不支持的功能时,就会以差异(diff)的形式显现出来。
对于这里的封面艺术,这些限制很容易接受,因为艺术作品在设计时就考虑到了这些限制。扁平填充、十六进制颜色、无滤镜、无嵌入文本。如果你的源艺术作品在绘制时没有考虑这些限制,那么在 oksvg 能够忠实渲染它之前,你需要对其进行简化。
何时才真正需要浏览器
当 SVG 简单且在你的控制之下时,纯 Go 是合适的工具。但它有一个明确的界限,超过这个界限它就不再是合适的工具了。明确这个界限是值得的,这样你就不会强行让光栅化工具超出其能力范围。
当艺术作品使用 SVG 滤镜、依赖 oksvg 无法正确渲染的渐变或蒙版,或者包含需要使用字体进行排版的真实文本(尤其是在涉及多种文字系统时),就应该使用无头浏览器。浏览器是一个完整的 SVG 和 CSS 引擎,拥有完整的文本塑形技术栈,它可以正确渲染复杂的文档,而 oksvg 可能会丢掉其中一半的内容。其代价正是这整个方法一直试图避免的:一个庞大的容器、较慢的冷启动和一个更重的构建包。当输出要求如此时,这个代价是值得付出的;而当并非如此时,这就是一种浪费。
由此得出的规则是:让渲染器与艺术作品相匹配。如果封面是平面形状和十六进制颜色,没有文本,那么纯 Go 的光栅化器为你提供了一条小巧、确定性、无依赖的路径,可以在任何运行 Go 的地方运行。如果艺术作品需要真正的 SVG 规范,就使用实现了真正 SVG 规范的引擎,并接受随之而来的容器。错误的做法是试图用 oksvg 处理复杂的艺术作品,或者将一个纯 Go 只需一毫秒就能完成的工作拖入浏览器来处理。