TTF文件探秘

6,903 阅读12分钟

提到 font-icon 大家应该都不陌生。因为其具有矢量图形,提交小,可以用css样式等优点,我们经常会将小图标制作成 font-icon 来使用。TTF(TrueTypeFont)是Apple公司和Microsoft公司共同推出的字体文件格式。往上有很多的现成的帮助我们制作字体图标的网站,例如iconfont,iconmoon。他们都有一个好用的功能,上传svg,然后就能生成 .ttf 等字体文件(本文主要探究 ttf 文件)。那么你是否好奇过,svg是怎么转成 .ttf 的呢? 要回答这个问题,首先要了解 TTF 文件到底是怎么存这些字体信息的。下面,按照 “如何找到文件中字体形状” 这个主线来粗略探索一下 TTF 文件。

探究前的准备工作

TTF文件的所有描述在官方文档中都可以找到详细的描述。但是官方文档有一个特点,就是多。而且单独来啃官方文档,还很容易犯困。

Typr.js 是 javascript 语言版本的 TTF 文件解析工具。可以结合代码,一边调试一边对着文档查。

  • 一份简单的 TTF demo 文件。

基础知识

约定

存储信息,我们最熟悉的方式就是 JSON 格式。但是对于文件来说,有一个致命的缺点,就是体积太大。缩小体积,一种最直观的方式,只保留 value,不要所有的 key。如此一来,就需要先约定好 JSON 的格式,例如第一个值是什么,占多少的字节;然后紧接着第二个值是什么,等等。虽然 TTF 不是 JSON 格式的,但是也类似,由于文件中只存了内容,没有 key,所以官方文档就是重要的约定,是一份文件使用说明书。

表结构

TTF文件内是由一系列的表组成的。官方文档用词是 "table"(C语言中常常用xxTable来表述数据结构),所以后文都会用“表”这个词。但是不要理解为我们前端的 table ,可以类比于 map 结构。例如有一个存储结构,保存的内容有 name 和 age,可以如下定义:

长度 定义 描述
4位 id 前32位bit存储的是id 的值
4位 age 接下来的4位,存储的是 age 的值

这样对应于下面的数据内容

{
  id: 1,
  age: 12
}

就可以保存为以下二进制

0001 1100

有了表结构的约定,就可以将 TTF 文件中,所有的二进制一一翻译过来,得到每个 key 对应的值。所以,要探究 TTF 文件里怎么保存字体的,就要搞明白文件中每个字节代表的什么。不过数据结构的设计中,还有一个关键的要素: 扩展性。在下文具体介绍中,我们来看 TTF 文件是 table 结构以及如何应对扩展性的。

文件解析

了解了基础知识之后,我们使用前面准备好的 demo,对照苹果官方文档来看文件内容的东西。有的时候官方文描述理解起来可能会比较抽象,可以借助于 Typr.js 中的源码来辅助理解。

文件最开头

文件最开头,描述了文件内部的整体的数据内容。结构如下

长度 定义 描述
32位 scalarType
16位 numTables 文件中一共有几张表
16位 entrySelector
16位 rangeShift

上表中, numTables 和文件的解析有关,这个值表示了文件接下来的数据是什么,例如解析出来 numTables = 11,那么接下来后面的数据依次描述了 11 张表的内容,也就是说下面的结构会重复出现 11 次

长度 定义 描述
32位 tag 表的名字
32位 checkSum 用来校验数据的正确性
32位 offset 表的真正数据内容,在文件中的起始字节数
32位 length 表真正数据的内容长度

TTF 中所有的数据都放在表结构中,不同的表结构代表了不同的数据。而 tag 则是表名字,也可以认为是表类型。32位的 tag 对应着 4 个 ASCII 码,例如我们的按照顺序读取的第一表的前 32 位 是 0x47 0x53 0x55 0x42 (直接使用类似 sublimeText 这样的工具打开的话,看到的就是 4753 5542 这样的,直接解析成16进制的了。文中为了方便也使用这样的 16 进制的)对应的 ASCII 是 GSUB,这个就是第一张表的 tag。 TTF 中定义了很多表,例如 'cmap','head' 等,不同的表分类存储了不同的内容。看官方文档,其中有 9 张表是必须,其他的表则根据情况作为扩展。

