Font2svg 特殊字体渲染方案

4029 字
21 分钟
...阅读
...评论

2023-09-12 发表于 哔哩哔哩技术公众号哔哩哔哩技术专栏 等处。

GitHub 开源地址:Font2svg

注:开源版方案和文中略有差别,但更通用。API 服务已经完成,前端组件的开源版还没搞完。

背景介绍

在 Web 开发中,经常会需要在页面中引入一些特殊字体,这些字体通常不在系统字体库的范围内,并且动辄 4~5MB,甚至有些字体超过 10MB,会影响用户加载体验,尤其在手机端使用移动网络的情况下。

针对不同的业务场景,通常会有以下几种解决方案:

一、使用切图

当需要显示特殊字体的文案为固定文案时,直接使用切图可能是最常见的一种解决方案,无论是透明的 PNG 切图,还是用矢量的 SVG,都能在便捷度和加载体验上有不错的体验。当然,使用 PNG 的时候需要注意高分辨率屏幕的适配,使用 1 倍图在高分辨率的屏幕下肉眼很容易看出模糊。

二、使用原始字体

当需要显示特殊字体的文案是动态文案时,就没办法直接用文字切图来实现了。通常在对用户加载体验没有特别要求的场景下,可直接通过 @font-face 来直接引入特殊字体的文件进来。缺点也显而易见,正如前文所说,动辄 4~5MB,甚至超过 10MB 的字体文件,会比较影响用户的加载体验,在首屏需要显示的场景下,用户往往会先看到先渲染默认字体,再闪现成特殊字体的情况。

三、使用裁剪/压缩后的字体

字体裁剪技术,可通过省略字体变体(如某些字体会包含多个粗细)、裁剪字体字符集(省略不常用的字符)、通过其他压缩算法(如 woff、woff2)等方式来降低字体体积。当文案内容相对可控的情况下,用该方案可比直接加载原始字体要节省更多体积。但若字符裁剪过多,会导致缺少部分字符,在显示时出现字体不一致的情况;若字符裁剪过少,体积仍会较大。并且通常情况下,裁剪后的字符数也是远大于用户实际需要渲染的字符数,还是会有非常大的不必要的下载流量。

实现原理

在动态渲染特殊字体文案的场景下,为了提升用户的加载体验,我们研发了一个创新的解决方案:「Font2svg」。在介绍实现原理前,先简单介绍下字体相关的基础知识。

字符(Character)

在计算机中,字符是任意单个文本元素,例如字母、数字、标点符号、汉字甚至 Emoji 表情等。字符通常是构建文本的基本单位。

字符集(Character Set)

字符集是一组预定义的字符的集合。它是对字符进行分类和组织的方式,以便在计算机系统中能够使用。会为每一个字符分配一个唯一的 ID,叫做 Code Point(码点、内码),常见字符集:ASCII、GB2312、GBK、Unicode 等。

字符编码(Character Encoding)

字符编码是一种将字符映射到特定二进制数的规则,以便在计算机中存储。通常字符集和字符编码都是成对出现,如 ASCII、GB2312、GBK 等,都是既代表了字符集,也代表了对应的字符编码。而 Unicode 比较特殊,有多种字符编码,如 UTF-8、UTF-16 等。

字体家族(Font Family)

字体家族是指具有相似设计特征的一组字体。这些字体共享类似的外观,但在细节上可能略有不同,如粗细、斜体等。例如:「思源宋体」就是一个字体家族。

字体样式(Font Style)

字体样式是字体家族中特定字体的变体,它定义了字体的外观。例如:Light、Regular、Bold、Heavy 等。

字型(Font)

字型是一个字体家族下的特定样式的字体,例如: 思源宋体-Bold

字形(Glyph)

字形是指一个单独字符在特定的字体(字型)中的具体外观或形状。每个字符都有对应的字形,它们描述了字符的细节和轮廓。如下图,就是「哔」这个字符在不同的字体下不同的字形:

