Font2svg 特殊字体渲染方案

admin 2023年9月12日16:01:32评论20 views字数 6714阅读22分22秒阅读模式

本期作者


Font2svg 特殊字体渲染方案


邓斌

哔哩哔哩资深开发工程师


Font2svg 特殊字体渲染方案


背景介绍


在 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)


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


Font2svg 特殊字体渲染方案


 字体度量(Metrics)


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


Font2svg 特殊字体渲染方案
Font2svg 特殊字体渲染方案


轮廓(Outline)


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

一个轮廓组成的字母C:


Font2svg 特殊字体渲染方案


多个轮廓组成的字母B:


Font2svg 特殊字体渲染方案


二次贝塞尔曲线:


Font2svg 特殊字体渲染方案


三次贝塞尔曲线:


Font2svg 特殊字体渲染方案


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


Font2svg 特殊字体渲染方案


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

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

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


Font2svg 特殊字体渲染方案


后台效果预览:

Font2svg 特殊字体渲染方案

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


字体解析服务通过 Python 实现,借助 freetype(https://github.com/rougier/freetype-py)这个库对字体进行解析,遍历字体中的每一个字符,每个字符的字形(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 信息没有还原好,在字体排版的时候就会出现问题。如果以轮廓本身的大小作为容器的话,一些未占满空间的文字,或者一些标点符号,在排版时,就会被缩放的很大;而如果以字体的上沿线和下沿线来计算一个方形容器显示的话,对于中文字符问题不大,但对于半角字符,其中间的间隙就会显得太大,如下图:


Font2svg 特殊字体渲染方案


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


Font2svg 特殊字体渲染方案


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}"


在有了 path、viewBox、width、height 等信息之后,我们就可以直接生成能表达具体字形所需要的 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 对象,然后根据需要去修改对应的属性或添加额外的样式,例如修改 path 的 fill 属性来设置颜色,修改完样式后再把修改后的 SVG 标签直接插入到文档中。还有一些字体全局的样式信息,例如下划线的位置、粗细等,在不同的字体中,也是有不一样的配置的。我们需要想办法把这些信息也通过 SVG 传递过来。解决方法也很简单,我们在 SVG 的根节点上添加一个自定义的属性,将所需传递的信息转成 JSON 格式然后塞到这个属性里,前端在解析 SVG 的 DOM 时,把这个属性中的数据取出来解析并渲染就可以了。


Font2svg 特殊字体渲染方案


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


Font2svg 特殊字体渲染方案


实践案例


页面接入


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


Font2svg 特殊字体渲染方案


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

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

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



渲染文字所需资源 首屏加载时长 页面跳失率
优化前 2855KB 2268ms 5.47%
优化后 45KB 1791ms 3.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 时,可通过系统默认字体直接在字符位置进行渲染作为兜底,与浏览器默认的处理方式一致。具体效果如下:


Font2svg 特殊字体渲染方案


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


Font2svg 特殊字体渲染方案


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


Font2svg 特殊字体渲染方案


 总结


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

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


以上是今天的分享内容,如果你有什么想法或疑问,欢迎大家在留言区与我们互动,如果喜欢本期内容的话,欢迎点个“在看”吧!


往期精彩指路

原文始发于微信公众号(哔哩哔哩技术):Font2svg 特殊字体渲染方案

  • 左青龙
  • 微信扫一扫
  • weinxin
  • 右白虎
  • 微信扫一扫
  • weinxin
admin
  • 本文由 发表于 2023年9月12日16:01:32
  • 转载请保留本文链接(CN-SEC中文网:感谢原作者辛苦付出):
                   Font2svg 特殊字体渲染方案https://cn-sec.com/archives/2029447.html

发表评论

匿名网友 填写信息