onlyoffice表格字体渲染实现思路

lxf2023-04-12 21:18:01

前言

字体渲染是浏览器自带的基本能力,不管是基于HTML还是Canvas渲染技术;针对大型Excel表格,onlyoffice内部使用Canvas技术来渲染表格内容;现代浏览器实现Canvas内字体设置和渲染的方案有成熟的API直接使用,代码简写如下:

const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
ctx.font = '微软雅黑';
ctx.fillText('中', 0, 0)

这种方案能满足大部分场景需求,不过不同浏览器在字体解析和渲染实现存在差异,这会出现一致性问题:同样的字符,在不同操作系统、不同浏览器展示效果可能不一致,知乎上也有类似问题提到一致性现象;所以如果编辑器产品方案要考虑浏览器兼容性和渲染一致性问题时,那么编辑器内部就要接管字体的解析和渲染工作。本文介绍onlyoffice解决这个问题的一种思路,希望能抛砖引玉。限于本文水平有限,有不准确的地方欢迎指正或者讨论。

实现细节

onlyoffice整体方案类似浏览器底层文本渲染引擎的实现思路,浏览器内部展示文本的整体流程包括三个步骤:

  • 查找字体:根据CSS font-face语法指定一系列字体列表,查找时按照顺序遍历列表,找到第一个符合条件(比如本地是否有该字体的TTF文件)的字体
  • 加载字体:加载符合条件的本地字体文件
  • 渲染字体:这个步骤浏览器调用OS提供的文本排版引擎,然后调用浏览器的渲染引擎(一般是CG图形接口)直接绘制,不同OS实现了不同文本排版引擎,比如IOS提供CoreText,Window7后提供DirectWrite引擎,每种排版引擎都是各自自研的。

读到这里了解到字符展示的全流程,但是网页包括很多字符,这些是通过HTML标签和CSS来组织结构,最终通过浏览器布局引擎决定文本位置,而布局引擎内部涉及到文本排版相关也是调用了OS提供的文本排版API实现。

Onlyoffice内部基本上模拟了这个流程,不过每个步骤的实现方案和浏览器原生实现不一样,整体流程如下:

onlyoffice表格字体渲染实现思路

下面分步骤说明具体实现思路,其中每个逻辑内部依赖freetype库,它是一个完全免费、高质量的且可移植的字体函数库,提供统一的接口来操作(比如读取、绘制字体位图信息等)多种字体格式文件;以下内容每个部分都和该库有关,下面文字渲染部分也会简单说明该库的内部原理

字体查找

在表格编辑器中,查找字体的目标是根据给定字体名字,找到最适合展示该字体的ttf文件。比如用户在工具栏-字体选择列表选中名字Aria,希望找到对应的Aria.ttf文件。

看到这里容易有这样的误解:列表项名字和对应ttf文件是一对一关系;事实上并不完全是这样,在解释之前先穿插说明下工具栏-字体选择列表中每项含义:每项代表一个字体集(font family)。一个字体集列包含一组密切相关的字体,它们差别在样式、亮度等差异,样式包括是否bold、是否斜体;例如,Aria是一个字体集,它包含如下ttf文件:

Aria.ttf (Roman常规样式的ttf文件)
Aria-Bold.ttf (样式为加粗的ttf文件)
Aria-Italic.ttf (样式为斜体的ttf文件)
Arial_Bold_Italic.ttf(样式为斜体加粗的ttf文件)

