Canvas 文字排版:measureText 量出来的宽度为什么总是不对

在 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重置整个上下文 ------变换、fontfillStyle 全部回到默认值。所以 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

letterSpacingwordSpacing 是新属性。 它们会影响 measureText 的结果,但支持度还不完整,用之前要检测。

同一段代码在 Chrome 和 Safari 上量出来会差零点几像素。 这是字体光栅化和 hinting 的差异,改不了。如果你的布局对亚像素敏感,需要留容差,不要用 === 去比较宽度。

七、说点做不到的

canvas 里没有真正的文本布局引擎。 它只能画单行文字。双向文本(中英混排里夹阿拉伯语/希伯来语)、竖排、复杂脚本的整形(印地语、泰语)这些,canvas 的 fillText 处理得并不完整,不同浏览器差异也大。真需要复杂排版,要么用 DOM,要么引入专门的排版库。

量不到"渲染后"的真实像素。 actualBoundingBox 是字形轮廓的边界,不包含抗锯齿溢出的那一两个像素,也不包含描边宽度。用 strokeText 时框要自己再放大半个 lineWidth

性能上它不便宜。 measureText 会触发一次真实的字形整形。在逐帧动画里对长文本反复量,很容易成为瓶颈。字符宽度可以缓存------但只在字体、字号、字距都不变的前提下有效,缓存键得包含 ctx.font 的完整值。

最后

把能直接用的几条收一下:

  1. 要"包住文字的框"用 actualBoundingBox*,不要用 width
  2. measureText 之前一定 await document.fonts.load(完整的 font shorthand)
  3. 量和画之间不要动变换矩阵;canvas.width 一改,font 和变换全没了,记得重设。
  4. 换行量整串不量单字,遍历用 for...ofIntl.Segmenter
  5. 跨浏览器留亚像素容差。

我在 forxi.cn 上做图片水印和文字相关的功能时,这些基本都撞过。上面的代码是照着思路重写的示意实现,不涉及具体产品逻辑,可以直接拿去改。

相关推荐
夏幻灵1 小时前
从零理解 Redux:从 state、action、reducer 到 Redux Toolkit 与异步请求
前端
烈风逍遥1 小时前
第四篇:AI 模块架构设计:多 Provider 切换、RAG 知识库与 Agent 编排
前端·后端·架构
用户80806181436931 小时前
JavaScript 面向对象入门到精通
前端·javascript
一条溺水的鱼1 小时前
一次讲透 JS 闭包 —— 概念、原理、应用和内存泄漏
javascript·面试
吠品1 小时前
STM32最小系统板引脚梳理与配置实操
前端·javascript·vue.js
曹一二2 小时前
前端性能优化场景题:图片懒加载 + 大数据渲染
前端
GISer_Jing2 小时前
Come on,工作总结
前端·ai·前端框架
Hilaku2 小时前
Sass 和 Less 在 2026 年彻底多余了吗?
前端·javascript·程序员
右耳朵猫AI2 小时前
Node.js周刊2026W38 | 进程中断缺陷修复、Copilot 迁至 Rust、Node 新增 VFS
javascript·后端·node.js