在 canvas 上画一段文字,需要知道它有多宽------换行要用、居中要用、加背景框要用。ctx.measureText() 看起来就是干这个的:
js
const w = ctx.measureText('Hello').width
然后你会发现,按这个宽度画出来的框,要么盖不住文字,要么留了一截空白,而且不同字体、不同浏览器上偏差还不一样。
这篇把偏差的来源逐个拆开,并给出能用的量法。
一、width 量的不是你以为的那个宽度
measureText().width 返回的是排版推进量(advance width),也就是"画完这段文字之后,画笔应该往右移多少"。
它和"这段文字的墨迹实际占了多宽"是两回事。差别来自两头的 side bearing------字形轮廓到字符盒边界之间的留白。
斜体的 f、花体的 Q、很多带装饰笔画的字符,墨迹会超出 推进量;而像空格、以及某些字体里的标点,墨迹远小于推进量。
所以如果你的目的是"给这段文字画一个刚好包住它的框",width 一定是错的。
二、正确的量法:actualBoundingBox
TextMetrics 上除了 width 还有六个字段,其中四个是你真正需要的:
js
const m = ctx.measureText(text)
// 墨迹的实际边界(相对于当前 textAlign / textBaseline 决定的原点)
const left = m.actualBoundingBoxLeft // 原点往左的距离(向左为正)
const right = m.actualBoundingBoxRight // 原点往右的距离
const asc = m.actualBoundingBoxAscent // 原点往上的距离
const desc = m.actualBoundingBoxDescent // 原点往下的距离
const inkWidth = left + right
const inkHeight = asc + desc
注意 actualBoundingBoxLeft 的符号很反直觉:它是"原点到左边界的距离",向左为正 。所以完整墨迹宽度是 left + right,不是 right - left。
拿它来画一个刚好贴合的框:
js
function drawTextWithBox(ctx, text, x, y, pad = 4) {
const m = ctx.measureText(text)
ctx.fillStyle = '#eef3ff'
ctx.fillRect(
x - m.actualBoundingBoxLeft - pad,
y - m.actualBoundingBoxAscent - pad,
m.actualBoundingBoxLeft + m.actualBoundingBoxRight + pad * 2,
m.actualBoundingBoxAscent + m.actualBoundingBoxDescent + pad * 2
)
ctx.fillStyle = '#123'
ctx.fillText(text, x, y)
}
另外两个 fontBoundingBoxAscent / Descent 量的是字体本身的上下界,和具体内容无关。做多行排版算行高时要用它,因为你希望每一行的行高一致,而不是随内容起伏。
三、字体没加载完,量出来的是回退字体
这是最容易中招的一条,而且症状很迷惑:刷新几次,有时候对有时候不对。
ctx.font = '16px MyFont' 这一行不会触发字体加载。如果这个字体还没下载完,canvas 会静默用回退字体渲染,你量到的是回退字体的宽度。等字体加载完,页面上的 DOM 文字会重排,但 canvas 上已经画好的内容不会自己重画。
必须显式等:
js
await document.fonts.load('16px MyFont') // 注意:字号也要写,它是 font shorthand 的一部分
await document.fonts.ready // 等所有 pending 的字体
ctx.font = '16px MyFont'
// 现在再 measureText
document.fonts.load() 的参数必须是合法的 font shorthand,只写字体名不带字号会直接失败且不报错。
还有一个更隐蔽的变种:字体加载完了,但你要画的字符在这个字体里没有字形。浏览器会对这些字符单独回退到别的字体,于是同一段文字里一部分用 A 字体、一部分用 B 字体,宽度自然对不上你的预期。中英混排、带生僻字或 emoji 的文本最容易遇到。
四、DPR:量和画必须在同一个坐标系里
高分屏上通常这么设 canvas:
js
const dpr = devicePixelRatio || 1
canvas.width = cssW * dpr
canvas.height = cssH * dpr
canvas.style.width = cssW + 'px'
canvas.style.height = cssH + 'px'
ctx.scale(dpr, dpr)
ctx.scale() 之后,measureText 返回的是当前变换下的用户坐标 ,也就是 CSS 像素------和你 fillText 用的坐标系一致,所以不需要额外换算。
但如果你先量后 scale ,或者在两个不同变换状态下量和画,就会差一个 dpr 倍。规矩很简单:量和画之间不要动变换矩阵。
另一个坑是设置 canvas.width 会重置整个上下文 ------变换、font、fillStyle 全部回到默认值。所以 resize 之后必须重新 ctx.scale() 和重设 ctx.font,很多"改了窗口大小文字就乱了"都是这个原因。
五、逐字符宽度相加 ≠ 整串宽度
想做换行,很自然会写成"一个字一个字量,加到超出宽度就断开"。这个做法在两种情况下会错。
字距调整(kerning)。 "AV" 这两个字母连写时,字体会告诉排版引擎把它们靠近一点。分开量再相加,比实际连写要宽。
连字(ligature)。 某些字体里 "fi" 会被渲染成一个连字字形,宽度小于 f 加 i。
正确做法是量整个候选子串,用二分或者逐步扩展:
js
function wrapText(ctx, text, maxWidth) {
const lines = []
let line = ''
for (const ch of text) { // 用 for...of,别用索引,否则会切断代理对
const test = line + ch
if (ctx.measureText(test).width > maxWidth && line) {
lines.push(line)
line = ch
} else {
line = test
}
}
if (line) lines.push(line)
return lines
}
这个版本每加一个字符就量一次,字符多的时候会慢。优化方向是先按一个粗略估计切段,再用二分在段边界附近微调,而不是逐字符。
注意 for...of 那一行:字符串的索引遍历会把 emoji 和某些字符的代理对 拆成两半,量出来是两个乱码的宽度,画出来是两个方块。for...of 按码点遍历能避免这个问题------但它仍然不能正确处理由多个码点组成的字素簇(比如带肤色修饰符的 emoji、某些组合音标)。真要严谨得用 Intl.Segmenter:
js
const seg = new Intl.Segmenter('zh', { granularity: 'grapheme' })
for (const { segment } of seg.segment(text)) { /* segment 是一个完整字素 */ }
六、还有几个零碎的坑
空格在行尾。 换行时行尾那个空格通常应该丢掉,否则右对齐时会看起来没对齐。但行首的空格(比如缩进)要保留,得分情况处理。
textBaseline 改变的是原点含义。 默认是 alphabetic(字母基线),不是顶部也不是中线。如果你按 y 是"文字顶部"来布局,所有文字都会往上偏一个 ascent。要按顶部布局就显式设 ctx.textBaseline = 'top'。
换行符不会自动生效。 fillText 遇到 \n 不会换行,它会画成一个空白或者直接忽略。必须自己按行拆开,逐行调用 fillText。
letterSpacing 和 wordSpacing 是新属性。 它们会影响 measureText 的结果,但支持度还不完整,用之前要检测。
同一段代码在 Chrome 和 Safari 上量出来会差零点几像素。 这是字体光栅化和 hinting 的差异,改不了。如果你的布局对亚像素敏感,需要留容差,不要用 === 去比较宽度。
七、说点做不到的
canvas 里没有真正的文本布局引擎。 它只能画单行文字。双向文本(中英混排里夹阿拉伯语/希伯来语)、竖排、复杂脚本的整形(印地语、泰语)这些,canvas 的 fillText 处理得并不完整,不同浏览器差异也大。真需要复杂排版,要么用 DOM,要么引入专门的排版库。
量不到"渲染后"的真实像素。 actualBoundingBox 是字形轮廓的边界,不包含抗锯齿溢出的那一两个像素,也不包含描边宽度。用 strokeText 时框要自己再放大半个 lineWidth。
性能上它不便宜。 measureText 会触发一次真实的字形整形。在逐帧动画里对长文本反复量,很容易成为瓶颈。字符宽度可以缓存------但只在字体、字号、字距都不变的前提下有效,缓存键得包含 ctx.font 的完整值。
最后
把能直接用的几条收一下:
- 要"包住文字的框"用
actualBoundingBox*,不要用width。 measureText之前一定await document.fonts.load(完整的 font shorthand)。- 量和画之间不要动变换矩阵;
canvas.width一改,font和变换全没了,记得重设。 - 换行量整串不量单字,遍历用
for...of或Intl.Segmenter。 - 跨浏览器留亚像素容差。
我在 forxi.cn 上做图片水印和文字相关的功能时,这些基本都撞过。上面的代码是照着思路重写的示意实现,不涉及具体产品逻辑,可以直接拿去改。