字体是怎样渲染的

前言

感觉从事计算机相关行业久了,不惯是写代码的还是做设计的,应该都免不了要跟字体打交道,一些基本的字体格式(ttf)也都还算了解,知道一个不同的 ttf 可以渲染出不同形状的文字。

这个时候问题来了,现在在我们的左边是存储了各种字形信息的字体文件,在我们右边是已经渲染出好的文字。在这中间发生了什么?

在参考了一系列博客、官方文档,并阅读调试了 Typr.js 源码后,我得以一窥字体的渲染方式,并将他写下来。本文将涵盖一下内容:

  • 整个渲染流程的介绍
  • 最小版本的基于 Python fonttools 库渲染字体的实现

字体格式与读取

关于字体文件(ttf)标准的一些了解可以参考 这篇文章。我在 中解读了具体怎么解析一个 ttf 文件。我们只需要了解一个 ttf 可以看成是一个 List[Dict],即包含多个字典(table)的列表,每个字典里的值可以是数字、文本、数组等。

使用 fonttools 可以方便的直接解析一个 ttf 文件。为了方便比对和调试,我以 github/Typr.js/demo/LiberationSans-Bold.ttf 这一英文字体为例。

from fontTools import ttLib
tt = ttLib.TTFont("LiberationSans-Bold.ttf")
print(tt.keys())

>>> ['GlyphOrder', 'head', 'hhea', 'maxp', 'OS/2', 'hmtx', 'cmap', 'fpgm', 'prep', 'cvt ', 'loca', 'glyf', 'kern', 'name', 'post', 'gasp', 'FFTM', 'GDEF', 'GPOS', 'GSUB']

其中四个字节长度的是字体文件中所有的表名,这是由字体文件的标准决定的,表名以四个字节长度大端存储。

所以你能看到有一个表名是 'cvt ',GlyphOrder 则是该库自己解析出来的。

TTF文件探秘 所说:

整体来看 TTF 文件,我们可以学到一些高密度存储数据的方式

fonttools 自行优化了一些读取方式,这使我们用 fonttools 能够更容易的获取到表的一些数据了。

此时,可以对照官方文档 参考这些表的意义,这里简单列几个

  • kern: kerning(字距调整)
  • hmtx: horizontal metrics(字形的水平宽度)
  • cmap: character to glyph mapping(unicode 到 字形 index 的映射)
  • glyf: glyph data(字形数据)

获取 character name

cmap: character to glyph mapping

通过打印 cmap 中的 table 我们能看到 cmap 存储了一个 index 到 character name 的映射:

print(tt['cmap'].tables[0].__dict__)

