大家好,我是前端西瓜哥。
前段时间将图形编辑器的文字渲染做了下改造。
原来的文字渲染使用的是 Canvas 2d 的 fillText 方法,现在改成了用 opentype.js 解析字体获取字形(glyph)路径,然后去渲染这些路径。
suika 图形编辑器 github 地址:
线上体验:
今天我们来简单说说怎么解析字体并用 glyph 渲染文本。
解析字体的优势
相比用 Canvas 2d,自己去解析生成 glyph 的路径去渲染,有什么优势呢?
首先是可以和 Canvas 2d 这一特殊的 渲染引擎解耦,我们有了 glyph 的路径信息,那就不再依赖引擎内部的文本渲染特性,可以换一些支持基础图形的引擎了,比如换成 SVG,或者自己基于 WebGL 实现的渲染引擎。
然后是可以在渲染前 对 glyph 路径做一些 “魔改”,比如一些对渲染质量要求不高的场景(比如 CAD 的文字),我么可以将 glyph 的曲线离散成一些间隔比较大的连续直线,去得到 “劣化” 版的低保真文字。
虽然看起来不太光滑(尤其是放大的时候),但可读性完全没问题,渲染效率高。
或者可以实现一些字体效果,比如字体没有对应的 bold 字体,可以通过 offset 扩展路径来大概模拟,就是效果差了些
或者做成动态分辨率,当文字远离你变得非常小的时候,用 “劣化” 版的文字,足够小的时候甚至可以直接渲染成一个黑色方块。
此外,我们可以 在不加载字体的情况下,渲染出文字。
因为我们已经拿到了路径信息,只要把这些路径信息保存好,那渲染时就可以不需要字体的参与了。当然字体不存在的话,修改文本就做不到了。
这在文本只需要渲染不需要编辑的场景是很有用的。
比如 图纸初始化时,可以不预先加载字体,直接渲染文本对象下额外保存的路径数据(之前编辑时得到的),字体的加载异步进行,确保用户编辑前加载好即可。
这样可以有效 减少用户初次打开图纸的时间,尤其是图纸使用了大量不同字体的场景。
将图纸导出在另一个没有对应字体的操作系统上打开,也能正确显示出来。但如果要修改,找不到的字体需要用户手动替换为已有的其他字体。
最后是可以实现 非常灵活的文字排版,比如 text on path,跟随路径的文字,其实就是在路径上间隔一段距离求出差值点和切向量,然后让对应字形路径的基线对齐上去。
(Figma 的 text on path 效果)
缺点是 要自己实现 各种底层的文字排版效果,各种计算各种 transform。
opentype.js 用法
这里我用了 opentype.js 第三方库(和 opentype 标准的官方没有关系),支持各种字体格式的解析。可以解析 otf、ttf、woff 等字体格式。
一些基础的用法。
1、加载字体。
import opentype from 'opentype.js';
const font = await opentype.load('./smiley-sans-oblique.otf');
2、拿到字符串对应的 glyph 路径。
const glyphs = font.stringToGlyphs(str);
glyph 里面有对应字符的 advanceWidth(前进宽度),unicode 值、字符名、在字符集的索引位置等信息。
读者可以试试 opentype.js 官方提供的 Glyph 工具:opentype.js.org/glyph-inspe…
glyph
我们具体看看一下 b 的在 “得意黑” 字体下的 glyph 是怎样的。
读取 b 的路径值。
const pathStr = glyph.path.toPathData(100);
拿到的路径是:
M96-30L107 21C126-19 158-40 203-40C319-40 392 95 414 284C437 485 388 590 287 590C246 590 210 572 182 537L221 810L124 810L5-30ZM259 500C322 500 332 408 313 274C296 154 263 50 196 50C151 50 126 96 128 158L164 405C180 462 214 500 259 500Z
看看长啥样。
可以看到,这个 b 水平方向是反的。因为返回的路径是右手坐标系的(y 向下),而 svg 是左手坐标系(y 向上)。
如果给它 y 方向翻转一下,就能得到正确的渲染结果。
可以看到,glyph 路径的位置是基于 基线(baseline) 对齐的。
另外,如果一个字符(如 emoji)在字体的字符集中找不到,opentype 会给你返回一个 notdef 字符,通常在字体字符集的第一位。
通常我们会亲切地称之为 豆腐块(tofu),因为通常都是一个空心的矩形方块,表示该 glyph 缺失。
如果没有做字体回退,并应用字体自身的豆腐块,会变成下面这样子。
不同字体的豆腐块也是各有特色。
glyph 的宽高
对于 glyph 的宽高,我和 Figma 一样,选择行高 lineHeight 和 glyph 的 advanceWidth 作为 glyph 的宽高。
有些软件是用最小包围盒的,比如 Affinity。不过进行文本编辑时,选区的高度还是会用 lineHeight。
我们需要用到字体的 hhea 表的信息。
-
font.tables.hhea.ascender:基线到最高字符顶部距离(正数,如 970)
-
font.tables.hhea.descender:基线到最低字符底部的距离(负数,如 -230)
-
font.tables.hhea.lineGap:行高补偿值(如 0)。
一个 font 对应的默认行高(行高值为 auto)的值的计算公式如下:
const lineHeight = ascender - descender + lineGap;
glyph 大概长这样,我们吧 linecap 分别往两边扩展一下,就是字体的 默认行高 了。
当然你也可以自行设置行高,这样的话可以忽略掉 linecap,让 ascender 和 descender 往两边等距地移动,直到高度等于设置的行高为止。
lineHeight 会作为 glyph 的高度了。glyph 的宽度就是 advanceWidth,直接读就好。
对于示例中的 “得意黑” 字体,默认行高可以算一下 970 -(-230) + 0,即 1200。
映射到字体大小
读者可能奇怪了,这个行高 1200 这个是啥意思?单位是什么?
其实它的单位是 font units(字体单位)。
字体的 head 有一个名为 unitsPerEm 的属性,是 字体设计坐标系的缩放基准,表示 1 em 下包含的单位数。
(所谓 em 只是个相对单位,我们可以替换为自己需要的单位,比如 px、mm)
字体的所有信息需要除以 unitsPerEm,才能拿到相对于字体的比值。
对于字号为 12 px 的得意黑字体,其行高就是 1200 / 1000,得到 1.2,然后乘以字号值 12,得到默认行高为 14.399999999999999 (px)。
如果是 Figma 的话,会再四舍五入为整数,即 14px。(Figma 会尽量让图形的宽高为整数)
glyph 的路径、字宽 advanceWidth 这些都一样使用了 font units 单位,需要除以 unitsPerEm 并乘以字号大小。
const renderSize = size * fontSize / unitsPerEm;
SVG 渲染示例
下面写个简单的 SVG 渲染示例。
首先加载字体,并拿到一些必要的字体信息。
const font = await opentype.load('./SourceHanSansCN-Regular.otf');
// 字体各种参数,单位都是 fontUnits
const unitsPerEm = font.unitsPerEm;
const ascender = font.ascender;
const descender = font.descender;
const lineGap = font.tables.hhea.lineGap;
// 默认行高
const defaultLineHeight = ascender - descender + lineGap;
因为 SVG 的坐标系和 glyph 的不同,所以先要 y 轴翻转一下。
glyph 是对齐 baseline 的,所以要 y 方向移动 ascender 距离,因为还有个 lineGap 的行高补偿会往两侧补偿,所以需要再移动 lineGap 的一半距离。
如果 lineHeight 不用默认的,自己设置,则需要求出 defaultLineHeight 的 lineHeight 的差值 padding,移动 padding 的一半距离。
最后 除以 unitsPerEm,再乘以字号 fontSize。
let lineHeight = defaultLineHeight;
let halfPadding = 0;
if (!attrs.useDefaultLineHeight) {
lineHeight = attrs.lineHeight / (attrs.fontSize / unitsPerEm);
halfPadding = (lineHeight - defaultLineHeight) / 2;
}
// unit 转为 px,且左上角对齐原点,需要应用的矩阵
const matrix = new Matrix()
.scale(1, -1)
.translate(0, ascender + lineGap / 2 + halfPadding)
.scale(fontSize / unitsPerEm, fontSize / unitsPerEm);
如果排版多个 glyph,使它们从左往右排列,需要额外在 x 方向移动对应 累加的 advanceWidth 距离。
下面是使用 SVG 渲染的完整代码:
import { SVG } from'@svgdotjs/svg.js';
import GUI from'lil-gui';
import opentype from'opentype.js';
import { Matrix } from'pixi.js';
import fontUrl from'./SmileySans-Oblique.otf';
const attrs = {
fontSize: 48,
lineHeight: 48,
// 使用默认行高
useDefaultLineHeight: true,
text: 'b',
};
const draw = SVG().addTo('body').size(700, 300);
const font = await opentype.load(fontUrl);
// 字体各种参数
const unitsPerEm = font.unitsPerEm;
const ascender = font.ascender;
const descender = font.descender;
const lineGap = font.tables.hhea.lineGap;
// 默认行高
const defaultLineHeight = ascender - descender + lineGap;
const main = () => {
// console.log(
// `${attrs.fontSize}px 字号下,默认行高为: ${
// defaultLineHeight * (attrs.fontSize / unitsPerEm)}px`,
// );
draw.clear();
const fontSize = attrs.fontSize;
const text = attrs.text;
const glyphs = font.stringToGlyphs(text);
let lineHeight = defaultLineHeight;
let halfPadding = 0;
if (!attrs.useDefaultLineHeight) {
lineHeight = attrs.lineHeight / (attrs.fontSize / unitsPerEm);
halfPadding = (lineHeight - defaultLineHeight) / 2;
}
// unit 转为 px,且左上角对齐原点,需要应用的矩阵
const matrix = new Matrix()
.scale(1, -1)
.translate(0, ascender + lineGap / 2 + halfPadding)
.scale(fontSize / unitsPerEm, fontSize / unitsPerEm);
const sceneGraph = draw.group().translate(20, 100);
let currentX = 0;
for (const glyph of glyphs) {
// 绘制 glyph 方块
sceneGraph
.rect(
(attrs.fontSize * glyph.advanceWidth!) / unitsPerEm,
(attrs.fontSize * lineHeight) / unitsPerEm,
)
.translate(currentX, 0)
.attr({
fill: 'none',
stroke: '#999',
strokeWidth: 1,
'vector-effect': 'non-scaling-stroke',
});
const glyphMatrix = matrix.clone().translate(currentX, 0);
// 绘制 glyph
sceneGraph.path(glyph.path.toPathData(100)).transform({
a: glyphMatrix.a,
b: glyphMatrix.b,
c: glyphMatrix.c,
d: glyphMatrix.d,
e: glyphMatrix.tx,
f: glyphMatrix.ty,
});
currentX += (attrs.fontSize * glyph.advanceWidth!) / unitsPerEm;
}
};
main();
const gui = new GUI({ width: 300 });
gui.add(attrs, 'fontSize', 10, 200).step(1);
gui.add(attrs, 'lineHeight', 10, 200).step(1);
gui.add(attrs, 'useDefaultLineHeight');
gui.add(attrs, 'text');
gui.onChange(main);
线上 demo
geo-play-nv7v.vercel.app/src/page/ty…
交互效果:
结尾
我是前端西瓜哥,关注我,学习更多文字渲染知识。
相关阅读,