制作字体选择器
制作字体选择器
很久以前,在构建 Pinecast 站点构建器时,我需要一个允许用户从 Google 字体中选择字体的下拉菜单。其他应用程序中的大多数字体选择器通过呈现自己的名称来显示每种字体。虽然像 Google Docs 和其他应用程序只显示少数可供选择的字体,但 Site Builder 需要显示超过一千种字体。这带来了一系列相当独特和复杂的挑战。
Google Fonts 上的字体以 WOFF2(或 WOFF,如果您的浏览器较旧,则为其他格式)提供。直接使用这些对于下拉菜单来说是一个糟糕的选择,因为打开下拉菜单会触发数千次从 Google 下载数百兆字节的字体文件。在实际显示字体时触发下载会导致滚动延迟并导致未渲染字体的闪烁。
相反,我决定预渲染每种字体。通过这样做,一个易于渲染的预览可以存储在我的 JavaScript 中并立即绘制到屏幕上。
我通过构建一个编译器开始了这个项目。此编译器从 Google Fonts API 下载完整的字体列表。它过滤掉要包含的不良字体(如条形码)并创建数据结构来存储元数据。每个字体的 TTF 文件保存到 /tmp
并传递给[ opentype.js](https://opentype.js.org/)
渲染为 SVG。每个 SVG 的路径(即矢量路径,而不是文件路径)都保存为字符串,以便稍后使用 React 进行渲染。
这需要大量的修补。每个 TTF 文件加载为 缓冲
到内存中,生成的 SVG 路径可以是几十 KB。如果同时完成,Node 很容易遇到内存不足的错误。我没有正确解决这个问题(例如,使用信号量),而是使用写入流将渲染字体路径的每一行提前转储到磁盘,允许垃圾收集器清理每个字体文件和路径呈现。
The font picker using the pre-rendered font paths
这产生了我想要的结果,但出现了新的挑战。生成的 TypeScript 文件是 巨大的 .在 gzip 之后,文件是兆字节大。即使我丢弃了所有占用超过 14KB 作为 SVG 路径的字体,生成的包也比以前大了 3 MB。
在这样一个复杂的应用程序中,可以为加载时间缓慢提出一个论据,并且可以使用加载指示器(如微调器或跳舞点)来减轻加载 JavaScript 包的延迟。然而,这并不令人满意,而且随着谷歌添加新字体,问题只会变得更糟。
代码拆分救援
一种快速止血的方法是从主 JavaScript 包中单独加载字体数据。有一些明显的方法。最天真的是将字体数据下载为 JSON 文件 拿来
.这使得在需要时加载字体变得很简单,但是涉及到很多样板文件并且它不能很好地与构建系统配合使用。
另一个答案是依靠 Webpack 为我们完成繁重的工作。这种技术称为代码拆分。
代码拆分是一种指示 Webpack 生成包含应用程序块的第二个(或第三个,或第四个……)JavaScript 包的方法。然后,您可以在初始包加载后加载该块。
我不会详细介绍如何使用 Webpack 进行代码拆分,因为那里有很多文章非常详细。总而言之,您在页面的其余部分之后加载庞大的 JavaScript 文件,让您的应用程序更快地加载,并且只有在加载 JavaScript 块之前渲染 UI 时才阻止在下载时渲染您的 UI。
一旦我的字体预览存储在他们自己的文件中,性能问题只会在您打开字体选择器时出现。许多用户不会打开字体选择器,因此问题基本上得到了缓解。也就是说,我们仍然不希望在首次打开字体选择器时加载字体需要 10 或 15 秒。
考虑压缩
尺寸仍然是个问题。包含字体路径数据的包本身就超过 3 兆字节。一定有更好的方法。
我怀疑有一种方法可以对 SVG 路径进行编码,从而消耗更少的空间。 SVG 路径是字母(“命令”)后跟零个或多个十进制数字(“参数”)。我推测二进制编码会比基于文本的编码更有效。很难说它是否会对餐巾纸背面的数学产生任何好处:
- 如果我们使用 ascii85 对二进制数据进行编码(因为二进制数据不能直接在 JavaScript 文件中编码),它将增加约 25% 的开销。
- 许多 SVG 路径命令有两个浮点数。对于 64 位浮点数,对于带有两个参数的每个命令来说,这是 16 个字节,尽管我们可以使用 32 位浮点数来节省 8 个字节。对像
0 0
存储为字符串时将远短于 8 个字节,但成对如-12.12 15.67
使用二进制编码节省几个字节。 - 每个路径命令的浮点参数越多,就越有可能节省位(因为在字符串编码中浪费位的机会更多)。命令的分布将在确定大小方面发挥重要作用。
让我们测试一下!
一个天真的通行证
第一个也是最简单的测试是只创建 SVG 路径的二进制编码。我用 解析SVG
模块将 SVG 路径解析为 JavaScript 数组 M1.8 0.2L0.2 -0.5
看起来像 [['M', 1.8, 0.2], ['L', 0.2, -0.5], ...]
,然后将其打包成一个 数组缓冲区
由三部分组成:
- 列出路径命令数量的 32 位 uint 标头
- 一个
Uint8Array
包含映射到路径中使用的每个字符的命令(SVG 路径中字母的 SVG 术语) - 一个
Float32Array
包含每个路径命令的每个参数
对于每种命令类型,都有固定数量的参数。 米
例如,总是有两个参数。基于 Uint8Array
,我们可以直观地从 Float32Array
.
这里有很多浪费。首先,8 位的命令是一个很大的浪费:SVG 规范定义的命令只有大约 20 条,这意味着每个命令浪费了 3 位。接下来,每个参数 32 位远比需要的多。路径命令 M0 0
只需要 4 个字节来编码为字符串,但我们要消耗 9 个字节来将其编码为二进制格式。
我写了一个小脚本来自动化我的尝试测试,天真的尝试产生了这个输出:
节省的字节数:-721494
比率:1.2097567163545102
Gzip比:1.4426273810518684
与原始文件的 gzip 压缩版本相比,我的天真尝试产生的输出大了 40%!
打包命令
我的下一个步骤是开始更紧密地打包数据。最明显的第一步是将命令打包成 5 位来表示 20 种可能性中的每一种。
我写了一个我称之为的实现[ 单位数组](https://gist.github.com/mattbasta/484f3ed8f006e7243a1434994e09afff)
这需要一个长度和每个 uint 的位数。它的行为就像一个 Uint8Array
,除了它将值截断为 N 位而不是 8 位。
使用它来打包命令而不是 Uint8Array
,我能够节省几个字节:
节省的字节数:-580885
比率:1.1688470197477028
Gzip比:1.4499228881612436
节省的总字节数得到了很大改善!与之前的尝试相比,我们节省了大约 150kb。不幸的是,压缩后的文件大小大大增加了。
发生这种情况是因为路径命令经常重复。给定的 SVG 路径可能包含命令 MLLLQLLQLLQLL
,在整个路径上重复该字符串的子集。当每个路径命令恰好消耗一个字节时,输出中就会出现重复。当二进制编码版本不是字节对齐时,输出中的重复次数要少得多。对于 gzip,这看起来像是随机噪声。通过更紧密地打包数据,我们降低了它的可压缩性!
我挑选了一下,发现 opentype.js
只使用四个不同的命令: 米
, 大号
, 问
, 和 Z
.这意味着我们可以仅将每个命令放入两位中,并且没有任何命令会跨越字节(因为 8 是 2 的倍数),因此 gzip 应该更适合整个过程。
将位数从 5 减少到 2 有点帮助,但不是很大:
节省的字节数:-440311
比率:1.1279486700544477
Gzip比率:1.443929762564997
新输出仅比原始输出大 12%,但我们在 gzip 压缩后的输出中仍然大 44%,尽管比以前更好。
打包参数
我在这里的树上留下了一大块低垂的果实:参数。每个参数四个字节远远超过需要。我首先考虑将其减半并制作 16 位浮点数,但这仍然非常浪费。浮点数的有趣之处在于它们从 -Infinity 变为 +Infinity,其中 50% 的可精确表示的数字在 -1 和 +1 之间。这是一种浪费,因为我们可以安全地将数字的十进制值截断为少量有效数字(大多数人将其 SVG 截断为 1-3 位小数精度)。我们绝对不需要像浮点数提供的那样多的小数位精度:如果我们截断到两位小数并且所有浮点数的 50% 在 -1 和 1 之间,这意味着我们几乎浪费了一半我们的 32 位存储大约 20 个可能的值:-0.9, -0.8, ... 0.8, 0.9。
浮点数在这里是一个不好的表示。一个简单的改变是使用整数并只除以 10。对于 8 位有符号整数,这将允许 -12.7 到 12.7。可悲的是,这还远远不够,实际参数值有数百个。
这些数字的一个有趣特性是,有几个值在所有 SVG 中出现了数千次,而大约一千个数字的使用次数少于十次。三百个值只使用一次。
我计算了所有字体中每个数值的出现次数。我将这些数据以 CSV 格式导入电子表格并绘制出来。这是按路径命令类型完成的,我将在稍后讨论。
These are frequencies for the “M” path command type. The chart is truncated to show the top 10% of numeric values.
您可以在此处看到,零是 M 路径命令类型中最常用的数字,其次是 0.2、0.3 和 0.1。在这种情况下,零出现了 660 次。 Q 和 L 每个都有更引人注目的数字,有数千个 0 和 0.2 的实例。
这里的一种解决方案是使用 霍夫曼编码 .霍夫曼编码是一个过程,通过该过程,序列中最常见的符号被赋予最短的表示,而序列中最不常见的符号被赋予更长的表示。这允许用少得多的比特对非常常见的值进行编码。
此过程将 SVG 中的每个数值视为一个符号。值有多精确甚至是什么值都没有关系;重要的是我们的参数值集的基数。例如,一个零可以编码为三位。仅出现一次的 234.5 可能会消耗 15 位或更多位。
实施这一点具有挑战性。网上没有通用的 Huffman 编码库,它不仅可以生成位表示,还可以生成打包的位集(例如,与 数组缓冲区
)。我改编了一些代码[ 霍夫曼_js](https://github.com/wilkerlucio/huffman_js)
Github 上的库来创建一个接受任意符号(而不是字符)的实现来构建用于制定 Huffman 代码的树。
我最初的尝试为所有路径中所有命令中的所有数值构建了一个单一的霍夫曼树。在上一次尝试的基础上应用,我们得到以下结果:
节省的字节数:2180514
比率:0.3654320060935835
Gzip比率:1.0707779233309709
哇!这非常好(节省了 2MB!),考虑到路径——此时——无损编码。我们也没有真正花太多时间优化事物。
重要的是gzip比率。它仍然 >1,这意味着尽管我们做出了勇敢的努力,但加载结果仍然需要更长的时间。它大了 7%,这并不令人放心,但考虑到 ascii85 增加了开销,这相当不错。
损失
到目前为止,我的工作主要集中在无损操作上。这意味着编码不会丢失任何信息:整个过程的输出与输入相同。无损编码的反面是 有损编码 .这是您丢弃信息并降低输出质量以节省空间的时候,就像在 JPEG 图像中一样。
我首先想到的是降低“大”数值的精度。 0.2 和 0.3 之间的差异可能很大,特别是如果重复数百次。不过,141.1 和 141.5 之间的差异几乎无法察觉。
我想出了一些完全任意的阈值来确定舍入的距离。这是我使用的:
函数损失化(值){
如果(数学.abs(值)> 70){
返回 Math.round(value / 4) * 4;
}
如果(数学.abs(值)> 40){
返回 Math.round(value / 3) * 3;
}
如果(数学.abs(值)> 20){
返回 Math.round(value / 2) * 2;
}
如果(数学.abs(值)> 10){
返回数学.round(值);
}
返回值;
}
基本上,一个数字离零越远,它的损失就越大。想象一个数轴:我们希望我们在数轴上绘制的值“捕捉”到预定义的值。但是,在任一方向上,数字轴上离零越远,这些捕捉点的间距就越远。接近零时,数字不会捕捉到很远。离零越远,数字在任一方向上都离得越远。
这里的想法是四舍五入减少了一组编码数值的基数,这应该减少被编码的符号数量,这应该减少霍夫曼编码产生的值的长度。
您可能会好奇这如何影响渲染输出。答案是它在视觉上是难以察觉的,至少对我来说是这样。
让我们看看这如何影响我们的输出:
节省的字节数:2452764
比率:0.2933209157366562
Gzip比率:0.8354983617125883
出色的!我们的 gzip 输出比原始 gzip 文件小 16% 左右。
我研究的另一种有损技术是使用路径简化。我使用神话般的创建了一个测试实现 Paper.js 库的路径简化算法,但我发现渲染时的结果并不好。字体有太多微小的细节,无法通过现有的简化算法进行处理。也许有一种方法可以将其中一种算法专门用于字体,但这对于比我聪明得多的人来说是一项工作。
更多霍夫曼编码
我最初假设使用三个单独的霍夫曼树(每种类型的路径命令一个)会产生更好的结果。每种命令类型的通用数值(理论上)将与较小的位串相关联。如果 0.2 比 0 更常见,则 0.2 会得到更小的字符串。
我对此进行了测试。我创建了我的 Huffman 编码库的实现,它允许您指定用于编码和解码下一个符号的树。结果并不好。
节省的字节数:2453094
比率:0.29434068322385015
Gzip比率:0.8449959788970782
请注意,节省的字节数比之前的结果要大,但比例更差。这是因为每个单独的路径占用的空间更少(几个字节),但总文件大小更大,因为它需要存储额外的两个霍夫曼代码。
然后我有了另一个想法:我们将命令作为两个位存储在我们的 单位数组
,但有些命令的出现频率远高于其他命令。我们可以为它们创建一个霍夫曼代码,而不是简单地打包这些位!
节省的字节数:2479326
比率:0.2862957215153192
Gzip比率:0.8343815695650806
这里的结果是一个改进,但不是很大。只有一个命令最终成为一位(当然,它经常出现),而最不常见的命令最终变成三位。总体而言,这是一个净赢,但是当每个路径只有几百个(或几十个)命令时,总的比特数并不多。在我们的例子中,总共大约有 20,000 个(有 1,000 种字体,这正是我所期望的)。
我的下一个想法是将命令序列存储在一个单元中,而不是将每个命令视为自己的符号。这是字体中的命令 极致 :
**米** LLLLLLLL **ZM** LLLL **ZM** LLQLLQLLQLLQLLQLLQLLQLLQLLQLLQLLQLLQLLQLLQLLQLLQLLQLL **ZM** LQLLQLLQLLLLLLQLLQLLQLLQLLQLLQLLQLLQLLQLLQLLQLL **ZM** LLQLLQLLLLQLLQLLQLLQLLQLLQLLQLLQLLQLLQLLQLL **ZM** LLQLLQLLLQLLQ **Z**
我已经突出显示了所有 米
和 Z
命令,使其更容易理解。需要指出一些有趣的事情:
- 字形的每个“片段”都以
米
然后是大号
和问
, 直到一个Z
到达了。 问
和米
几乎总是跟着一个或多个大号
s。- 的序列
[MQ]L+
经常重复。
我决定通过将每个命令字符串与正则表达式匹配来创建符号 /([MQ]L*|Z)/g
,而不是将每个命令视为一个符号。只花了一点时间就可以让 Huffman 编码实现很好地发挥作用,结果还不错:
节省的字节数:2513864
比率:0.2758463779197209
Gzip比:0.8172053280427702
继续取得良好进展,gzip 压缩包又减少了百分之几。
我玩弄了正则表达式,尝试了可以提高压缩率的差异组合。一些尝试:
- 限制连续次数
大号
s。大量连续大号
之后的命令米
或者问
可能会导致不常用的符号,而两个较短的符号(例如,MLLLL
和LLLL
相对MLLLLLLLL
) 可以更频繁地使用。这产生了负面影响。 - 匹配
QZ
作为自己的象征。 - 匹配连续
问
命令。这没有效果。
我定了 /(QZ|[MQ]L*|Z)/
因为它似乎产生了最好的结果:
节省的字节数:2515364
比率:0.27538726434060756
Gzip比:0.8158475953153337
其他可能的压缩方式
上面的结果是我停下来的地方。但这并不是说我缺乏额外的想法!当我有更多时间时,我想尝试一些事情:
米
,问
, 和大号
接受成对的参数 (问
接受两对)。探索这些对是否可以被视为它们自己的符号以节省空间会很有趣。- 也许结合前面的想法,使用大写字母的 SVG 命令使用绝对坐标。有使用的小写变体 相对的 坐标。使用相对坐标时,值可能会更小。如果有更多的小值,可能会降低参数值的基数
- 我没看过,但我怀疑 svgomg 内置某种无损路径简化算法。探索它是否有效,如果是,它是如何工作的,将会很有趣。无损简化算法不难按原样放入代码中。
- 与路径简化一样,序列
大号
命令可能很容易简化。大号
从当前光标位置到参数定义的点绘制一条线。如果两条线形成的角度足够接近 180°,则某些中间线可能是完全可移除的。
执行
拥有这些字体的压缩版本很棒。尽管使用了内存,但在浏览器中对它们进行解码也并不昂贵。在执行解码过程时,我无法测量任何明显的停顿。
字体选择器的实现几乎没有什么魔力:它是一个简单的自定义下拉菜单,使用 反应列表
一次最小化页面上的节点数。
主要的 JavaScript 包知道字体列表,因此它能够在每个字体系列的下拉列表中呈现一个项目(在加载 SVG 路径时使用显示名称的默认字体)。加载了 SVG 路径和渲染它们的组件 React.lazy
.该组件仅接受字体系列的名称作为道具。
结果是字体系列名称的平滑滚动下拉列表,以它们自己的字体作为矢量呈现,文件大小相当小。这还不错!
整理起来
这是很多工作,但我在此过程中学到了很多东西。不过,最后我只节省了大约 18% 的最终 gzip 压缩包大小。这有点难以下咽。事实证明,Gzip 非常擅长通过重复压缩内容。这不是一件坏事!我没有测量它,但如果 Brotli 在压缩数据方面做得更出色,我不会感到惊讶。即使 Brotli 的比率非常接近 1,生成的捆绑包仍然非常小。
自然,我们必须问自己是否在计算时间(在这种情况下,解码时间)和存储(在这种情况下,使用的带宽)之间进行权衡。在这种情况下,计算时间微不足道,即使我们做了 10 倍的工作,权衡仍然是值得的。
这对现实世界有什么影响?好吧,捆绑包的大小因部署而异。现在,包含字体数据的块压缩后大小约为 950kb。这意味着原始 gzip 压缩后的大小约为 1.12mb,我们可以四舍五入地说只有大约四分之一兆字节的差异。这不是微不足道的!对于连接速度较慢的用户,四分之一兆字节可能是几秒钟。
当我做这项工作时,我有 Dropbox 的 Lepton 图像压缩器 心里。 Lepton 通过对 JPEG 应用无损压缩来工作,而 gzip 本身无法做到这一点。 Gzip 尽管非常好,但对 JPEG 中的实际内容一无所知。相反,Lepton 使用特定于 JPEG 编码方式的技术,解锁了保存简单压缩算法无法自行保存的字节的能力。
现在,当然,这里的目的非常不同:Dropbox 想要节省服务器上的存储空间,而 Pinecast 想要减少通过线路发送到客户浏览器的字节数。成本/时间/资源的权衡非常不同,但基本的技术原理非常相似。就结果而言,令人鼓舞的是,Dropbox 实现了约 22% 的压缩,而我实现了额外的约 18% 的压缩。
总的来说,我对这里的结果很满意。 18% 并不是我所希望的绝对最佳结果,但未压缩的输出仍然小了近 73%,这对于纯粹的学术练习来说非常酷。我可能会在某个时候重新审视这项工作,看看我是否不能挤出更多的字节。如果您有任何想法或建议,请随时与我们联系。
也感谢 杰夫·萨尔纳特 因为在 Twitter 上戳我并提醒我我从未完成过这篇博文。把它擦亮并把它拿出来是我完全忘记做的事情!
版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明