>>> 'cmap': {32: 'space', 33: 'exclam', 34: 'quotedbl', 35: 'numbersign', 36: 'dollar', 37: 'percent', 38: 'ampersand', 39: 'quotesingle', 40: 'parenleft', 41: 'parenright', 42: 'asterisk', 43: 'plus', 44: 'comma', 45: 'hyphen', 46: 'period', 47: 'slash', 48: 'zero', 49: 'one', 50: 'two', 51: 'three', 52: 'four', 53: 'five', 54: 'six', 55: 'seven', 56: 'eight', 57: 'nine', 58: 'colon', 59: 'semicolon', 60: 'less', 61: 'equal', 62: 'greater', 63: 'question', 64: 'at', 65: 'A', 66: 'B', 67: 'C', 68: 'D', 69: 'E', 70: 'F', 

这些 index 实际是 unicode 编码,在 python 中我们可以通过 ord 或者 encode(‘utf8’) 来转换:

print(ord(','))
>>> 44

print(list(',A'.encode('utf8')))
>>> [44, 65]

我们可以基于此拿到相应的 character name,比如 ',A' -> [44, 65] -> ['comma', 'A']

寻找字形数据

glyf: glyph data

print(tt['glyf'].__dict__.keys())
>>> dict_keys(['tableTag', 'glyphs', 'glyphOrder'])

print(tt['glyf'].__dict__['glyphOrder'])
>>> ['.notdef', '.null', 'nonmarkingreturn', 'space', 'exclam', 'quotedbl', 'numbersign', 'dollar', 'percent', 'ampersand', 'quotesingle', 'parenleft', 'parenright', 'asterisk', 'plus', 'comma', 'hyphen', 'period', 'slash', 'zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'colon', 'semicolon', 'less', 'equal', 'greater', 'question', 'at', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'bracketleft', 'backslash', 'bracketright', 'asciicircum', 'underscore', 'grave', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', 'braceleft',

由于 fonttools 已经帮我们处理好了映射关系,所以我们只可以直接根据 character name 找到字形数据

glyphs = tt['glyf'].__dict__['glyphs']
gls = [glyphs[i] for i in cname]
print(gls)
>>> [<fontTools.ttLib.tables._g_l_y_f.Glyph object at 0x11c099c10>, <fontTools.ttLib.tables._g_l_y_f.Glyph object at 0x11c0a41f0>]

我们还可以根据 glyphOrder 找出 character name 在原本字体文件中 glyphs 中的顺序:

cmap = tt['cmap'].tables[0].__dict__['cmap']
cname = [cmap[i] for i in list(',A'.encode('utf8'))] # ['comma', 'A']

glyphOrder = tt['glyf'].__dict__['glyphOrder']
gid = [glyphOrder.index(i) for i in cname]
print(gid)
>>> [15, 36]

字形数据 fonttools 帮我们处理成了字典,但没有帮我们处理成直观的格式:

print(gls[0].data)
>>> b'\x00\x01\x00\x8b\xfe\xc3\x01\xb0\x011\x00\n\x00"@\x11\x03\x04\n\x08\x04\x08\x0c\x0b\x08\t\x9b[\x08\x04\xa8[\x08\x00/++\x11\x12\x0199\x113\x11310%\x14\x06\x07#>\x015#\x11!\x01\xb039\xb9;J\x81\x01!Bx\xb8OG\xaaL\x011\x00\x00'

但没关系,这一部分逻辑在 Typr.js:_parseGlyf 中有相应的实现。我用 Python 复刻了其中的算法,将其最终转换为了 SVG path:

data = gls[0].data
print(data)
data = parse_glyf(data)
print(data)
>>> {'noc': 1, 'xMin': 139, 'yMin': -317, 'xMax': 432, 'yMax': 305, 'endPts': [10], 'instructions': [64, 17, 3, 4, 10, 8, 4, 8, 12, 11, 8, 9, 155, 91, 8, 4, 168, 91, 8, 0, 47, 43, 43, 17, 18, 1, 57, 57, 17, 51, 17, 51, 49, 48], 'flags': [37, 20, 6, 7, 35, 62, 62, 53, 35, 17, 33], 'xs': [432, 432, 381, 324, 139, 198, 272, 272, 143, 143, 432], 'ys': [66, -54, -238, -317, -317, -246, -76, 0, 0, 305, 305]}

svg_path = simple_Glyphfunction(data)
print(svg_path)
>>> ['M', 432, 305, 'L', 432, 66, 'Q', 432, -54, 406, -146, 'Q', 381, -238, 324, -317, 'L', 139, -317, 'Q', 198, -246, 235, -161, 'Q', 272, -76, 272, 0, 'L', 143, 0, 'L', 143, 305, 'Z']

那这样是不是就大功告成了呢?我们将 ‘,A’ 同时转换成 svg path,然后构建 svg 文件看看效果

svg_paths = []
for gl in gls:
    data = gl.data

    data = parse_glyf(data)
    svg_path = simple_Glyphfunction(data)
    svg_paths.extend(svg_path)

print(' '.join(map(str, svg_paths)))
M 432 305 L 432 66 Q 432 -54 406 -146 Q 381 -238 324 -317 L 139 -317 Q 198 -246 235 -161 Q 272 -76 272 0 L 143 0 L 143 305 Z M 1425 0 L 1133 0 L 1008 360 L 471 360 L 346 0 L 51 0 L 565 1409 L 913 1409 Z M 760 1123 L 739 1192 L 733 1170 Q 723 1134 709 1088 Q 695 1042 537 582 L 942 582 L 803 987 Z

然而结果并不是很理想,有两个问题:

  • 上下颠倒了
  • 没有间距,挤在一起了
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 26.0.3, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
<svg version="1.1" id="图层_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
	 viewBox="0 0 190 160" style="enable-background:new 0 0 190 160;" xml:space="preserve">
<g>
	<path d="M 432 305 L 432 66 Q 432 -54 406 -146 Q 381 -238 324 -317 L 139 -317 Q 198 -246 235 -161 Q 272 -76 272 0 L 143 0 L 143 305 Z M 1425 0 L 1133 0 L 1008 360 L 471 360 L 346 0 L 51 0 L 565 1409 L 913 1409 Z M 760 1123 L 739 1192 L 733 1170 Q 723 1134 709 1088 Q 695 1042 537 582 L 942 582 L 803 987 Z
"/>
</g>
</svg>

首先是颠倒的问题,比较好解决:y 坐标轴全取复数即可

<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 26.0.3, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
<svg version="1.1" id="图层_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
	 viewBox="0 0 190 160" style="enable-background:new 0 0 190 160;" xml:space="preserve">
<g>
	<path d="M 432 -305 L 432 -66 Q 432 54 406 146 Q 381 238 324 317 L 139 317 Q 198 246 235 161 Q 272 76 272 0 L 143 0 L 143 -305 Z M 1116 0 L 844 0 Q 840 -15 834 -75 Q 829 -136 829 -176 L 825 -176 Q 734 20 479 20 Q 290 20 187 -127 Q 84 -275 84 -540 Q 84 -809 192 -955 Q 301 -1102 500 -1102 Q 615 -1102 698 -1054 Q 782 -1006 827 -911 L 829 -911 L 827 -1089 L 827 -1484 L 1108 -1484 L 1108 -236 Q 1108 -136 1116 0 Z M 831 -547 Q 831 -722 772 -816 Q 714 -911 600 -911 Q 487 -911 432 -819 Q 377 -728 377 -540 Q 377 -172 598 -172 Q 709 -172 770 -269 Z
"/>
</g>
</svg>

其次是间距的问题。

利用 hmtx 和 kern 调整间距

hmtx: horizontal metrics
kern: kerning

我们可以看到 hmtx 为每个 character name 存储了两个信息:

print(tt['hmtx'].metrics)
>>> {'.notdef': (1536, 205), '.null': (0, 0), 'nonmarkingreturn': (569, 0), 'space': (569, 0), 'exclam': (682, 193), 'quotedbl': (971, 135), 'numbersign': (1139, 35), 'dollar': (1139, 27), 'percent': (1821, 51), 'ampersand': (1479, 90), 'quotesingle': (487, 109), 'parenleft': (682, 102), 'parenright': (682, 2), 'asterisk': (797, 6), 'plus': (1196, 86), 'comma': (569, 139), 'hyphen': (682, 80), 'period': (569, 139), 'slash': (569, 20), 'zero': (1139, 81), 'one': (1139, 129), 'two': (1139, 71), 'three': (1139, 47), 'four': (1139, 31), 'five': (1139, 63), 'six': (1139, 75), 'seven': (1139, 88), 'eight': (1139, 65), 'nine': (1139, 71), 'colon': (682, 197), 'semicolon': (682, 195), 'less': (1196, 86), 'equal': (1196, 85), 'greater': (1196, 86), 'question': (1251, 94), 'at': (1997, 117), 'A': (1479, 51), 'B': (1479, 137), 'C': (1479, 84), 'D': (1479, 137), 'E': (1366, 137), 'F': (1251, 137), 'G': (1593, 84), 'H': (1479, 137), 'I': (569, 137), 'J': (1139, 31), 'K': (1479, 137), 'L': (1251, 137), 'M': (1706, 137), 'N': (1479, 137), 'O': (1593, 84), 'P': (1366, 137), 'Q': (1593, 84), 'R': (1479, 137), 'S': (1366, 59), 'T': (1251, 23), 'U': (1479, 123), 'V': (1366, 14)

而 kern 则为每两个 character name 存储了一个长度:

print(tt['kern'].kernTables[0].kernTable)
>>> {('space', 'A'): -76, ('space', 'Y'): -37, ('space', 'Alphatonos'): -76, ('space', 'Alpha'): -76, ('space', 'Delta'): -76, ('space', 'Lambda'): -76, ('space', 'Upsilon'): -37, ('space', 'Upsilondieresis'): -37, ('one', 'one'): -113, ('A', 'space'): -76, ('A', 'T'): -152, ('A', 'V'): -152, ('A', 'W'): -113, ('A', 'Y'): -188, ('A', 'v'): -76, ('A', 'w'): -37, ('A', 'y'): -76, ('A', 'quoteright'): -113, ('F', 'comma'): -227, ('F', 'period'): -227, ('F', 'A'): -113, ('L', 'space'): -37, ('L', 'T'): -152, ('L', 'V'): -152, ('L', 'W'): -113, ('L', 'Y'): -188, ('L', 'y'): -76, ('L', 'quoteright'): -113, ('P', 'space'): -37,

其中 hmtx 定义了每个字的宽度,而 kern 则定义了一些特殊字形(比如上标 å )的偏移距离。

综上所述,我们就可以用这两个表计算出一个字符在一串字符中应该处于哪个位置。

便于展示,这里省略了换行情况下 y 轴的处理:

x = 0
y = 0
svg_paths = []
for i, gl in enumerate(gls):
    ax = tt['hmtx'].metrics[cname[i]][0]

    data = gl.data

    data = parse_glyf(data)
    print(data)
    data['xs'] = [i + ax + x for i in data['xs']]
    # data['ys'] = [i + ax + x for i in data['ys']]
    svg_path = simple_Glyphfunction(data)
    svg_paths.extend(svg_path)

print(' '.join(map(lambda x: x if isinstance(x, str) else str(x), svg_paths)))
>>> M 1001 -305 L 1001 -66 Q 1001 54 975 146 Q 950 238 893 317 L 708 317 Q 767 246 804 161 Q 841 76 841 0 L 712 0 L 712 -305 Z M 2367 0 L 2095 0 Q 2091 -15 2085 -75 Q 2080 -136 2080 -176 L 2076 -176 Q 1985 20 1730 20 Q 1541 20 1438 -127 Q 1335 -275 1335 -540 Q 1335 -809 1443 -955 Q 1552 -1102 1751 -1102 Q 1866 -1102 1949 -1054 Q 2033 -1006 2078 -911 L 2080 -911 L 2078 -1089 L 2078 -1484 L 2359 -1484 L 2359 -236 Q 2359 -136 2367 0 Z M 2082 -547 Q 2082 -722 2023 -816 Q 1965 -911 1851 -911 Q 1738 -911 1683 -819 Q 1628 -728 1628 -540 Q 1628 -172 1849 -172 Q 1960 -172 2021 -269 Z

<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 26.0.3, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
<svg version="1.1" id="图层_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
	 viewBox="0 0 190 160" style="enable-background:new 0 0 190 160;" xml:space="preserve">
<g>
	<path d="M 1001 -305 L 1001 -66 Q 1001 54 975 146 Q 950 238 893 317 L 708 317 Q 767 246 804 161 Q 841 76 841 0 L 712 0 L 712 -305 Z M 2367 0 L 2095 0 Q 2091 -15 2085 -75 Q 2080 -136 2080 -176 L 2076 -176 Q 1985 20 1730 20 Q 1541 20 1438 -127 Q 1335 -275 1335 -540 Q 1335 -809 1443 -955 Q 1552 -1102 1751 -1102 Q 1866 -1102 1949 -1054 Q 2033 -1006 2078 -911 L 2080 -911 L 2078 -1089 L 2078 -1484 L 2359 -1484 L 2359 -236 Q 2359 -136 2367 0 Z M 2082 -547 Q 2082 -722 2023 -816 Q 1965 -911 1851 -911 Q 1738 -911 1683 -819 Q 1628 -728 1628 -540 Q 1628 -172 1849 -172 Q 1960 -172 2021 -269 Z"/>
</g>
</svg>

至此,我们完成了从 unicode 字符到 SVG 图片的渲染,单从这一步来看,并没有多少技术难度,更多的是对字体文件标准的把握,作为入门,我觉得已经够了。

所有上文中提到的代码统一整合后放在 ttf-render-demo

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值