确定了表 tag,接下来的 32 位是 checkSum,我们可以用这个值来对文件的正确定进行校验。

再取后面的 32位,这个值就是 offset 可以确定每张表中真正数据存放的位置。再 32 位为这个表的数据长度。

例如 demo 解析出来如下的内容

tag: GSUB
checkSum: 2969482221
offset: 312
length: 66

那么就表明,文件中有一张叫 GSUB 的表,要读取这张表中的内容的话,就从 312 个字节开始(0表示文件第一个字节),312+66 就是这个表的最后一个字节。 依次循环,我们可以得到文件中所有表的位置描述。转为 map 结构例子如下:

{
	GSUB: {offset: 312, length: 66}
	OS/2: {offset: 380, length: 86}
	cmap: {offset: 476, length: 368}
	glyf: {offset: 852, length: 68}
	head: {offset: 224, length: 54}
	hhea: {offset: 188, length: 36}
	hmtx: {offset: 468, length: 8}
	loca: {offset: 844, length: 6}
	maxp: {offset: 280, length: 32}
	name: {offset: 920, length: 621}
	post: {offset: 1544, length: 47}
}

按照这样的方式,文件中的保存的表,可以按照任意顺序来存放,只需要将信息保存在头部即可。如果未来需要保存更多的信息而新增表时,现有的整体结构仍然可以支持,满足了扩展性的要求。

字体形状的描述

通过对文件开头的解析,得到了文件中每张表在哪里。其中有一张叫 glyf 的表,每个字体的形状描述就放在这里。先瞅一眼 glyf 的描述,戳这里,这个表中存了每个字体的形状,基本的结构是

第一个字体形状一堆数据 第二个字体形状一堆数据 第三个字体形状一堆数据 ...

这张表只保存了形状数据,但是要获取每个字体的形状,我们需要要明确的知道每个字体数据在这张表中的开始位置和结束位置,这就需要结合 maxp 和 loca 表。

个数

先看 maxp 表,从上一节解析出来的内容,定位到 maxp 在文件中的起始位置 offset: 280。结合表结构描述,读取内容,可以得到下面的信息

长度 定义 描述
固定的32位 version 0x00010000 (1.0)固定为1.0版本
16位 numGlyphs 文件中字体形状的总个数

假设在这里可以获取到 numGlyphs: 2 。拿到数量之后,就去 loca 中获取每个字体在 glyf 中的位置了。

位置

loca 表的结构是一个数组,每一项对应一个字体形状数据在 glyf 表中的偏移位置(offset)。不过 loca 表对偏移位置的存储有两种方式: long 和 sort。具体使用哪个,由 head 表中 indexToLocFormat 标识决定。

如果是 sort 方式的话,loca 表每一项是 16 位,而对应的 offset 值是

offset = readUnit16() * 2

如果是 long 方式,loca 表每一项是 32 位,对应的 offset 值是

offset = readUnit32()

采用 long 和 sort 的两种方式,其实是一种适当缩减文件大小的方式。当我们文件中的字体个数比较少的时候,offset 的最大值也比较小,如果不超过 sort 方式所能描述的最大值时,就可以选用 sort 的方式,这样整体 loca 所占用的字节数就比较少。若文件内字体太多,sort 已经无法容纳下了,那么就换用 long 的方式。由于 glyf 结构的规定保证了每个字形的数据长度是 n*32 位,sort 中采用乘2的方式,使得可以描述的最大 offset 变大。但是对于 long 方式,为什么不乘以2了呢?个人猜测是:没必要,因为 32位 本身可以描述的范围已经很大了。

介绍完定义,看例子,假设 loca 解析出来结果:

indexToLocFormat = 0 // sort 模式
//loca 值
[0, 42]

那么对应的 offset 结果是

// 在 glyf 中 offset
第一个字形: 0 * 2
第二个字形: 42 * 2
// 在整体文件中的 offset
第一个字形: 0*2 + glfy.offset
第二个字形: 42 * 2 + glfy.offset

到此就可以获得每一个字形数据在文件中的位置了,接下来到 glyf 中获取具体的形状。

形状

我们从上一节中获取到的 offset 位置来解析具体的一个字形,首先会依次获取到如下几个数据

长度 定义 描述
16位 numberOfContours 用来绘制字形的轮廓数量
32位 xMin 字形坐标中x值的最小值
32位 yMin 字形坐标中y值的最小值
32位 xMax 字形坐标中x值的最大值
32位 yMax 字形坐标中y值的最大值

