最近在公司的一个 Cocos2D-X 项目中碰到一个问题,TTF 文本加描边后会变宽,而且很明显没有对齐,字和描边之间没齐,每个字的水平基准线也没有对齐。最后发现是排版的代码有问题,官方分支上已经修复,但是这个项目使用的是 quick 分支出来的社区版,所以手动把修改做了个补丁提交了个修复TTF 描边效果的 PR。
在 Cocos2D-X 是 TTF 的排版是使用的开源库 FreeType 2,核心的实现基本都在 cocos/2d/CCFontFreeType.cpp
中的 FontFreeType::getGlyphBitmap
。原理是通过 FreeType 为每个字生成位图,然后通过 FreeType 返回的排版信息放到合适的位置,而问题就出现加了描边之后,位置计算的不正确。
为了弄清楚原因,又去看了下相关的文档,了解了下 Cocos2D-X 具体是如何排版 TTF 的。
Glyph Convention
FreeType 本身不光提供的 API 文档,而且还有很丰富的文档说明字体排版中的各种概念。在排版中最小的单位是一个 Glyph,对应文本中的一个字符。Glyph 会有一个原点 (Original),又被称为 pen cursor。在横向排版中,pen cursor 会在水平基准线上从左到右排列。
Glyph 中可见像素的最小包围框被称作 Bouncing Box (略作 bbox)。Bouncing Box 左上角相对原点会有一个偏移,即 (horiBearingX, horiBearingY)
。这样导出位图的时候就不用包含空白了。而 horiAdvance
则是下一个字符的原点相对该字符的偏移。
](/zh/2017/05/cocos2d-x-ttf/freetype-glyph.png)
要显示一行文本只需要先确定第一个字符原点的位置,相对这个位置找到 bbox 左上角的点,然后把 Glyph 位图填充在这个位置。再根据 horiAdvance
确定下一个字符的原点,重复直到处理完所有字符。

Stroker
要添加描边的话,实际是每个字符用 FreeType 导出了两次位图,一次是字符本体,一次是描边。描边本身的位图是会比本体大的,如下如示。

之前的代码比较粗暴地把本体和描边中心对齐,然后也没改位图左上角相对原点的偏移。因为不同的字符描边在各个方向多出的尺寸并不是一致的,放在一起就明显会不齐了。

测试代码
要测试的话,比较方便的是使用有 FreeType 库的脚本语言,比如 Python,下面的代码使用到了 freetype-py 来生成位图,然后使用 Pillow 生成图片。
※ main.py
import freetype as ft
from PIL import Image
def main(stroke=0):
"""executable entry."""
face = ft.Face('./WenQuanYiMicroHei.ttf')
face.set_char_size(48*64)
if stroke == 0:
flags = ft.FT_LOAD_DEFAULT
else:
flags = ft.FT_LOAD_DEFAULT | ft.FT_LOAD_NO_BITMAP
face.load_char('心', flags)
slot = face.glyph
glyph = slot.get_glyph()
if stroke > 0:
stroker = ft.Stroker()
stroker.set(
64,
ft.FT_STROKER_LINECAP_ROUND,
ft.FT_STROKER_LINEJOIN_ROUND,
0
)
glyph.stroke(stroker, True)
blyph = glyph.to_bitmap(ft.FT_RENDER_MODE_NORMAL, ft.Vector(0, 0), True)
bitmap = blyph.bitmap
width, rows, pitch = bitmap.width, bitmap.rows, bitmap.pitch
print({
'width': slot.metrics.width >> 6,
'height': slot.metrics.height >> 6,
'horiBearingX': slot.metrics.horiBearingX >> 6,
'horiBearingY': slot.metrics.horiBearingY >> 6,
'horiAdvance': slot.metrics.horiAdvance >> 6,
'bitmapWidth': bitmap.width,
'bitmapHeight': bitmap.rows
})
img = Image.new("L", (width, rows), "black")
pixels = img.load()
for y in range(img.size[1]):
offset = y * pitch
for x in range(img.size[0]):
pixels[x, y] = 255 - bitmap.buffer[offset + x]
img.show()
if __name__ == '__main__':
main()
main(2)