课程 16 - 文本的高级特性
在上一节课中,我们介绍了基于 SDF 的文本渲染的原理,也尝试使用了 ESDT 和 MSDF 提升渲染质量,另外也提到过 CanvasKit 相比 Canvas 提供的文本高级绘制特性。
在本节课中,我们首先会来看看 SDF 之外的绘制方式,然后将讨论并尝试实现这些特性:装饰线、阴影、文本跟随路径,最后文本不光要能渲染,也要有良好的交互,我们讲讨论输入框、文本选中以及 A11y 这些话题。
首先我们来看看除了 SDF 之外,还有哪些文本渲染方式。
使用贝塞尔曲线渲染文本
使用 Figma 的导出 SVG 功能可以发现,它的文本也是使用 Path 渲染的。如果不考虑渲染性能和 CJK 字符,使用贝塞尔曲线渲染文本确实是不错的选择。为了得到字符的矢量信息,在浏览器环境可以使用:
- opentype.js
- use-gpu 使用的是基于 ab-glyph 封装的 use-gpu-text
- 越来越多的应用使用 harfbuzzjs,详见:State of Text Rendering 2024。例如 font-mesh-pipeline 就是一个简单的示例
下面我们展示使用 opentype.js 和 harfbuzzjs 渲染文本的示例,他们都支持 ttf
格式的字体文件。
opentype.js
opentype.js 提供了 getPath
方法,给定文本内容、位置和字体大小,就可以完成 Shaping 并获取 SVG path-commands,其中包含 M
、L
、C
、Q
、Z
命令,我们将它转换为 Path 的 d
属性。
opentype.load('fonts/Roboto-Black.ttf', function (err, font) {
const path = font.getPath('Hello, World!', 0, 0, 32); // x, y, fontSize
// convert to svg path definition
});
harfbuzzjs
首先初始化 harfbuzzjs WASM,这里使用 Vite 的 ?init 语法。然后加载字体文件,并创建 font 对象。
import init from 'harfbuzzjs/hb.wasm?init';
import hbjs from 'harfbuzzjs/hbjs.js';
const instance = await init();
hb = hbjs(instance);
const data = await (
await window.fetch('/fonts/NotoSans-Regular.ttf')
).arrayBuffer();
blob = hb.createBlob(data);
face = hb.createFace(blob, 0);
font = hb.createFont(face);
font.setScale(32, 32); // 设置字体大小
然后创建一个 buffer 对象,并添加文本内容。我们之前提过 harfbuzz 并不处理 BiDi,因此这里需要手动设置文本方向。最后调用 hb.shape 方法进行 Shaping 计算。
buffer = hb.createBuffer();
buffer.addText('Hello, world!');
buffer.guessSegmentProperties();
// TODO: use BiDi
// buffer.setDirection(segment.direction);
hb.shape(font, buffer);
const result = buffer.json(font);
此时我们就得到字形数据了,随后可以使用 Path 绘制
result.forEach(function (x) {
const d = font.glyphToPath(x.g);
const path = new Path({
d,
fill: '#F67676',
});
});
TeX math rendering
我们可以使用 MathJax 来渲染 TeX 数学公式,将公式转换为 SVG 后,再使用 Path 渲染。这里我们参考 LaTeX in motion-canvas 的做法,得到 SVGElement:
const JaxDocument = mathjax.document('', {
InputJax: new TeX({ packages: AllPackages }),
OutputJax: new SVG({ fontCache: 'local' }),
});
const svg = Adaptor.innerHTML(JaxDocument.convert(formula));
const parser = new DOMParser();
const doc = parser.parseFromString(svg, 'image/svg+xml');
const $svg = doc.documentElement;
再使用 课程 10 - 从 SVGElement 到序列化节点 中介绍的方法将 SVGElement 转换为图形,添加到画布中。
const root = await deserializeNode(fromSVGElement($svg));
装饰线
阴影
Canvas2D 提供了 shadowBlur 属性,CanvasKit 在增强的段落样式中提供了 shadows
属性。
const paraStyle = new CanvasKit.ParagraphStyle({
textStyle: {
shadows: (shadows || []).map(({ color, offset, blurRadius }) => {
return {
color: color2CanvaskitColor(CanvasKit, color),
offset,
blurRadius,
};
}),
},
});
Pixi.js 提供了 DropShadowFilter 来实现阴影效果,但我们可以不使用后处理手段,而是直接在 SDF 中实现阴影效果。使用 shadowOffset
和 shadowBlurRadius
来控制采样 SDF 纹理的偏移和模糊程度。
float shadowDist = texture(SAMPLER_2D(u_Texture), v_Uv - shadowOffset).a;
dropShadowColor.a *= smoothstep(0.5 - shadowSmoothing, 0.5 + shadowSmoothing, shadowDist);
outputColor = mix(dropShadowColor, outputColor, outputColor.a);
文本跟随路径
在 Figma 社区中,很多用户都在期待这个特性,例如:Make text follow a path or a circle
在 SVG 中可以通过 textPath 实现,详见:Curved Text Along a Path
<path
id="curve"
d="M73.2,148.6c4-6.1,65.5-96.8,178.6-95.6c111.3,1.2,170.8,90.3,175.1,97"
/>
<text width="500">
<textPath xlink:href="#curve"> Dangerous Curves Ahead </textPath>
</text>
Skia 提供了 MakeOnPath
方法,详见 Draw text along a path:
const textblob = CanvasKit.TextBlob.MakeOnPath(text, skPath, skFont);
canvas.drawTextBlob(textblob, 0, 0, textPaint);
在 Mapbox 中沿道路河流放置 label 是很常见的场景,详见 Map Label Placement in Mapbox GL

更友好的交互方式
输入框
目前我们只实现了文本的绘制,实际在应用中,文本输入框是必不可少的。下图来自 Figma,可以看到使用了原生的 <textarea>
元素定位在画布上,当双击 Text 时,会展示输入框:

在 excalidraw 中也采用了这种方式:https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/element/textWysiwyg.tsx#L728
我们也增加一个 <ic-textarea>
元素:
@customElement('ic-textarea')
export class Textarea extends LitElement {
@query('textarea')
editable: HTMLTextAreaElement;
render() {
return html`<textarea></textarea>`;
}
}
文本选中
特殊效果
加载 Web 字体
对于使用 Canvas2D API 生成 SDF 的方案,只需要使用 webfontloader 先加载字体,再使用 fontFamily
指定字体即可。
import WebFont from 'webfontloader';
WebFont.load({
google: {
families: ['Gaegu'], // 指定字体
},
active: () => {
const text = new Text({
x: 150,
y: 150,
content: 'Hello, world',
fontFamily: 'Gaegu', // 指定字体
fontSize: 55,
fill: '#F67676',
});
},
});
Material Design on the GPU
Material Design on the GPU 中介绍了一种基于 SDF 文字的材质效果,使用法线贴图配合光照实现墨迹在纸张表面的晕染效果。我们不用考虑光照,直接使用 simplex noise 来实现,叠加多个吸收效果:
import { simplex_2d } from './simplex-2d';
import { aastep } from './aastep';
export const absorb = /* wgsl */ `
${aastep}
${simplex_2d}
float absorb(float sdf, vec2 uv, float scale, float falloff) {
float distort = sdf + snoise(uv * scale) * falloff;
return aastep(0.5, distort);
}
`;