前端字体截取实战

为什么要截取字体?

众所周知,相对于英文字体,中文字体天生是“庞然大物”。英文字体两三百 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 */
}

当然,上面的代码是几乎可以兼容所有浏览器的方案。大约在两年前,也就是 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 码点范围,语法如下:

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

<div>Me & You = Us</div>

以上 @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 元素:

<style>
  @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;
  }
</style>

<!--其他句子-->
<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 的内容,推荐大家看一看张鑫旭老师的文章 “CSS unicode-range 特定字符使用 font-face 自定义字体”

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

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

当然,如果你有现成的文本或码点,也可以只让 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 的情况下,生成了 .ttf, woffwoff2 三种格式的字体子集,这是为了提高对浏览器的兼容性。最后,除了例行生成 CSS 文件,--css 选项还让 glyphhanger 把 CSS 文件的内容输出到了控制台,便于复制。

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


原文地址:前端字体截取:实战篇

上次更新: 9/23/2019, 11:14:30 AM
Copyright © 2019 by Wu WenJun