多行文字与度量
Canvas 没有 DOM 的换行、bidi 嵌入、选区。fillText(text, x, y, maxWidth?) 的 maxWidth 是 水平压缩字形,不是自动折行。排版要自己做;做不好就不要用 Canvas 承载正文。
1. 度量必须在设完 font 之后
export interface FontSpec {
sizePx: number;
family: string;
weight?: string;
style?: string;
}
export function applyFont(
ctx: CanvasRenderingContext2D,
spec: FontSpec,
): void {
const style = spec.style ?? "normal";
const weight = spec.weight ?? "400";
ctx.font = `${style} ${weight} ${spec.sizePx}px ${spec.family}`;
ctx.textAlign = "left";
ctx.textBaseline = "alphabetic";
ctx.direction = "ltr";
}
export function measureRun(
ctx: CanvasRenderingContext2D,
text: string,
): { width: number; ascent: number; descent: number } {
const m = ctx.measureText(text);
return {
width: m.width,
ascent: m.actualBoundingBoxAscent,
descent: m.actualBoundingBoxDescent,
};
}
m.width 是 advance(光标要走多远),actualBoundingBox* 是墨水盒。斜体、字母 j、中文标点悬挂,两者差一截。命中盒用墨水盒,排下一个字用 advance。
系统字体在 macOS / Windows / Android 上 shaping 不同,不要把 Chrome 录的 width 写进快照测试。需要稳定测量:自托管字体 + document.fonts.load 完成后再 measureText。
export async function readyFont(family: string, sample = "汉字测试Ab"): Promise<void> {
await document.fonts.load(`16px ${family}`, sample);
if (!document.fonts.check(`16px ${family}`, sample)) {
throw new Error(`font not ready: ${family}`);
}
}
2. 折行:按码元不够,CJK 要按字形簇
export function wrapLines(
ctx: CanvasRenderingContext2D,
text: string,
maxWidth: number,
): string[] {
const segmenter = new Intl.Segmenter("zh", { granularity: "grapheme" });
const glyphs = [...segmenter.segment(text)].map((s) => s.segment);
const lines: string[] = [];
let current = "";
for (const g of glyphs) {
if (g === "\n") {
lines.push(current);
current = "";
continue;
}
const next = current + g;
if (current.length > 0 && ctx.measureText(next).width > maxWidth) {
lines.push(current);
current = g;
} else {
current = next;
}
}
if (current.length > 0 || text.endsWith("\n")) lines.push(current);
return lines;
}
这是贪心折行,不是 Knuth-Plass。英文单词中间会切开——要在空格/CJK 边界优先断,再退回强制断。
不要用 [...text] 切 emoji:会把 surrogate 和 ZWJ 序列切碎,出现豆腐。必须 Intl.Segmenter 或 graphemer 一类。
letterSpacing 写入 context 后 measureText 才含间距。先量后设,行宽会偏。
3. 绘制与命中
export interface TextBlock {
x: number;
y: number;
lineHeight: number;
lines: string[];
}
export function fillTextBlock(
ctx: CanvasRenderingContext2D,
block: TextBlock,
): void {
let baseline = block.y;
for (const line of block.lines) {
ctx.fillText(line, block.x, baseline);
baseline += block.lineHeight;
}
}
export function hitTextBlock(
ctx: CanvasRenderingContext2D,
block: TextBlock,
px: number,
py: number,
): { line: number; offset: number } | undefined {
const line = Math.floor((py - (block.y - ctx.measureText("M").actualBoundingBoxAscent)) / block.lineHeight);
if (line < 0 || line >= block.lines.length) return undefined;
const content = block.lines[line] ?? "";
let acc = 0;
const segmenter = new Intl.Segmenter("zh", { granularity: "grapheme" });
let offset = 0;
for (const { segment } of segmenter.segment(content)) {
const w = ctx.measureText(segment).width;
if (px < block.x + acc + w / 2) {
return { line, offset };
}
acc += w;
offset += segment.length;
}
return { line, offset: content.length };
}
Caret 放在 canvas 里极难做 IME。生产编辑器:几何和静态排版用 Canvas,正在输入的那一行用绝对定位的 <textarea>。见 Overlay。
4. 适用 / 不适用
适用:地图注记、少量标签、导出海报上的标题、数据点旁的值。
不适用:可选中的文章、富文本协作、需要读屏的说明。那是 DOM/SVG。drawFocusIfNeeded 只画焦点环,不提供 accessible name。
5. 失败形态
| 症状 | 原因 |
|---|---|
| maxWidth 把字压扁 | 当成了换行参数 |
| emoji 变两半 | 按 UTF-16 切 |
| 字体闪一下宽度跳 | 没等 document.fonts |
| 点选总偏上 | 用了 alphabetic baseline 当盒子顶 |
| 中文行比英文「松」 | 只用 width 没看 ink box,lineHeight 按西文行高 |
权威资料
核对日期:2026-08-26