前端字体截取

2,569 阅读17分钟

为什么要截取字体?

众所周知,相对于英文字体,中文字体天生是“庞然大物”。英文字体两三百KB已经很大了,而中文字体几MB十几MB都算小的。一方面,中文字体包含的字形数量极多,动辄数以千计甚至万计,而英文字体则只需包含几十个基本字符和符号,哪怕支持多种语言及字符变体,容量达到三千多个字形已经算非常庞大的了。另一方面,中文字形的曲折变化复杂度高,在基于轮廓的矢量字体设计中,用于控制中文字形曲线的控制点普遍比英文更多,因而需要的数据量更大,也会导致字体文件膨胀。

前端开发实践中,为了实现一些特殊视觉效果,经常需要使用某些特殊字体,而用户电脑上几乎不太可能安装这些字体,这时候通常需要使用Web字体技术,让浏览器动态下载我们的自定义字体。可是中文字体非常庞大,很多时候“全量”加载某个字体文件是不现实的。特别是对于一些动态页面且每个页面只有少量字符用到该字体的情况下。当然,也不是每个页面都会用到一个字体文件中的所有字符,全量加载本身也极其浪费。

研究表明,3500常用中文汉字(中国义务教育9年级需要掌握的汉字数量)即可覆盖日常使用汉字的99.8%:

  • 500 字(78.53202%)
  • 1000字(91.91527%)
  • 1500字(96.47563%)
  • 2000字(98.38765%)
  • 2500字(99.24388%)
  • 3000字(99.63322%)
  • 3500字(99.82015%)

可见,最常用的前500个汉字的覆盖率已经达到78%。因此,“全量”加载某个字体,特别是中文字体,在当前网络环境下不仅浪费流量和时间,而且也是完全没有必要的。这时候,我们可以根据网页用到的字符来截取字体的片段,这个技术英文叫subset,也就是“取子集”。

本文首先简单回顾Web自定义字体的技术规范,然后通过实例介绍两种前端常用的截取字体的技术。首先是CSS中的unicode-range属性,我们称之为“软截取技术”,因为它只是在本地既有字体或者浏览器已经下载的字体基础上做一个指向子集的“软链接”,并不能真正减小浏览器下载文件的大小。其次是Node命令行工具glyphhanger,我们称之为“硬截取技术”,即在服务端从“全量”字体中分离出一个体积相对极小的字体子集,做成Web字体通过Web服务器或CDN下发给浏览器。

无论是“软截取”,还是“硬截取”,都会用到Web字体和@font-face规则。因此,我们需要先来了解一下这个基础的Web标准语法。

Web字体与@font-face

为了超越“Web安全字体”的局限,在网页上使用一些用户电脑上不太可能会安装的字体,微软曾率先提出了@font-face规则。这个规则后来进入W3C的CSS Fonts Module Level 3模块,于是就有了前端常用的Web自定义字体技术:

