Web 项目中,使用一个适合的字体能给用户带来良好的体验。可是字体文件这么多,若是设计师或者开发人员想要查询字体,只能一个个打开,很是影响工做效率。所以,夸克平台须要实现一个功能,可以支持根据固定文字以及用户输入预览字体。在实现这一功能的过程当中主要解决两个问题:javascript
如今将问题的解决以及个人思考总结成文。css
在聊这两个问题以前,咱们先简述怎样使用一个 Web 自定义字体。要想使用一个自定义字体,能够依赖 CSS Fonts Module Level 3 定义的 @font-face
规则。一种基本可以兼容全部浏览器的使用方法以下:html
@font-face { font-family: "webfontFamily"; /* 名字任意取 */ src: url('webfont.eot'); url('web.eot?#iefix') format("embedded-opentype"), url("webfont.woff2") format("woff2"), url("webfont.woff") format("woff"), url("webfont.ttf") format("truetype"); font-style:normal; font-weight:normal; } .webfont { font-family: webfontFamily; /* @font-face里定义的名字 */ }
因为 woff2
、woff
、ttf
格式在大多数浏览器支持已经较好,所以上面的代码也能够写成:前端
@font-face { font-family: "webfontFamily"; /* 名字任意取 */ src: url("webfont.woff2") format("woff2"), url("webfont.woff") format("woff"), url("webfont.ttf") format("truetype"); font-style:normal; font-weight:normal; }
有了@font-face
规则,咱们只须要将字体源文件上传至 cdn,让 @font-face
规则的 url
值为该字体的地址,最后将这个规则应用在 Web 文字上,就能够实现字体的预览效果。java
但这么作咱们能够明显发现一个问题,字体体积太大致使的加载时间过长。咱们打开浏览器的 Network 面板查看:git
能够看到字体的体积为5.5 MB,加载时间为5.13 s。而夸克平台不少的中文字体大小在20~40 MB 之间,能够预想到加载时间会进一步增加。若是用户还处于弱网环境下,这个等待时间是不能接受的。github
那么中文字体相较于英文字体体积为何这么大,这主要是两个方面的缘由:web
咱们能够借助于 opentype.js
,统计一个中文字体和一个英文字体在字形数量以及字形所占字节数的差别:数组
字体名称 | 字形数 | 字形所占字节数 |
---|---|---|
FZQingFSJW_Cu.ttf | 8731 | 4762272 |
JDZhengHT-Bold.ttf | 122 | 18328 |
夸克平台字体预览须要知足两种方式,一种是固定字符预览, 另外一种是根据用户输入的字符进行预览。但不管哪一种预览方式,也仅仅会使用到该字体的少许字符,所以全量加载字体是没有必要的,因此咱们须要对字体文件作精简。promise
unicode-range 属性通常配合 @font-face
规则使用,它用于控制特定字符使用特定字体。可是它并不能减少字体文件的大小,感兴趣的读者能够试试。
fontmin
是一个纯 JavaScript
实现的字体子集化方案。前文谈到,中文字体体积相较于英文字体更大的缘由是其字形数量更多,那么精简一个字体文件的思路就是将无用的字形移除:
// 伪代码 const text = '字体预览' const unicodes = text.split('').map(str => str.charCodeAt(0)) const font = loadFont(fontPath) font.glyf = font.glyf.map(g => { // 根据unicodes获取对应的字形 })
实际上的精简并无这么简单,由于一个字体文件由许多表(table)
构成,这些表之间是存在关联的,例如maxp
表记录了字形数量,loca
表中存储了字形位置的偏移量。同时字体文件以offset table(偏移表)
开头,offset table
记录了字体全部表的信息,所以若是咱们更改了glyf
表,就要同时去更新其余表。
在讨论 fontmin
如何进行字体截取以前,咱们先来了解一下字体文件的结构:
上面的结构限于字体文件只包含一种字体,且字形轮廓是基于 TrueType
格式(决定 sfntVersion
的取值)的状况,所以偏移表会从字体文件的0字节
开始。若是字体文件包含多个字体,则每种字体的偏移表会在 TTCHeader 中指定,这种文件不在文章的讨论范围内。
偏移表(offset table):
Type | Name | Description |
---|---|---|
uint32 | sfntVersion | 0x00010000 |
uint16 | numTables | Number of tables |
uint16 | searchRange | (Maximum power of 2 <= numTables) x 16. |
uint16 | entrySelector | Log2(maximum power of 2 <= numTables). |
uint16 | rangeShift | NumTables x 16-searchRange. |
表记录(table record):
Type | Name | Description |
---|---|---|
uint32 | tableTag | Table identifier |
uint32 | checkSum | CheckSum for this table |
uint32 | offset | Offset from beginning of TrueType font file |
uint32 | length | Length of this table |
对于一个字体文件,不管其字形轮廓是 TrueType 格式仍是基于 PostScript 语言的 CFF 格式,其必须包含的表有 cmap
、head
、hhea
、htmx
、maxp
、name
、OS/2
、post
。若是其字形轮廓是 TrueType 格式,还有cvt
、fpgm
、glyf
、loca
、prep
、gasp
六张表会被用到。这六张表除了 glyf
和 loca
必选外,其它四个为可选表。
fontmin
内部使用了 fonteditor-core
,核心的字体处理交给这个依赖完成,fonteditor-core
的主要流程以下:
将字体文件转为 ArrayBuffer
用于后续读取数据。
前文咱们说到紧跟在 offset table(偏移表)
以后的结构就是 table record(表记录)
,而多个 table record
叫作 Table Directory
。fonteditor-core
会先读取原字体的 Table Directory
,由上文表记录的结构咱们知道,每个 table record
有四个字段,每一个字段占4个字节,所以能够很方便的利用 DataView
进行读取,最终获得一个字体文件的全部表信息以下:
在这一步会根据 Table Directory
记录的偏移和长度信息读取表数据。对于精简字体来讲,glyf
表的内容是最重要的,可是 glyf
的 table record
仅仅告诉了咱们 glyf
表的长度以及 glyf
表相对于整个字体文件的偏移量,那么咱们如何得知 glyf
表中字形的数量、位置以及大小信息呢?这须要借助字体中的 maxp
表和 loca(glyphs location)
表,maxp
表的 numGlyphs
字段值指定了字形数量,而 loca
表记录了字体中全部字形相对于 glyf
表的偏移量,它的结构以下:
Glyph Index | Offset | Glyph Length |
---|---|---|
0 | 0 | 100 |
1 | 100 | 150 |
2 | 250 | 0 |
... | ... | ... |
n-1 | 1170 | 120 |
extra | 1290 | 0 |
根据规范,索引0
指向缺失字符(missing character)
,也就是字体中找不到某个字符时出现的字符,这个字符一般用空白框或者空格表示,当这个缺失字符不存在轮廓时,根据 loca
表的定义能够获得 loca[n] = loca[n+1]
。咱们能够发现上文表格中多出了 extra
一项,这是为了计算最后一个字形 loca[n-1]
的长度。
上述表格中 Offset 字段值的单位是字节,可是具体的字节数取决于字体head
表的indexToLocFormat
字段取值,当此值为0
时,Offset 100 等于 200 个字节,当此值为1
时,Offset 100 等于 100 个字节,这两种不一样的状况对应于字体中的Short version
和Long version
。
可是仅仅知道全部字形的偏移量还不够,咱们没办法认出哪一个字形才是咱们须要的。假设我须要字体预览
这四个字形,而字体文件有一万个字形,同时咱们经过 loca
表得知了全部字形的偏移量,但这一万里面哪四个数据块表明了字体预览
四个字符呢?所以咱们还须要借助 cmap
表来肯定具体的字形位置,cmap
表里记录了字符代码(unicode)
到字形索引的映射,咱们拿到对应的字形索引后,就能够根据索引得到该字形在 glyf
表中的偏移量。
而一个字形的数据结构以 Glyph Headers
开头:
Type | Name | Description |
---|---|---|
int16 | numberOfContours | the number of contours |
int16 | xMin | Minimum x for coordinate data |
int16 | yMin | Maximum y for coordinate data |
int16 | xMax | Minimum x for coordinate data |
int16 | yMax | Maximum x for coordinate data |
numberOfContours
字段指定了这个字形的轮廓数量,紧跟在 Glyph Headers
后面的数据结构为 Glyph Table
。
在字体的定义中,轮廓是由一个个位置点构成的,而且每一个位置点具备编号,这些编号从0
开始按升序排列。所以咱们读取指定的字形就是读取 Glyph Headers
中的各项值以及轮廓的位置点坐标。
在 Glyph Table
中,存放了每一个轮廓的最后一个位置点编号构成的数组,从这个数组中就能够求得这个字形一共存在几个位置点。例如这个数组的值为[3, 6, 9, 15]
,能够得知第四个轮廓上最后一个位置点的编号是15,那么这个字形一共有16个位置点,因此咱们只须要以16
为循环次数进行遍历访问 ArrayBuffer 就能够获得每一个位置点的坐标信息,从而提取出了咱们想要的字形,这也就是 fontmin
在截取字形时的原理。
另外,在提取坐标信息时,除了第一个位置点,其余位置点的坐标值并非绝对值,例如第一个点的坐标为[100, 100]
,第二个读取到的值为[200, 200]
,那么该点位置坐标并非[200, 200]
,而是基于第一个点的坐标进行增量,所以第二点的实际坐标为[300, 300]
由于一个字体涉及的表实在太多,而且每一个表的数据结构也不同。这里没法一一列举
fonteditor-core
是如何处理每一个表的。
在使用了 TrueType 轮廓的字体中,每一个字形都提供了 xMin
、xMax
、yMin
和 yMax
的值,这四个值也就是下图的Bounding Box
。除了这四个值,还须要 advanceWidth
和 leftSideBearing
两个字段,这两个字段并不在 glyf
表中,所以在截取字形信息的时候没法获取。在这个步骤,fonteditor-core
会读取字体的 hmtx
表获取这两个字段。
在这一步会从新计算字体文件的大小,而且更新偏移表(Offset table)
和表记录(Table record)
有关的值, 而后依次将偏移表
、表记录
、表数据
写入文件中。有一点须要注意的是,在写入表记录
时,必须按照表名排序进行写入。例若有四张表分别是 prep
、hmtx
、glyf
、head
、则写入的顺序应为 glyf -> head -> hmtx -> prep
,而表数据
没有这个要求。
fonteditor-core
在截取字体的过程当中只会对前文提到的十四张表进行处理,其他表丢弃。每一个字体一般还会包含 vhea
和 vmtx
两张表,它们用于控制字体在垂直布局时的间距等信息,若是用 fontmin
进行字体截取后,会丢失这部分信息,能够在文本垂直显示时看出差别(右边为截取后):
在了解了 fontmin
的原理后,咱们就能够愉快的使用它啦。服务器接受到客户端发来的请求后,经过 fontmin
截取字体,fontmin
会返回截取后的字体文件对应的 Buffer,别忘了 @font-face
规则中字体路径是支持 base64
格式的,所以咱们只须要将 Buffer 转为 base64
格式嵌入在 @font-face
中返回给客户端,而后客户端将该 @font-face
以 CSS 形式插入 <head></head>
标签中便可。
对于固定的预览内容,咱们也能够先生成字体文件保存在 CDN 上,可是这个方式的缺点在于若是 CDN 不稳定就会形成字体加载失败。若是用上面的方法,每个截取后的字体以 base64
字符串形式存在,则能够在服务端作一个缓存,就没有这个问题。利用 fontmin
生成字体子集代码以下:
const Fontmin = require('fontmin') const Promise = require('bluebird') async function extractFontData (fontPath) { const fontmin = new Fontmin() .src('./font/senty.ttf') .use(Fontmin.glyph({ text: '字体预览' })) .use(Fontmin.ttf2woff2()) .dest('./dist') await Promise.promisify(fontmin.run, { context: fontmin })() } extractFontData()
对于固定预览内容咱们能够预先生成好分割后的字体,对于用户输入的动态预览内容,咱们固然也能够按照这个流程:
获取输入 -> 截取字形 -> 上传 CDN -> 生成 @font-face -> 插入页面
按照这个流程来客户端须要请求两次才能获取字体资源(别忘了在 @font-face
插入页面后才会去真正请求字体),而且截取字形
和上传 CDN
这两步时间消耗也比较长,有没有更好的办法呢?咱们知道字形的轮廓是由一系列位置点肯定的,所以咱们能够获取 glyf
表中的位置点坐标,经过 SVG
图像将特定字形直接绘制出来。
SVG
是一种强大的图像格式,可使用CSS
和JavaScript
与它们进行交互,在这里主要应用了path
元素
获取位置信息以及生成 path
标签咱们能够借助 opentype.js
完成,客户端获得输入字形的 path
元素后,只须要遍历生成 SVG
标签便可。
下面附上字体截取后文件大小和加载速度对比表格。能够看出,相较于全量加载,对字体进行截取后加载速度快了145
倍。
fontmin
是支持生成woff2
文件的,可是官方文档并无更新,最开始我使用的woff
文件,可是woff2
格式文件体积更小而且浏览器支持不错
字体名称 | 大小 | 时间 |
---|---|---|
HanyiSentyWoodcut.ttf | 48.2MB | 17.41s |
HanyiSentyWoodcut.woff | 21.7KB | 0.19s |
HanyiSentyWoodcut.woff2 | 12.2KB | 0.12s |
这是在实现预览功能过程当中的第二个问题。
在浏览器的字体显示行为中存在阻塞期
和交换期
两个概念,以 Chrome
为例,在字体加载完成前,会有一段时间显示空白,这段时间被称为阻塞期
。若是在阻塞期
内仍然没有加载完成,就会先显示后备字体,进入交换期
,等待字体加载完成后替换。这就会致使页面字体出现闪烁,与我想要的效果不符。而 font-display
属性控制浏览器的这个行为,是否能够更换 font-display
属性的取值来达到咱们的目的呢?
Block Period | Swap Period | |
---|---|---|
block | Short | Infinite |
swap | None | Infinite |
fallback | Extremely Short | Short |
optional | Extremely Short | None |
字体的显示策略和 font-display
的取值有关,浏览器默认的 font-display
值为 auto
,它的行为和取值 block
较为接近。
第一种策略是FOIT(Flash of Invisible Text)
,FOIT
是浏览器在加载字体的时候的默认表现形式,其规则如前文所说。第二种策略是
FOUT(Flash of Unstyled Text)
,FOUT
会指示浏览器使用后备字体直至自定义字体加载完成,对应的取值为swap
。
两种不一样策略的应用:Google Fonts FOIT 汉仪字库 FOUT
在夸克项目中,我但愿的效果是字体加载完成前不展现预览内容,FOIT
策略最为接近。可是 FOIT
文本内容不可见的最长时间大约是3s
, 若是用户网络情况不太好,那么3s
事后仍是会先显示后备字体,致使页面字体闪烁,所以 font-display
属性不知足要求。
查阅资料得知,CSS Font Loading API在 JavaScript
层面上也提供了解决方案:
先看看它们的兼容性:
又是 IE,IE 没有用户不用管
咱们能够经过 FontFace
构造函数构造出一个 FontFace
对象:
const fontFace = new FontFace(family, source, descriptors)
family
CSS
属性 font-family
的值,source
url
或者 ArrayBuffer
descriptors optional
font-style
font-weight
font-stretch
font-display
(这个值能够设置,但不会生效) @font-face
规则的 unicode-ranges
font-variant
font-feature-settings
构造出一个 fontFace
后并不会加载字体,必须执行 fontFace
的 load
方法。load
方法返回一个 promise
,promise
的 resolve
值就是加载成功后的字体。可是仅仅加载成功还不会使这个字体生效,还须要将返回的 fontFace
添加到 fontFaceSet
。
使用方法以下:
/** * @param {string} path 字体文件路径 */ async function loadFont(path) { const fontFaceSet = document.fonts const fontFace = await new FontFace('fontFamily', `url('${path}') format('woff2')`).load() fontFaceSet.add(fontFace) }
所以,在客户端咱们能够先设置文字内容的 CSS 为 opacity: 0
,
等待 await loadFont(path)
执行完毕后,再将 CSS 设置为 opacity: 1
, 这样就能够控制在自定义字体加载未完成前不显示内容。
本文介绍了在开发字体预览功能时遇到的问题和解决方案,限于 OpenType
规范条目不少,在介绍 fontmin
原理部分,仅描述了对 glyf
表的处理,对此感兴趣的读者可进一步学习。
本次工做的回顾和总结过程当中,也在思考更好的实现,若是你有建议欢迎和我交流。同时文章的内容是我我的的理解,存在错误难以免,若是发现错误欢迎指正。
感谢阅读!