字体度量(Metrics)

字体度量是指字体中字符的尺寸和位置信息。它包括了字符的宽度、高度、上沿线(ascender)、下沿线(descender)、基线(baseline)等数据。字体度量在排版中非常重要,它决定了字符之间的间距和对齐方式,确保文本在屏幕或打印上的合理布局。

轮廓(Outline)

字体轮廓是字形的矢量图形表示,一个字形由若干轮廓组成,每个轮廓由若干条线段或贝塞尔曲线闭合而成。根据字体格式不同,使用的贝塞尔曲线的阶数也有区别。TrueType(TTF)字体采用二次贝塞尔曲线(3 点控制),而 OpenType(OTF)字体采用三次贝塞尔曲线(4 点控制)。

一个轮廓组成的字母 C:

多个轮廓组成的字母 B:

二次贝塞尔曲线:

三次贝塞尔曲线:

本方案的原理本质上还是利用 SVG 来渲染特殊字体,通过上面的介绍,我们知道,字体的轮廓由若干线段和贝塞尔曲线组成,得知了线段的坐标和贝塞尔曲线的控制点,我们就可以绘制出轮廓的 SVG,而有了 SVG 我们就可以用来显示。因此我们可以通过解析字体文件,按需获取对应字符的 SVG 来避免加载整个字体文件。如何去唯一表示一个特定字符呢?那就是通过字符的 Unicode 编码。如下图所示,我们在渲染「哔哩哔哩乾杯」这几个字符的时候,只需要根据「哔」、「哩」、「乾」、「杯」这四个字符的 Unicode 编码去获取他们的 SVG,然后组合起来显示即可。

考虑到性能,如果每个字符都实时去请求服务端生成 SVG 的话,并发小的时候没影响,并发大了的话,这个字体转 SVG 的服务就会成为瓶颈。不过由于这些 SVG 生成之后就几乎不会变,所以可以预先全部生成好,放到 CDN 上,每次直接根据 Unicode 编码去 CDN 上获取指定字符的 SVG 即可。这样既避免了服务端的压力,还利用了 CDN 的特性,可以让用户更快的获取到所需的资源。

「字体转 SVG」只是第一步,在实际渲染中,还需要修改 SVG 的样式,例如字体颜色、下划线等,因此我们还做了一个前端的组件,用来对获取到的 SVG 进行样式的处理以还原字体的样式。这个组件实现了根据所需字符「动态加载 SVG」以及「SVG 复原字体样式」,在使用起来只需要传入字体名称、所需显示的字符、一些字体样式等,大大减少了前端接入的复杂度。

整体流程如下图,页面开发阶段,在 Font2svg 后台预先上传字体文件,解析所有字符的 SVG,并预先上传至对象存储/CDN 上。在页面加载时,通过前端组件传入需要渲染的字符和对应字体的配置,从 CDN 拉取特定字体特定字符的 SVG,并根据样式对字符的 SVG 进行二次编辑,复原字体上色、描边、下划线等样式。

后台效果预览:

如何解析字体并生成字符的 SVG

字体解析服务通过 Python 实现,借助 freetype 这个库对字体进行解析,遍历字体中的每一个字符,每个字符的字形(glyph)包含了轮廓(outline)和度量(metrics)数据,通过 outline.decompose 方法,可以将字符的轮廓数据分解为一系列的路径段,其中每个路径段都是一个点序列,表示字符的一部分。这些路径段可以是直线段、二次贝塞尔曲线或三次贝塞尔曲线。通过这些路径段的类型和点坐标,再通过 svgpathtools 这个库绘制成 SVG 的路径。

outline.decompose(
    context=None,
    move_to=self.callbackMoveTo,
    line_to=self.callbackLineTo,
    conic_to=self.callbackConicTo,
    cubic_to=self.callbackCubicTo,
)

