Go语言实现文本转图片

2021-08-15 ⏳4.2分钟(1.7千字)

最近需要将大段的文本转换成图片。查了一下网上的资料,找到了hqbobo/text2pic这个项目,发现它在渲染仿宋字体的时候会给每个字加一个方框。于是简单研究了一下如何在 Go 语言中渲染文字。最终开发了txtimg小工具。今天就把相关经验总结一下分享给大家。

txtimg 绘制效果

代码的主体来自 Go 语言官方的 freetype 示例,可以从这里下载。

主要流程分三部分:准备、绘制和保存。

准备阶段又有三部分:加载字体、准备画布、初始化 freetype 引擎。

加载字体比较简单,核心代码如下:

fontBytes, err := ioutil.ReadFile(*fontfile)
f, err := freetype.ParseFont(fontBytes)

最终得到一个字体对象 f

然后就是准备画布,核心代码如下:

fg, bg := image.Black, image.White
rgba := image.NewRGBA(image.Rect(0, 0, 640, 480))
draw.Draw(rgba, rgba.Bounds(), bg, image.ZP, draw.Src)

主要是指定字体和背景颜色,初始化一片 rgba 画布并指定宽高,然后给画布画上背景色。

最终得到一个画布对象 rgba

然后就是初始化 freetype 引擎了,功能见注释。

c := freetype.NewContext()
// 设置像素密度
c.SetDPI(*dpi)
// 指定字体
c.SetFont(f)
// 指定字体大小 
c.SetFontSize(*size)
// 指定画布对象
c.SetDst(rgba)
// 指定画布绘制范围
c.SetClip(rgba.Bounds())
// 指定文字颜色
c.SetSrc(fg)

这里比较关键的是 dpisize 这两个参数,它们结合到一起会影响每一行显示多少个字。后面我们会详细讨论。

到这里准备工作完成,开始渲染文字。

pt := freetype.Pt(10, 10+int(c.PointToFixed(*size)>>6))
for _, s := range text {
  _, err = c.DrawString(s, pt)
  // ...
  pt.Y += c.PointToFixed(*size * *spacing)
}

核心方法是 c.DrawString()。绘制文字的时候需要指定一个 freetype.Pt 对象,这是一个坐标点。坐标系的横轴是 x 纵轴是 y。坐标值使用所谓的26-6定点数。26-6 定点数是一个 32 位整数,其中高 26 位表示整数部分,低 6 位表示小数部分。调用 c.DrawString() 需要指定文字的左下角坐标位置。但是我们不能直接使用 10 或者 20 这样数字,因为坐标值需要根据字号和 dpi 调整。freetype 为我们提供了 c.PointToFixed 函数,用来计算实际的坐标值。官方示例中 pt := freetype.Pt(10, 10+int(c.PointToFixed(*size)>>6)) 表示的坐标是 (0, font_size),两者都加了一个 10 是为了在上方和左侧保留 10 像素的空白。

c.DrawString() 绘制完成后会返回右边的位置,供下次绘制使用。

最后就是保存图片:

of, err := os.Create("out.png")
defer of.Close()
b := bufio.NewWriter(of)
err = png.Encode(b, rgba)
err = b.Flush()

以上就是绘制文字的整个过程。但示例代码有两个问题:

先说换行问题。一般文字转图片就是为了绕过一些平台(比如微博)对文本字数的限制,内容会很长。我们自然希望在转成图片的时候支持自动换行。

那么要如何实现这个自动换行呢?最简单的办法就是在绘制文字之前先计算一下绘制结果区域是最右侧坐标,如果超过了画布的宽度,则需要新起一行绘制。

那如何实现预估结果区域宽度呢?这就需要用到 freetype.Face 了。首先我们要初始化 Face 对象:

opts := truetype.Options{}
opts.Size = *size
opts.DPI = *dpi
face := truetype.NewFace(f, &opts)

注意,这里同样需要指定字体大小和 dpi。

然后,我们可以逐字符绘制图象:

for _, x := range []rune(scanner.Text()) {
  w, _ := face.GlyphAdvance(x)
  if pt.X.Round()+w.Round() > *width-*padding {
    newline()
  }
  pt, err = c.DrawString(string(x), pt)
}

在绘制之前,我们通过 face.GlyphAdvance(x) 计算即将占用的宽度,然后将数值跟当前位置的 X 坐标相加,如果超过了画布的宽度则新起一行。

新起一行的逻辑也很简单:

pt.X = fixed.Int26_6(*padding) << 6
pt.Y += c.PointToFixed(*size * *spacing)

这里要同时确定 X 和 Y 的值。X 的值取 padding;Y 的值取行距(其实就是字体大小的位数)乘上字体的高度。两都都需要转化成 26-6 定点小数。

第二个问题就是如何指定每一行的字数。这个问题非常迷。最终我找到了这篇文章,最关键的内容是 pixel_size = point_size * resolution / 72。其中 point_size 就是我们说的字体大小,resolution 就是 dpi,计算结果就是字体的像素尺寸。每一行可以显示的字数是 width / pixel_size,那根据字数 charspoint_size 的公式则是

point_size = (width - 2 * padding) / chars * 72 / dpi

如果两边需要留空白则需要指定 padding。有了这个公式,我们就不需要绞尽脑汁计算字体大小了。

最后一个问题就如何列表特殊字符。特殊字符有两种,一种是像 Emoji 这种字体文件没有的字符,另一种是空白字符。先说 Emoji。

Emoji 需要专门的字体才能显示。我简单试了一下,将字体指定为 Emoji 字体并不能支持显示表情,所以没办法,只能先忍痛放弃了。那问题来了,如何确认某字符不被当前字体文件支持呢?理论上 face.GlyphAdvance(x) 返回的第二个参数应该告诉我们对应字符是否被支持。但这个方法始终返回 true,可能是 bug。好在我们还有另外一个办法,使用字体对象的 f.Index() 方法。如果字体不支持某字符,f.Index() 会返回零。

空白字符的处理也比较简单。我发现字体文件不能正常渲染 \t,最终我的方案是将 \t 替换成空格。所以整体的绘制逻辑变成了这样:

w, _ := face.GlyphAdvance(x)
if x == '\t' {
        x = ' '
} else if f.Index(x) == 0 {
        continue
} else if pt.X.Round()+w.Round() > *width-*padding {
        newline()
}

pt, err = c.DrawString(string(x), pt)

好了,到这里就把整个过程分析完了。如果你想试用,可以直接安装 txtimg

go install github.com/taoso/txtimg@latest

cat file.txt | go run main.go \
	-fontfile font.ttf \
	-width 940 \
	-height 400 \
	-chars 20 \
	-spacing 1.0 > out.png

txtimg 基本满足我自己的需要了。如果想开发一个通用的文本转图片的工具还是相当困难的。所以我把自己的经验分享给大家,希望各位读者可以根据自己的实际需要开发或者定制自己的工具。后面如果有时间,我会研究一下如何支持 Emoji 表情。我还可能研究一下如何实现标点挤压功能(解决每一行末尾有空间但比实际字符占用空间小一点而导致的换行问题)。如果功能加的多了,txtimg 就变成一个排版工具了🐶当然这是力所不能及的。如果同学们有兴趣也欢迎提MR。