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 会重置整个上下文 ------变换、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 的完整值。

最后

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

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

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

相关推荐
溪语流沙33 分钟前
【每天一个CSS | Day19】流光文字与一笔写成的手写签名
前端·javascript·css
逐光者93340 分钟前
STM32——SPI 屏 · Flash · 高速
java·前端·stm32
To_OC1 小时前
同样是让 AI 写全栈,为什么别人一句话就够了
前端·前端框架·next.js
CDwenhuohuo1 小时前
electron pc项目打包成桌面端
前端·javascript·electron
前端大斗师1 小时前
「大于 1000」把 1000 元那单也算进去了:我在 Vue3 订单页对了 10 句话
前端·人工智能·typescript·大模型·原力计划
Jmyd01232 小时前
用 Web VR 引擎搭建 3D 虚拟展厅:从素材上云到多端实时渲染的完整数据链路
前端·3d·vr·三维数字化
刘天远3 小时前
Agent成本核算实现:事件表、状态分布与Python归集
前端·数据库·人工智能·python
欣欣之王来了3 小时前
开发环境准备:Node.js、npm、VS Code安装配置
前端·学习·架构·项目·vue3教程
xiaolinudao1233 小时前
将多个 Excel 表格中的数据合并到单个表中|6 种实现方案全解析
java·前端·excel
LRL_3 小时前
跨平台 Node 模块安装指南:如何利用 pnpm/npm 配置`supportedArchitectures` 锁定平台依赖
前端·npm·node.js