所以需要根据选中的字体集,加载对应所有字体文件;这就需要提前建立字体集和字体文件的映射关系,这部分逻辑在服务端启动时通过运行脚本程序生成,生成逻辑如下:

  • 遍历机器中事先指定的字体文件所在目录(一般是/user/share/fonts),获取所有字体文件列表(假设结果是fileFontList
  • 遍历fileFontList每个字体文件,建立以下信息
    • 建立字体路径(wsFontPath)和索引下标的映射关系mapFontFiles,因为wsFontPath值是唯一的,索引下标的意义是通过数值索引给字体文件重命名
    • 根据freetype库读取字体文件内部的元信息,保存在字体文件信息列表里
    • 建立字体集内部对象fontMap:以字体集名字作为key,value是字体集内部元信息,其结构如下;其中Index*对应字体文件的索引号,以属性IndexI为例,其值设置依据:判断当前bItalic是否为真,bBold是否为假,是就根据wsFontPath,从mapFontFiles获取索引号,作为IndexI的值
  • 根据fontMap,按照字体集名字以字典序进行排序,得到字体集列表uiFontList,这个信息最终决定了用户在前端工具栏看到的字体下拉选择列表内容。
// 字体集内部结构
interface CFontInfo {
    ...  
    IndexI number //
    IndexB number
    IndexBI number
    IndexR number // 
    Name string // 字体集名字
}

最终字体集和字体文件的关联图如下,这些信息最终写到JS文件,前端在页面初始化时加载和执行该JS文件,将信息反序列化为页面内部数据结构。

onlyoffice表格字体渲染实现思路 有了这个信息,就可以作为字体匹配的依据,匹配的结果是根据某个字体名称,找到最合适的字体文件。字体名称出现在字体集列表里,比如用户手动在UI工具栏-字体集列表选中某个字体集。此时只要根据CFontInfo结构内部信息,判断对应Index信息是否存在,存在时向服务端动态加载文件名为Index的字体文件即可。

字体加载

字体加载前后主要考虑两个问题:

1、怎么决定首次请求加载哪些字体?

先解释下为何要考虑这点,难道不能事先把工具栏里涉及到的所有字体列都下载下来?从用户体验角度看,这个方案是不可行,因为字体列表数量多(默认字体集列表里有一百多个列表项),而且有些字体比较大,比如微软雅黑对应TTF格式文件的大小超过20M,所以如果一开始全部加载,会造成用户首次打开时速度慢,页面内存占用大的问题。所以为了减少不必要的字体加载,加快首页打开速度,需要根据一定策略决定要请求加载目标字体名。

这个策略有两种类型:

  • 静态:比如业务只考虑中文环境,中文默认使用黑体展示,英文使用Times系列,那么事先就默认指定请求这两种字体即可。国内的石墨表格就是这么做
  • 动态:这个类型一开始不知道是哪种业务环境,可能是中文、英文、日文、韩文、阿拉伯文等情况,所以需要有个启发式分析过程,找到后再去加载字体。像onlyoffice表格编辑器就使用该策略。

当然如果单元格内已经指定了固定字体名字,那么首次加载就必须包含这些字体了。现在考虑动态策略里的启发式分析过程,核心拆解成两个步骤

  • 找到目标文字 :就是查找表格内部使用到哪些语言的文字,比如用户将本地语言设置为中文,那么菜单、日期等格式化文案就显示为中文,这些内部配置的格式化文案可以看作“目标文本”。目前编辑器内部默认使用本地语言配置、文件名作为策略,然后遍历目标文本(比如格式化文案、文件名包含的所有文字),每个文字作为下一步的输入
  • 根据文字找到匹配的字体:简单来说这一步就是给定文字的unicode值,查询索引区间表,判断unicode落在哪个区间,这个区间内的值就是字体索引文件名。这里解释下什么是索引区间表, 如下图:

onlyoffice表格字体渲染实现思路

这个区间表是服务端生成后返回给前端:内部遍历所有unicode字符范围(大概100w个),计算每个unicode值对应最佳展示字体,用文件名索引值表示计算结果;考虑到存储所有字符的效率低,以及观察到某段unicode范围内对应相同的计算结果,所以通过区间表这个数据结构表示结果就比较高效。

根据上图可知区间表下标是有序的,所以给定某个unicode值,查找时就可以用折半算法来快速找到所在区间。该区间的值就是要找到的文件名索引。

索引区间表还有个很重要作用是当字体文件内部不支持展示某个文字时的默认回退策略。怎么理解?举个例子:默认情况下编辑器单元格内设置了Calibri字体作为默认字体,假设要显示“中”,根据字体匹配流程,Calibri字体集匹配到的字体文件名是Carlito.TTF(实际是一个索引名,比如063.TTF这样),但是该字体文件支持的字形列表不包含中文,此时就根据“中”对应的unicode码点,到索引区间表查询最佳的字体,假设结果是黑体,所以在设置Calibri字体集下,最终“中”这个文字以“黑体”效果展示。

2、找到指定字体文件后,怎么加载字体?

这个逻辑比较简单,基本上通过AJAX技术动态加载字体,关于动态加载的代码实现,主要是维护内部的请求状态,这块感兴趣的可以自己尝试实现。

字体渲染

前面描述了字体元信息含义,提到字体依赖该信息查询到对应ttf格式文件。字体渲染就是按照指定TTF格式,渲染某个文字。这个内部实现也是依赖FreeType字体引擎,也就是说FreeType是实现字体跨平台一致性效果的核心实现,目前编辑器实现跨平台文字渲染用到它。所以在详细说明字体渲染之前先了解FreeType引擎内部原理;

FreeType引擎提供的API核心能力是将字形关键点按照规则连线变成指定设备下的字符轮廓,最后再将轮廓填充成Bitmap位图显示在光栅设备上。内部主要涉及四个步骤:

1. 假设已经给定了字体文件,以及要渲染指定大小的文字‘A',根据JS提供的charCodeAt 方法得到对应的UTF-16编码值。然后把这些信息作为FreeType API的输入

  1. 字体文件查找字形:这涉及到字体文件内部格式的理解,以TTF格式为例,可以简单理解该文件内存储每个字符编码和对应字形的映射关系,每个字形存储了轮廓信息,每个轮廓实际上就是一系列坐标点,几何意义上表示直线或者贝塞尔曲线。或者把轮廓想象成汉字的笔画就行,每个笔画用直线或者贝塞尔曲线表示,所有笔画组成字形的轮廓。

  2. 初始化字体大小和位置,位置根据字体是否加粗、斜体、描边、阴影等效果来决定。这一步目的是将轮廓缩放成需要的尺寸。

  3. 转换位图:FreeType根据字形轮廓和给定字体信息,填充为位图。也可以简单理解成把轮廓内部的坐标点填充在屏幕就行了

上述流程实现的源码在这里。目前FreeType引擎和上述流程的实现使用C语言开发,所以为了让它在页面执行,WebAssembly技术登场了。编辑器前端使用Emscripten将C语言代码编译到 WebAssembly,生成wasm格式的文件,然后页面首次打开时加载该文件。具体build逻辑见这里。

所以使用freeType引擎渲染文字得到的位图信息,就可以在Canvas上展示了。至此从编辑器角度,一个字符的核心渲染思路就比较清晰了。现在站在使用者角度,梳理下设置单元格字体到最终字体展示的逻辑:

  • 查询要渲染字符对应的unicode码点
  • 调用freeType库暴露的API,传入码点和具体ttf文件,得到对应字形的bitmap位图信息
  • 通过Canvas的drawImage方法绘制字形,在指定光栅设备(例如显示器)渲染bitmap位图信息展示文字

总结

本文介绍onlyoffice表格编辑器内部文字渲染的基本流程,为了效果一致性考虑,它并没有直接调用浏览器内部提供的fillText这样API,而是基于FreeType字体库重新实现了一套全生命周期的渲染逻辑,包括字体查找、加载和渲染的流程;通过前后端协同,服务端事先准备字体的元信息、字体集列表、字体索引区间表信息并返回给前端页面,然后页面内部根据指定的文字和设置的字体集,查找对应的字体文件,通过FreeType库渲染得到该文字对应的位图信息,最终绘制该位图到Canvas画布上以展示文字

当然这套逻辑比较定制化,这部分源码不那么清晰易懂,而且FreeType渲染引擎和操作系统自带的文字渲染引擎差异可能会出现同样的文字渲染出细微差异,这给用户体验上也带来困扰,所以上述技术实现思路在大部分业务场景是使用不到的。但不妨作为一次学习机会了解文字渲染底层思路,以及wasm技术的应用,也能帮助理解基于Canvas技术内部的文字渲染原理

参考资料

字体概念:zhuanlan.zhihu.com/p/20338690

FreeType官网:freetype.org/

webAssembly:developer.mozilla.org/zh-CN/docs/…

Onlyoffice字体相关源码:github.com/ONLYOFFICE/…