跳到主要内容

中文字体加载

典型症状是:字体明明已经加载完,画布上的字也换成了新字体, 但换行位置、行数、垂直位置还是错的,Transformer 的框也对不上文字。 layer.draw() 调多少次都没用。

原因是 Konva.Text 的排版结果在创建那一刻就算好了,字体晚到并不会让它重新算。

用法​

最稳的做法是先等字体,再创建文字节点:

const FONT = '"Source Han Sans SC"';
const content = '双十一大促 · 全场 5 折起,满 300 减 50';

async function createTitle(layer) {
// 第二个参数传实际要显示的文字,而不是随便一个「中」字
await document.fonts.load(`32px ${FONT}`, content);

const title = new Konva.Text({
text: content,
fontFamily: `${FONT}, "PingFang SC", "Microsoft YaHei", sans-serif`,
fontSize: 32,
width: 360,
});
layer.add(title);
return title;
}

document.fonts.load() 的第二个参数决定要下载字体的哪些部分。 中文 Web 字体几乎都是按 unicode-range 切成几十上百个分片的, 浏览器只下载样本文字涉及的分片。只传一个 '中',就只保证了「中」所在的那一片, 其余的字照样是回退字体。

为什么已有节点不会更新​

Konva.Text 的换行结果(每一行放哪些字、各行多宽)和基线偏移, 由内部的 _setTextData() 计算。它只在两个时机运行: 构造时,以及 text、fontFamily、fontSize、width 等排版相关属性发生变化时。

字体下载完成不在其中。测量时用的是当时可用的字体,也就是回退字体, 结果就一直留在节点上。之后的 draw() 会用新字体画字形, 但沿用的仍是按回退字体算出的换行。

本站用本机 Chrome 实测(konva@10.7.0,宽 200、20px,文字为 'Konva 10.7 WWW mmm iii 中英混排 Transformer width 测试 lorem ipsum dolor', 字体服务端延迟 800ms 返回):

节点行数基线偏移
字体加载前创建46.5
同一节点,字体加载完成后46.5
字体加载完成后新建58

已有节点要强制重排,不能把属性设成原来的值——Node#_setAttr 发现新旧值相等会直接返回, 不触发任何事件,实测 t.fontFamily(t.fontFamily()) 毫无作用。要先换成别的值再换回来:

document.fonts.ready.then(() => {
stage.find('Text').forEach((t) => {
const family = t.fontFamily();
// 设成相同的值不会触发重排,先换一个值再换回来
t.fontFamily('');
t.fontFamily(family);
});
});

实测这样处理后,已有节点的行数与基线和新建节点完全一致。

同一组实测里还有一个现象:纯中文段落在字体加载前后换行完全相同。 几乎所有中文字体的汉字都是等宽的全角字形,回退字体和目标字体量出来一样宽。 问题集中在中英混排、数字、标点,以及依赖基线的 verticalAlign 和 Transformer 包围盒上。 所以只用纯中文测试,很容易误以为没有这个问题。

导出图片前等字体​

字体只在 canvas 里使用、页面 DOM 没用到时,Chrome 仍会在第一次绘制时开始下载, 但这一帧用的是回退字体。Konva 不监听字体加载事件,所以不会自动重绘, 画面会一直停在回退字体的样子,直到别的原因触发下一次绘制。

导出同理:toDataURL() 在字体就绪前调用,导出图里就是回退字体。

await document.fonts.load(`32px ${FONT}`, allTextOnStage);
// 如果节点是在字体到达前创建的,先按上一节重排
const url = stage.toDataURL({ pixelRatio: 2 });

pixelRatio 的取值见高清导出。 在服务端生成图片时字体要用后端库注册,见在 Node.js 中使用 Konva。

国内环境注意事项​

体积。一套完整的中文字体通常有数 MB 到十几 MB,直接当 Web 字体引用, 移动网络下要等好几秒,这几秒里的画布全是回退字体。要么只保留用到的字做子集化, 要么切成 unicode-range 分片按需加载。只有少量固定文案(比如海报模板标题)时, 子集化最划算。

来源。Google Fonts 在国内访问不稳定,不要依赖 fonts.googleapis.com。 字体文件放在自己的服务器或国内 CDN 上;跨域引用字体时服务端必须返回 Access-Control-Allow-Origin,否则浏览器直接拒绝加载,画布上始终是回退字体。

版权。微软雅黑、苹方等系统字体的授权不允许作为 Web 字体分发, 把它们的字体文件放到服务器上就是侵权。可以免费商用的开源选择有 思源黑体 / Noto Sans SC、思源宋体、霞鹜文楷等(均为 SIL OFL 授权), 具体以各字体随附的授权文本为准。

回退链。即使用了 Web 字体,fontFamily 也要写完整的回退链, 例如 '"Source Han Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif', 保证字体加载失败时每个平台都能落到一个实际存在的中文字体上,而不是宋体。

常见问题​

为什么只有英文和数字的位置不对?​

因为汉字在几乎所有中文字体里都是等宽全角,回退字体和目标字体测出来一样宽,换行不变; 拉丁字母、数字和标点的字宽则因字体而异。上面的实测里,纯中文段落前后完全一致, 中英混排则从 4 行变成 5 行。

document.fonts.ready 之后还是不对?​

document.fonts.ready 只等已经开始下载的字体。一个 canvas 还没画过、 DOM 也没用到的字体,根本还没开始下载,ready 会立刻兑现。 要主动触发下载,用 document.fonts.load(font, text)。

另一种可能是字体确实加载了,但文字节点是之前创建的,排版没有重算——见上面的强制重排。

能不能监听字体加载,自动重排?​

可以监听 document.fonts 的 loadingdone 事件,在回调里执行上面的重排循环:

let timer = 0;
document.fonts.addEventListener('loadingdone', () => {
cancelAnimationFrame(timer);
timer = requestAnimationFrame(() => {
stage.find('Text').forEach((t) => {
const family = t.fontFamily();
t.fontFamily('');
t.fontFamily(family);
});
});
});

要做合并:分片字体每下载完一批分片就触发一次,不合并的话一屏文字会被重排几十遍。