基础设施

在纯 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 元标签,然后下载该标签中的 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 驱动程序的差异。确定性的输出使缓存变得微不足道,并使图像内容可寻址:对字节进行哈希,将其存储在该哈希值下,就永远不需要重新生成或复制。

整个流程有四个阶段,每个阶段都是一个简单的函数:

  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 支持情况 说明
路径、矩形、圆形、多边形 完全支持 封面艺术的核心,渲染效果清晰
十六进制和具名填充与描边 完全支持 请使用这些,而不是 hsl()
hsl() 和现代颜色函数 不支持 先在 Go 中转换为十六进制
线性渐变 部分支持 简单情况可用,复杂的色标可能会有偏差
preserveAspectRatio slice 不可靠 按原生比例渲染并自行裁剪
滤镜(模糊、投影) 不支持 未实现,会被静默忽略
带有真实字体布局的文本 避免使用 需要字体和字形塑造,设计上就排除了

这些失败案例的模式是相同的:当 oksvg 不支持某个功能时,它通常不会报错。它会解析、绘制,而缺失的功能只是在输出中不存在。这使得在快速目视检查中很容易漏掉这些失败,因此通过“黄金镜像”测试来捕获它们就显得尤为重要。渲染你的艺术作品,逐一检查每个像素,并用字节哈希锁定输出,这样后续编辑触发了不支持的功能时,就会以差异(diff)的形式显现出来。

对于这里的封面艺术,这些限制很容易接受,因为艺术作品在设计时就考虑到了这些限制。扁平填充、十六进制颜色、无滤镜、无嵌入文本。如果你的源艺术作品在绘制时没有考虑这些限制,那么在 oksvg 能够忠实渲染它之前,你需要对其进行简化。

何时才真正需要浏览器

当 SVG 简单且在你的控制之下时,纯 Go 是合适的工具。但它有一个明确的界限,超过这个界限它就不再是合适的工具了。明确这个界限是值得的,这样你就不会强行让光栅化工具超出其能力范围。

当艺术作品使用 SVG 滤镜、依赖 oksvg 无法正确渲染的渐变或蒙版,或者包含需要使用字体进行排版的真实文本(尤其是在涉及多种文字系统时),就应该使用无头浏览器。浏览器是一个完整的 SVG 和 CSS 引擎,拥有完整的文本塑形技术栈,它可以正确渲染复杂的文档,而 oksvg 可能会丢掉其中一半的内容。其代价正是这整个方法一直试图避免的:一个庞大的容器、较慢的冷启动和一个更重的构建包。当输出要求如此时,这个代价是值得付出的;而当并非如此时,这就是一种浪费。

由此得出的规则是:让渲染器与艺术作品相匹配。如果封面是平面形状和十六进制颜色,没有文本,那么纯 Go 的光栅化器为你提供了一条小巧、确定性、无依赖的路径,可以在任何运行 Go 的地方运行。如果艺术作品需要真正的 SVG 规范,就使用实现了真正 SVG 规范的引擎,并接受随之而来的容器。错误的做法是试图用 oksvg 处理复杂的艺术作品,或者将一个纯 Go 只需一毫秒就能完成的工作拖入浏览器来处理。