可以戳这篇文章了解什么是字形的轮廓。 文档中对于 numberOfContours 还有个说明,当这个值是负值的时候,表示复合字形;当这个值是 0 或者正数的时候,是简单字形。复合字形和简单字形后续对应的数据存储方式也有很大的差异,这里我们仅以简单字形来做分析。下面是简单字形的存储结构:

长度 定义 描述
16位 endPtsOfContours[n] 每条轮廓线结束点的坐标的数组,如果有 n 条轮廓那么就有 n 个值,每个值由16位表示
16位 instructionLength 描述信息的长度
8位 instructions[instructionLength] 一些描述信息数组
uint8 flags[variable] 表示点的状态等等
8位或者16位 xCoordinates[] x坐标值
8位或者16位 yCoordinates[] y坐标值

endPtsOfContours 用来分割每一条轮廓。假如 endPtsOfContours 值是 [9, 79],那么 xCoordinates 和 yCoordinates 中 index = 0 到 index = 9 所表示的 10 个坐标点连起来行程第一条轮廓,index = 10 到 index = 79 所表示的坐标点连接起来绘制出第二条轮廓线。

字形的轮廓线中是有一些弧线的,例如下面这个图,

例如上图 1 - 9 的点连成弧线,而 0 - 1 就是一条直线。所以除了知道每个点的坐标之外,还需要知道怎么把这些点变成轮廓。我们知道描述可以用贝塞尔方程式来描述曲线,上图中实心的点是在曲线上的点,而空心的点则是贝塞尔曲线的控制点。所以通过用 (曲线上点1,非曲线上点2,非曲线上点3,非曲线上点4,曲线上点5)这几个点就可以确定出一段弧线。并且通过(曲线上点0,曲线上点1,非曲线点2)这三个点是否在曲线上的状态,还可以判断出点1 是一段贝塞尔曲线的起始点。官方文档有一个很详细的描述。总的来说,除了点的坐标,我们还需要点的状态,这个状态就由 flag 中的一个 bit 来表示。状态结合坐标,我们就可以确定出字形的轮廓了,再由各个轮廓组成一个完整的字形。

最后结合其他表中对字形的整体描述,例如每个字形大小,字形的胖瘦,排版方向等等,字形的展示就完全确定了。

总结

整体来看 TTF 文件,我们可以学到一些高密度存储数据的方式: 1.通过约定,省略了对 key 的保存,只保留 value 2.文件开头总的结构描述,保证了整体数据的可扩展性。 3.类似 glyf 中的 flag ,对于 boolean 的状态值,使用 1 个 bit 来表示。通过一个 8 位的数据,来集中表示多个状态。 4.很多地方会有 long 和 sort 的不同模式,当字形数量少的时候进一步压缩。

后话

查找字体

那么我们经常在 font-icon 的使用中,都会写 content: ‘\uxxxx’,然后浏览器就会渲染出对应的东西。是怎么找到的呢?

TTF 文件中有一个必须的基本表: cmap 表。这个表中保存了每个字形的 id 和对应的 glyf 中的 index。cmap 有很多中保存 id 的方式, 假设按照 format12 的结构解析出来 cmap 内容如下

startCharCode  endCharCode  startGlyphCode
59000,         59030,       1
59100,         59105,       8

某个字形的编码是 \ue680, 对应数值是 59008,在第一个区间里。那么对应 glyf 中 index 值是

(59008-59000) + 1 // 9

然后结合 loca 中的 offset 信息,就找到对应的字形轮廓了。其他的 id 保存方式可以看文档。

svg 到 font-icon

网上很多工具可以实现将 svg 制作为 .ttf 文件,是如何实现的呢? 简单来说,svg中会有一个path。这个 path 信息中的内容,可以对照 TTF 官方中绘制轮廓的规则,将其转化为 glyp 中每个点和状态。例如 M10 10 C 20 20, 40 20, 50 10 这样的path,可以得到4个点

点1: (10,10), onCurve: true
点2: (20,20), onCurve: false
点3: (40,20), onCurve: false
点4: (50,10), onCurve: true

有了字形轮廓后,再根据那一堆麻烦的规则,生成其他各种表的数据,最终形成 TTF 文件。