def callbackMoveTo(self, *args):
    self._verbose("MoveTo ", len(args), self.vectorsToPoints(args))
    self._lastX, self._lastY = args[0].x, args[0].y

def callbackLineTo(self, *args):
    self._verbose("LineTo ", len(args), self.vectorsToPoints(args))
    line = Line(self.lastXyToComplex(), self.vectorToComplex(args[0]))
    self.svg_paths.append(line)
    self._lastX, self._lastY = args[0].x, args[0].y

def callbackConicTo(self, *args):
    self._verbose("ConicTo", len(args), self.vectorsToPoints(args))
    curve = QuadraticBezier(self.lastXyToComplex(), self.vectorToComplex(args[0]), self.vectorToComplex(args[1]))
    self.svg_paths.append(curve)
    self._lastX, self._lastY = args[1].x, args[1].y

def callbackCubicTo(self, *args):
    self._verbose("CubicTo", len(args), self.vectorsToPoints(args))
    curve = CubicBezier(
        self.lastXyToComplex(),
        self.vectorToComplex(args[0]),
        self.vectorToComplex(args[1]),
        self.vectorToComplex(args[2]),
    )
    self.svg_paths.append(curve)
    self._lastX, self._lastY = args[2].x, args[2].y

除了 SVG 的路径之外,还有一个很重要的内容,就是 viewBox。因为在字体中,表达一个字形,除了轮廓之外,还有一个很重要的 metrics 信息,metrics 信息没有还原好,在字体排版的时候就会出现问题。如果以轮廓本身的大小作为容器的话,一些未占满空间的文字,或者一些标点符号,在排版时,就会被缩放的很大;而如果以字体的上沿线和下沿线来计算一个方形容器显示的话,对于中文字符问题不大,但对于半角字符,其中间的间隙就会显得太大,如下图:

因此,我们需要从 Metrics 中提取每个字形的基本信息。以横排为例,取每个字形自己的宽度来绘制 viewBox,高度还是通过上下沿线相减来计算。这样在排版时就可以得到想要的效果了:

def calcViewBox(self, metrics):
    view_box_min_x = 0
    view_box_min_y = -self.face.ascender
    view_box_width = metrics.horiAdvance
    view_box_height = self.face.ascender - self.face.descender
    return f"{view_box_min_x} {view_box_min_y} {view_box_width} {view_box_height}"

在有了 pathviewBoxwidthheight 等信息之后,我们就可以直接生成能表达具体字形所需要的 SVG 文件了。

还有一个特殊字符,那就是空格,他是不包含任何轮廓的,解析轮廓的时候结果是空的。在生成 SVG 的时候,如果 path 是为空会失败,这时候可以生成一个和 viewBox 同样大小的透明度为 0 的轮廓用来占位。

path = (
    Path(*self.svg_paths).scaled(1, -1)
    if len(self.svg_paths) > 0
    else parse_path(
        f"M 0,{-self.face.ascender} L {metrics.horiAdvance},{-self.face.ascender} L {metrics.horiAdvance},{-self.face.descender} L 0,{-self.face.descender} Z"
    )
)

怎么设置字体样式

在前面的步骤中,我们为每个字形预先生成好了对应的 SVG 文件,但我们知道,SVG 文件是一个静态的文件,其字体颜色等样式是固定的,在前端使用的时候,如何去动态设置颜色等样式呢?

我们在前端去加载 SVG 字体文件的时候,并不是直接通过 img 标签的方式来引入 SVG,而是在前端组件中去下载 SVG 文件内容并解析 DOM 对象,然后根据需要去修改对应的属性或添加额外的样式,例如修改 pathfill 属性来设置颜色,修改完样式后再把修改后的 SVG 标签直接插入到文档中。还有一些字体全局的样式信息,例如下划线的位置、粗细等,在不同的字体中,也是有不一样的配置的。我们需要想办法把这些信息也通过 SVG 传递过来。解决方法也很简单,我们在 SVG 的根节点上添加一个自定义的属性,将所需传递的信息转成 JSON 格式然后塞到这个属性里,前端在解析 SVG 的 DOM 时,把这个属性中的数据取出来解析并渲染就可以了。