@font-face {
  font-family: 'MyWebFont';
  src: url('webfont.eot'); /* 兼容IE9 */
  src: url('webfont.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */
       url('webfont.woff2') format('woff2'), /* 最新浏览器 */
       url('webfont.woff') format('woff'), /* 较新浏览器 */
       url('webfont.ttf')  format('truetype'), /* Safari、Android、iOS */
       url('webfont.svg#svgFontName') format('svg'); /* 早期iOS */
}

示例代码出处:https://css-tricks.com/snippets/css/using-font-face/

当然,上面的代码是几乎可以兼容所有浏览器的方案。大约在两年前,也就是2016年,由于浏览器版本的快速更迭,写成下面这样已经是比较现实的了:

@font-face {
  font-family: 'MyWebFont';
  src:  url('myfont.woff2') format('woff2'),
        url('myfont.woff') format('woff');
}

如果要兼容更多浏览器,那再加上一种几乎所有浏览器都支持的ttf格式则似乎更稳妥:

@font-face {
  font-family: 'MyWebFont';
  src: url('myfont.woff2') format('woff2'),
       url('myfont.woff') format('woff'),
       url('myfont.ttf') format('truetype');
}

不过,我们的最终目标还是写成这样,即只使用woff2这种自带压缩的格式:

@font-face {
  font-family: 'MyWebFont';
  src: url('myfont.woff2') format('woff2');
}

从技术角度讲,除了直接使用@font-face,还可以使用@import规则或link元素导入或加载包含@font-face声明的外部文件:

// 导入
@import url(//fonts.googleapis.com/css?family=Open+Sans);
// 或者引用
<link href='//fonts.googleapis.com/css?family=Open+Sans' rel='stylesheet' type='text/css'>
// 实际使用
body {
  font-family: 'Open Sans', sans-serif;
}

打开Google Fonts看一看:https://fonts.googleapis.com/css?family=Open+Sans

以上都是技术规范,至于什么时候可以过渡到只使用专门针对Web字体优化的压缩格式woff2,应该只是一个时间问题。

回顾完了基础的技术规范和语法,明确了未来的方向,接下来我们进入实战。先看一下CSS Fonts Module Level 3定义的与@font-face规则配合使用的unicode-range属性,然后再给大家介绍一家有名的国外Web开发公司Filament Group, Inc.推出的字体截取工具glyphhanger

unicode-range

unicode-range属性虽然可以算作“字体截取”技术,但它是“软截取”,不是“硬截取”。它类似于一种快捷方式,而不能真正减少浏览器需要下载的字体文件大小。

顾名思义,unicode-range用于指定自定义字体中包含的字符的Unicode码点范围,语法如下:

// CSS
@font-face {
  font-family: 'Ampersand';
  src: local('Times New Roman');
  unicode-range: U+26;
}
div {
  font-size: 4em;
  font-family: Ampersand, Helvetica, sans-serif;	
}
// HTML
<div>Me & You = Us</div>

示例代码出处:https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/unicode-range

以上@font-face规则自定义了一个名为“Ampersand”(英文&符号)的字体,这个字体“截取”自本地字体Times New Roman,而这个字体只包含一个字符:U+2626是英文&符号的十六进制Unicode码点,对应的十进制值是38)。

HTML中div元素根据font-family的指令,依次会应用自定义字体Ampersand(Times New Roman,衬线字体)、Helvetica(无衬线字体)和sans-serif(无衬线)字体族。实际应用效果如下:

Unicode编码扩容到了17个编码平面,每个平面的容量为65,536,总容量为1,114,112个码点,其中实际分配使用的只有128,237个,约占12%。因此在可以预见的未来,Unicode有足够的空间包含地球上所有文明的字符。

看一个中文字体的例子。假设我们要用特殊字体突出显示“初唐四杰”之一王勃的千古名篇《滕王阁序》中最有名的那句:“落霞与孤鹜齐飞,秋水共长天一色。”

可以先把这句名句(包括标点)转换成Unicode码点:

字符串转码点可以使用以下JavaScript函数:

function text2point(t) {
   return t.split('').map(c => 'u+'+c.charCodeAt().toString(16)).join(',')
}

然后以“隶变”(Libian SC)作为源字体,自定义一个名叫custom的字体,把它应用到.emphasis元素:

// CSS
@font-face {
  font-family: custom;
  src: local(Libian SC);
  unicode-range: u+843d,u+971e,u+4e0e,u+5b64,u+9e5c,u+9f50,u+98de,u+ff0c,
      			u+79cb,u+6c34,u+5171,u+957f,u+5929,u+4e00,u+8272,u+3002;
  font-weight: 500;
}
.emphasis {
  font-family: custom;
}
// HTML
<!--其他句子-->
<span class="emphasis">落霞与孤鹜齐飞,秋水共长天一色。</span>
<!--其他句子-->

注意,上面代码中的码点列表为排版阅读方便而人为换了行,实际使用中不要人为换行,以免造成语法错误。下面的代码示例也一样。

结果如下:

此时,我们发现标点(逗号和句号)的样式与其他文字不统一,而其他文字使用的是“苹方”(PingFang SC)字体(在Mac上)。是不是可以简单地从前面的码点列表中删除逗号和句号的码点u+ff0cu+3002?这个方案在Safari 12、Firefox 62中可行,删除码点之后的逗号和句号会继承使用“苹方”字体,但是在Chrome 69中并不奏效。

此外,Chrome似乎还有一个bug。假设不删除上述码点,而直接在标点左侧输入一个自定义字体中并不包含的字符,Chrome会强制把这个字符显示成自定义字体。看来浏览器的实现还是有不一致的地方。时间关系,Windows平台下的IE和Edge没有测试,读者可以自行测试一下。

无论如何,我们可以再定义一个只包含逗号和句号两个字符的自定义字体来解决这个问题:

@font-face {
  font-family: punc;
  src: local(PingFang SC);
  unicode-range: u+ff0c,u+3002;
}
.emphasis {
  font-family:punc, custom;
}

这样,即使不删除custom声明中的码点,Chrome、Safari和Firefox也都可以将逗号和句号显示为“苹方”字体了:

注意,不要试图基于英文字体自定义punc字体,因为英文字体中不包含对中文标点符号对应码点的映射。

虽然这个例子明显是自造的,“对中文内容中的某部分中文字符做特殊字体处理,或者是英文字体中部分字符做特殊字体处理”正是unicode-range这种“软截取技术”最适合的应用场景。

使用unicode-range的注意事项:

  • unicode-range可以接收
    • 单个码点:U+26(或u+26)
    • 码点范围:U+0-7FU+0025-00FF
    • 通配符范围:U+4??,相当于U+400-U+4FF
    • 逗号分隔的多个值:U+0025-00FF, U+4??
  • unicode-range默认值为:U+0-10FFFF,即全部Unicode字符编码
  • unicode-range的值是码点的字面值或字面值列表,不是字符串
    • 正确:unicode-range: u+ff0c,u+3002;
    • 错误:unicode-range: "u+ff0c,u+3002";
  • unicode-range的值不能有语法错误,比如上面说的不是字符串,以及不能出现多余的逗号:u+ff0c,u+3002,;(末尾多了一个逗号)等,出现语法错误的后果是自定义字体会变成源字体的别名,而非基于源字体截取的子集。(当然,通过@font-face定义已有字体全集的别名,也是一种实用的CSS技术,可以参考前面张老师的文章。)
  • 转换为码点时确保使用正确的字符,比如前面例子中的“鹜”(u+9e5c)不要错误地使用“骛”(u+9a9b)。

关于unicode-range这种“软截取技术”的使用就介绍这些。接下来我们介绍“硬截取工具”:glyphhanger。

glyphhanger

glyphhanger是Zach Leatherman(https://www.zachleat.com/web/)Filament Group(https://www.filamentgroup.com)写的一个.ttf转WOFF/WOFF2等Web字体格式的命令行工具,可以:

  • 抓取远程或本地文件并分析其中包含的文字
  • 将分析结果去重排序并转换为Unicode码点
  • 根据指定的源字体生成对应格式的子集(需要安装另一个工具,稍后介绍)
  • 同时也生成包含@font-face规则的CSS文件

这个工具非常实用方便,下面我们就来演示在制作Web字体过程中glyphhanger的几个典型用法。

首先,全局安装:

npm install -g glyphhanger

用法一:把王勃的千古名句改成“思源宋体”

进入包含例子页面的目录fontSubsetInAction,运行如下命令:

➜ fontSubsetInAction glyphhanger http://127.0.0.1:8080/index.html --family='custom' --subset=SourceHanSerifCN-Light.ttf --formats=woff2

U+3002,U+4E00,U+4E0E,U+5171,U+5929,U+5B64,U+6C34,U+79CB,U+8272,U+843D,U+957F,U+971E,U+98DE,U+9E5C,U+9F50,U+FF0C
Writing CSS file: SourceHanSerifCN-Light.css
Subsetting SourceHanSerifCN-Light.ttf to SourceHanSerifCN-Light-subset.woff2 (was 12.44 MB, now 3.57 KB)

这里给glyphhanger传入了4个参数。

  • 要分析的远程文件(这里是一个本地Web服务):http://127.0.0.1:8080/index.html
  • --family='custom'指定只分析以上页面中应用了font-family: custom;规则的元素
  • --subset=SourceHanSerifCN-Light.ttf指定使用的源字体,这里为“思源宋体”(Source Han Serif)
  • --formats=woff2指定想要生成的字体子集的目标格式,这里是WOFF2

glyphhanger首先输出了“落霞与孤鹜齐飞,秋水共长天一色。”对应的Unicode码点(包含逗号和句号)。紧接着在当前目录创建了一个名为“SourceHanSerifCN-Light.css”的文件。之后的输出显示,截取的字体叫“SourceHanSerifCN-Light-subset.woff2”,且源字体文件有12.44 MB,子集文件3.57 KB。16个汉字字符就用了3.57 KB,平均每个字符占228字节,吓人吧?!

不过,比起12.44 MB,3.57 KB已经算极小了。下面,看看glyphhanger帮我们生成的CSS文件:

/* This file was automatically generated by GlyphHanger 3.0.3 */
@font-face {
  font-family: custom;
  src: url(SourceHanSerifCN-Light-subset.woff2) format("woff2");
  unicode-range: U+3002,U+4E00,U+4E0E,U+5171,U+5929,U+5B64,U+6C34,U+79CB,
      			U+8272,U+843D,U+957F,U+971E,U+98DE,U+9E5C,U+9F50,U+FF0C;
}

直接使用即可,相比之前手工生成码点,这样省事多了。结果如下:

用法二:分析网页用到汉字子集

可能有读者没有注意到,上面例子中glyphhanger输出的码点是按照每个字符在Unicode编码中的顺序从小到大排序过的。而且,这些码点是在自动去重之后排的序。

“落霞与孤鹜齐飞,秋水共长天一色。”没有重复的字,我们再看下面这个例子:

➜  fontSubsetInAction glyphhanger https://lisongfeng.cn/post/dive-into-async-function.html --string

 "#$&'()*+,-./0123456789:;<=>?ACDEFGHIJKLMNOPQRSTUVWXY[]`abcdefghijklmnopqrstuvwxy{|} ©«»—“”…、。一丁三上下不与且两个中串为主么义之乎乘也书了事二于互些交人什仅今介从代以们件任会伟传似但位低住体何作你使例供依便信修倍候值假做停催像儿充先兑入全兮关其典内册再写决况准出函分切列则刚创初利别到制刻前力办功加务动助努包化区升半协单占即却原去参又及反发取受变口句另只叫可各合同名后向吗吧含启呀员呢周味命和品哈哉响哎哥哦啊啥啦喽嘛器回因困围图在地坑块型基塞境增处备复外多大夫失头奇套好如始姐媒媲子字存它完定实家容对导封将小少尔尝就尾层屈展属山崩嵌工己已布带帮常干年并序库应底度建开异式引张强当彻往征待很得循微心必快念态怎思急性总恢息悉悖悲情想意感成我或户所手才打执扩扰找承把抛抢护抽拒括拼拿持按挠捕换据探接控推描提摸操擎支收改效救数文料断新方无既早时明易是显普景智暂更替最月有未末本机杂束条来构析果某查标样核根格案梦检概模次止正此步段每比毕毫永求没法注洞活流浏消涩深添溃满漏演漫点烦烫然照熟版特状独狱环现理甚甜生用由界疼疾白的目直相省看真眼着知码础确示神种秒积称程稍穷立竟端笔符第笼等答简算管箭类糖系索级纯线组细终绍经结给绝绞统继续维综编网置美翻考者而聪肉背能脚自至致节芋苦荒获虽蛮行补表被装要见规览解触计认让议记讲论设访证评译试话该详语误说请诺读调谋象负责败质费资赋越足踩身转载较辑达迅过迈运返还这进远迭述退送适逆逐递通速造逻遍道那部都释里重量鉴针链错键长问闲间队阻际限除随隐集需非靠面页顺须题风饰饱首香驾验高麻默!(),:;?~

另外,这里也使用另一个参数--string让glyphhanger把Unicode码点转换为字符串输出,是按照码点从小到大排序的。

字符串去重其实很简单,下面这个简单的JavaScript函数就可以搞定:

function textEliminateDuplicationAndSorting(text) {
    return text.split('').filter((value, index, self) => { 
      return self.indexOf(value) === index;
    }).sort().join('')
}

用法三:指定文本或码点生成字体子集

当然,如果你有现成的文本或码点,也可以只让glyphhanger帮你生成相应源字体的子集和CSS文件。比如我想把“渔舟唱晚,响穷彭蠡之滨,雁阵惊寒,声断衡阳之浦。”也显示为“思源宋体”:

➜  fontSubsetInAction glyphhanger --whitelist="落霞与孤鹜齐飞,秋水共长天一色。渔舟唱晚,响穷彭蠡之滨,雁阵惊寒,声断衡阳之浦。" --subset=SourceHanSerifCN-Light.ttf --css

U+3002,U+4E00,U+4E0E,U+4E4B,U+5171,U+54CD,U+5531,U+58F0,U+5929,U+5B64,U+5BD2,U+5F6D,U+60CA,U+65AD,U+665A,U+6C34,U+6D66,U+6E14,U+6EE8,U+79CB,U+7A77,U+821F,U+8272,U+843D,U+8821,U+8861,U+957F,U+9633,U+9635,U+96C1,U+971E,U+98DE,U+9E5C,U+9F50,U+FF0C
Subsetting SourceHanSerifCN-Light.ttf to SourceHanSerifCN-Light-subset.ttf (was 12.44 MB, now 13.02 KB)
Subsetting SourceHanSerifCN-Light.ttf to SourceHanSerifCN-Light-subset.zopfli.woff (was 12.44 MB, now 9.13 KB)
Subsetting SourceHanSerifCN-Light.ttf to SourceHanSerifCN-Light-subset.woff2 (was 12.44 MB, now 7.45 KB)
Writing CSS file: SourceHanSerifCN-Light.css

@font-face {
  src: url(SourceHanSerifCN-Light-subset.woff2) format("woff2"), 
  		url(SourceHanSerifCN-Light-subset.zopfli.woff) format("woff"), 
  		url(SourceHanSerifCN-Light-subset.ttf) format("truetype");
  unicode-range: U+3002,U+4E00,U+4E0E,U+4E4B,U+5171,U+54CD,U+5531,U+58F0,U+5929,
  			U+5B64,U+5BD2,U+5F6D,U+60CA,U+65AD,U+665A,U+6C34,U+6D66,U+6E14,
			U+6EE8,U+79CB,U+7A77,U+821F,U+8272,U+843D,U+8821,U+8861,U+957F,
			U+9633,U+9635,U+96C1,U+971E,U+98DE,U+9E5C,U+9F50,U+FF0C;
}

这一次使用--whitelist参数传入了要截取的汉字,省略了--formmats,增加了--css参数。

从结果可以看到,glyphhanger还是对文字进行了去重、转码点和排序。而且,在没有指定--formats的情况下,生成了.ttfwoffwoff2三种格式的字体子集,这是为了提高对浏览器的兼容性。最后,除了例行生成CSS文件,--css选项还让glyphhanger把CSS文件的内容输出到了控制台,便于复制。

但是要注意,CSS文件和输出都没有包含font-family属性,也就是没有自定义字体的名字(custom),使用时必须自己手工加上。好,结果如下:

安装pyftsubset

glyphhanger本身只做了网页抓取和分析,实际的字体截取使用的是一个著名的Python包fonttoolsgithub.com/fonttools/f…。安装方法如下:

pip install fonttools

# Additional installation for --flavor=woff2
git clone https://github.com/google/brotli
cd brotli
python setup.py install

# Additional installation for --flavor=woff --with-zopfli
git clone https://github.com/anthrotype/py-zopfli
cd py-zopfli
git submodule update --init --recursive
python setup.py install

文章最后,为了便于大家参考,我们给出glyphhanger的帮助信息,大家可以自己去探索更多好玩的用法:

➜  fontSubsetInAction glyphhanger -h
glyphhanger error: requires at least one URL or whitelist.

usage: glyphhanger ./test.html
       glyphhanger http://example.com
       glyphhanger https://google.com https://www.filamentgroup.com
       glyphhanger http://example.com --subset=*.ttf
       glyphhanger --whitelist=abcdef --subset=*.ttf

arguments:
  --version
  --whitelist=abcdef
       A list of whitelist characters (optionally also --US_ASCII).
  --string
       Output the actual characters instead of Unicode code point values.
  --family='Lato,monospace'
       Show only results matching one or more font-family names (comma separated, case insensitive).
  --json
       Show detailed JSON results (including per font-family glyphs for results).
  --css
       Output a @font-face block for the current data.
  --subset=*.ttf
       Automatically subsets one or more font files using fonttools `pyftsubset`.
  --formats=ttf,woff,woff2,woff-zopfli
       woff2 requires brotli, woff-zopfli requires zopfli, installation instructions: https://github.com/filamentgroup/glyphhanger#installing-pyftsubset

  --spider
       Gather local URLs from the main page and navigate those URLs.
  --spider-limit=10
       Maximum number of URLs gathered from the spider (default: 10, use 0 to ignore).
  --timeout
       Maximum navigation time for a single URL.