一种很新的中文字体网页嵌入方案
每页 20 KiB 以内、纯静态字形嵌入,不妨碍灵活修改与动态内容
太长不看版:直接跳至“原理”一节。
浩瀚深邃的汉字记载着中华文化的博大精深,也伤透了工程师们的脑筋。汉字字形的数字化存储与表达曾是一个世界性难题。王选前辈在汉字照排系统研究中费尽心思将汉字字形的大小压缩了百倍;至今不过半个世纪,我们已可以在众多的字形库中对比挑选,可是复杂多变的个性化字形设计又一次将数据量的问题摆在了人们面前。
背景
一份汉字字体库的大小一般为数 MiB 至数十 MiB 不等;因此在文档或网页中嵌入字体时,通常对完整字体库进行子集化(subsetting)操作,只保留文档或页面中出现的字。经过压缩(常用 gzip、WOFF2 等方案),一个字平均需要的空间约为上百字节,而汉语常用字约为二至三千(对于数千字的文章,很可能不超过一千),由此可以将字体带来的数据量控制在百 KiB 的合理范围内。
对于网站而言,一般采取两种方案之一。
其一是在取子集时取全站出现的所有字。这个方案易于实现与维护,然而它有三个问题:首先,对于内容量大的站点,使用的字可能覆盖大多数常用字(约三千),这仍然会在首次访问站点时产生 MiB 量级的数据量,延长加载时间;其次,许多站点上的内容常常不是固定而是时常更新的,一次更新若使用了新的字,则需增补子集,造成此前的字库缓存失效;同时,站点上的动态内容如评论等文字无法保证被覆盖到。
其二是将整个字库拆成若干子集,每一子集包含一系列文字,通过样式表中 @font-face 规则下的 unicode-range 属性指明,让浏览器根据页面的内容按需取用。这一方案被 Google Fonts 采用,其将汉字按照使用频率拆分为百余个子集,各自包含百余个汉字,数据量大约为数十 KiB(以思源宋体为例,平均约为 30–40 KiB)。这一方案尽管有一定冗余(一个子集中只要有一个字被使用,包含百余字的整个子集就需要被加载),但有效地减少了传输数据量,且可以依靠浏览器按照页面上的全部内容自动获取需要的字形子集。另外,此前已加载的子集都可以进入缓存,避免网络资源的浪费。
后一种方案是目前大部分网站的策略。但是它的冗余仍然可观,尤其在首次访问时体现。一个包含百余字的子集只有在每个字都未被使用时才不被加载,对于前两三千个常用字,这一概率很低,而对于非常用字,极端情况下一个字就会造成整个子集的加载。由此可以估算一个文字量中等的页面所需的子集数在 30 左右,首次访问的开销可能仍然接近 MiB 级,与理想情况还有一定差距。
基于子集化的思路,可以针对网站设计更优的字形嵌入方案。
方法
此方案面向静态内容为主,且具有一定文字量的网站。笔者在使用这一方法优化站点时,有 30 余个页面,平均包含约数百字。
原理
首先考虑静态文字内容。算法的核心思路是将全站所有出现的汉字分为“站点常用字”与“非常用字”,前者作为一个大子集,后者则为每个页面建立专门的小子集。每个页面包含“站点常用字”的子集信息,供浏览器按需获取,同时也指明一个专门的小子集,包含此页面上未被常用子集覆盖的字。
“站点常用字”的划定是一次性的,但随着内容的更迭,也可间或重新计算。具体规则可以结合实际,采取启发式方法——例如笔者采取的规则为“出现在至少三个页面的字”,这是考虑到站点有目录页,包含每一个子页面的标题与简介,其中的字都会在两个页面中出现,草率地将其归为常用字并不合理;而如果一个汉字在至少三个页面出现,则大致足以说明这个字在不同话题中都会使用。
在划定常用字集合后,首先将常用字提取为一个子集,加入所有页面共用的样式表;然后检查每个页面,找出其中未被覆盖的字,在字形库中取出子集形成文件,写入此页专门的样式表中。例如:
/* 共用样式表 */ @font-face { font-family: 'Noto Serif SC'; font-style: normal; font-weight: 400; font-display: swap; src: url(NotoSerifSC.common.woff2) format('woff2'); } /* 页面专用样式表 */ @font-face { font-family: 'Noto Serif SC — Page <Fireflies>'; font-style: normal; font-weight: 400; font-display: swap; src: url(NotoSerifSC.page-1.woff2) format('woff2'); } body { font-family: 'Noto Serif SC — Page <Fireflies>', 'Noto Serif SC'; }
首次访问优化
针对常用字集合,也可以继续进行一些优化,将其拆分为少量更小的子集。这是由于首页、目录页等文字量并不大的页面往往是首次访问的页面,希望这些页面的总数据量可以进一步缩减。
既然常用字集合划分时参考了每个页面上的具体内容,不妨继续利用之。设 为常用字集合, 为页面集合,页面 包含的汉字集合为 ,那么问题实际上就是求解 的这样一个划分 :
上式刻画了所有页面加载各自所需子集的开销之和,以字符数目计。其中 是约束项,刻画了页面加载一个子集需要的额外开销(如网络请求)。
这个问题是困难的,但是如果给这些常用字人为地任意固定一个顺序,将目标转化为序列分段的问题,则可以用动态规划模型描述之并加以解决。既然能找出固定排列下的最优解,就可以通过进化算法来寻找一个排列,从而获得相当优秀的解。
动态规划模型:记常用字的顺序为 。以 表示将 划分为若干个连续段,所有页面加载开销之和的最小值。状态转移式为
根据此式可以在 时间内计算最优解,但由于 往往达到千的量级,且这一过程会被进化算法大量调用,时间消耗仍然不可接受。实际上,由于进化算法有适应能力,这一子问题中并不需要找到严格的最优解,只要找到相对较优的解,进化算法就有能力针对次优算法表现出的特性来优化所寻找的排列。例如,在状态转移时只考虑后面求和值变化的位置,即“某个页面在第 个字 前最后一次出现的文字 ”,可以在 时间内获得一个不错的解。
上述动态规划过程也可以用线段树数据结构优化至 。常用的 1D/1D 优化似乎都不太管用,不知道有没有更好的办法 T-T
解决了这一序列上的问题,将其作为估值函数,通过遗传算法寻找一个常用字的排列 即可。对于排列之间的交配繁衍,可以采取 PMX(partially-mapped crossover,部分映射交叉)算子。
笔者在实现时,常用字包含约 1000 字,取 的值为 5;遗传算法的种群大小为 250,每一代产生的后代数目为 150,共计算 5000 代。
支持动态内容
在以上基础上,若需要覆盖页面上的动态内容,则可以将常用字以外的所有字拆分为子集,与“背景”一节中介绍的“方案二”一致。
维护
此方案要求每次修改内容后重新计算被修改页面的小子集。另外,如果修改了所有页面共用的元素(如标题、侧边栏等),则需要为所有页面重新计算。当修改积累到一定程度时,原本的常用字集合可能不能很好地适配站点内容,此时可考虑重构常用字集。原本的缓存在重构后会失效,每一位访客都需要重新下载常用字子集;但是考虑到其大小并不大(约数百 KiB 量级),且重新下载后仍可以保持长时间的缓存,带来的影响是有限的。
总结
借助此方法,笔者的站点将约 1000 个常用字拆分为五个子集,合计约 320 KiB;每个页面加载的小子集平均包含约十余个字符,绝大多数大小在 20 KiB 以内,最大未超过 50 KiB。访问主页时,中文字体消耗的网络流量约 220 KiB。站点采用的字形接近楷书,而若选用黑体、宋体等形态更规则的字体,传输数据量有望进一步缩减。
此方案的主要局限性在于要求单独的构建步骤,且可选的遗传算法步骤耗时较长。但是对于文字内容较多的网站如博客、百科等,这一方案或许能起到不错的效果。