上面截图中,underlineunderlineThickness 就分别代表了下划线位置和粗细信息,在解析到这两个信息后再做一些转换处理,可通过 ::before 伪元素来绘制下划线:

实践案例

页面接入

创作中心每周会给 UP 主推送荣誉周报,页面中首屏有非常大的篇幅显示的是本周关键词,效果如下图:

这个场景有两处使用了特殊字体「方正 FW 筑紫黑」,一个是卡片标题「本周关键词」这几个字,这个是固定标题,用传统的切图方式也是 OK 的,但下面的关键词是根据 UP 主上周的投稿、评论、弹幕、播放等数据由服务端动态下发的,这个就无法通过切图来实现了,需要穷举的关键词特别多。

在使用本方案之前,该页面使用的是直接引入字体文件来设置字体。由于该页面加载资源较多,在加载时不可避免会出现字体闪烁、图片资源加载过程页面抖动的问题,在手机上通过 4G 网络访问时尤其明显。为了避免这种情况,该页面渲染之前加入了一个 Loading 页,在 Loading 页上加载所需的所有资源,包括图片、字体文件等。

使用本方案优化前后的数据对比如下:

渲染文字所需资源首屏加载时长页面跳失率
优化前2855KB2268ms5.47%
优化后45KB1791ms3.11%
对比🔽 下降 98.4%🔽 下降 21%🔽 下降 2.36PP
  • 渲染文字所需资源:优化前字体文件为 2855KB,优化后 JS 组件库为 37KB,4 个 SVG 为 8KB,总共 45KB 的资源,下降了 98.4%
  • 首屏加载时长:从 2268ms 下降至 1791ms,下降了 21%
  • 页面跳失率:从 5.47% 降低至 3.11%,下降了 2.36PP

最终本方案不仅通过降低首屏加载时长提升了用户的加载体验,同时也获得了页面跳失率下降的业务结果。

失败兜底

尽管本方案所需加载的资源量相比直接引入字体包要小很多,但请求数量会根据字符数有所上升,在极端情况下,网络失败会导致资源请求失败;另外,一些字体包含的字符数有限,在请求这个字体本身就不存在的字符的时候,也会出现请求失败的情况。在缺失 SVG 的情况下如何进行兜底的显示也是我们需要考虑的内容。

在组件侧获取不到对应的 SVG 时,可通过系统默认字体直接在字符位置进行渲染作为兜底,与浏览器默认的处理方式一致。具体效果如下:

由于兜底的默认字体与目标字体不同,他们的容器大小和下划线位置、下划线粗细等设置均有可能不一样,所以在开启了下划线的情况下,会出现下划线高度和粗细不一致的现象,如下图:

为了解决这个问题,我们在渲染下划线的时候,对于兜底的默认字体,不采用 css 样式来绘制下划线,而是与 SVG 一样,通过伪元素并且使用目标字体的下划线设置来进行绘制,最终效果如下图:

总结

在本文中,我们深入探讨了 Font2svg 方案的技术原理和实现细节。我们通过将字体转换成 SVG 进行渲染,降低了用户渲染特殊字体动态文字所需下载的文件大小,提高了加载速度,从而优化了特殊字体在 Web 页面中的加载体验。

这套方案在动态渲染特殊字体文案的场景下具有广泛的应用前景,不只是在 Web 端,在客户端上也同样适用。它能为设计师和开发者提供更灵活、高效的特殊字体渲染方案,让设计师不会再因为字体包体积而放弃使用一些艺术字体,同时也让开发者不再为字体包的大小而头疼。

评论区
Copyright © Bean Deng