图形编辑器开发:使用 opentype.js 解析字体并渲染文本

119 阅读8分钟

大家好,我是前端西瓜哥。

前段时间将图形编辑器的文字渲染做了下改造。

原来的文字渲染使用的是 Canvas 2d 的 fillText 方法,现在改成了用 opentype.js 解析字体获取字形(glyph)路径,然后去渲染这些路径。

suika 图形编辑器 github 地址:

github.com/F-star/suik…

线上体验:

blog.fstars.wang/app/suika/

今天我们来简单说说怎么解析字体并用 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 halfPadding0;
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(700300);

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 halfPadding0;
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(20100);

let currentX0;

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…

交互效果:

图片

结尾

我是前端西瓜哥,关注我,学习更多文字渲染知识。


相关阅读,

图形编辑器:基于 canvas的所见即所得文本编辑

opentype.js 使用与文字渲染