4. 字体度量与排版:行高、基线、字间距、字偶距的概念与计算
做 UI 的同学,十有八九都被「文字对不齐」折磨过。明明 x 和 y 坐标都设对了,文字就是偏上或偏下。这背后其实是一套严谨的度量系统在起作用。今天我们就把它彻底讲透。
4.1 基线:一切排版的基准线
先问一个问题:一行文字,到底「坐」在哪条线上?
答案是基线(Baseline)。它是绝大多数拉丁字母「坐」的那条线。你看字母 A、B、C,它们的底部都稳稳地落在基线上。而 g、j、p、q、y 这些字母,有一部分会「沉」到基线以下,这部分叫下降部(Descender)。
我刚开始做字体渲染时,以为文字框的底部就是基线。结果多行文本一拼,行与行之间的空隙忽大忽小,丑得没法看。后来才明白,基线才是排版真正的锚点。
| 术语 | 说明 |
|---|---|
| 基线(Baseline) | 字母底部对齐的参考线 |
| 上升线(Ascender Line) | 字母顶部最高处(如 h、l 的顶端) |
| 下降线(Descender Line) | 字母底部最低处(如 g、p 的底部) |
| x-高度线(x-Height Line) | 小写字母 x 的高度,决定字体「视觉大小」 |
核心要点:在 Canvas 或 SVG 中绘制文字时,fillText(text, x, y) 的 y 坐标,指的就是基线的位置,不是文字框的顶部。
4.2 行高:行与行的呼吸空间
行高(Line Height)指的是相邻两行基线之间的距离。它决定了文本的「呼吸感」。
计算公式很简单:
行高 = 字体大小 × 行高倍数
// 例如:字体 16px,行高倍数 1.5
lineHeight = 16 × 1.5 = 24px
但这里有个坑。行高多出来的部分,会平均分配到文字的上方和下方。这部分叫「半行距」(Half-leading)。
半行距 = (行高 - 字体大小) / 2
// 接上例
halfLeading = (24 - 16) / 2 = 4px
也就是说,文字上方有 4px 空白,下方也有 4px 空白。这个分配方式,不同浏览器和渲染引擎可能略有差异。我曾经在做一个跨平台阅读器时,就因为 iOS 和 Android 对半行距的处理不同,导致段落间距对不齐。最后只能手动计算并补偿。
我的建议:在动画刷新场景中,行高最好用固定像素值,而不是倍数。因为动画过程中,字体大小可能会变化,用倍数会导致行高也跟着变,容易产生抖动。
4.3 字间距:字母之间的均匀呼吸
字间距(Letter Spacing / Tracking)是字母与字母之间的统一间距。它影响的是整段文字的「松紧度」。字间距为正,文字变疏;为负,文字变密。
在 CSS 中对应 letter-spacing,在 Canvas 中需要手动计算:
// Canvas 中实现字间距
ctx.letterSpacing = '2px'; // 部分浏览器支持
// 更通用的做法:逐个字符绘制
const text = 'Hello';
const spacing = 2; // 像素
let x = 100;
for (let char of text) {
ctx.fillText(char, x, y);
x += ctx.measureText(char).width + spacing;
}
注意,字间距是加在字符宽度之外的。如果字符本身宽度是 10px,字间距 2px,那么每个字符占 12px 空间。
避坑指南:我曾经在做一个跑马灯动画时,直接用 measureText 测量整段文字宽度,然后除以字符数来算平均宽度。结果中英文混排时,宽度差异巨大,动画速度忽快忽慢。后来改成逐字符测量 + 累加,才解决问题。
4.4 字偶距:专业排版的灵魂
字偶距(Kerning)和字间距不同。字间距是全局统一的,而字偶距是针对特定字符对的微调。
举个例子:大写字母 A 和 V 放在一起,如果按标准字间距排列,A 的右上角和 V 的左下角之间会有一个明显的「空洞」。字偶距就是把这个空洞缩小,让 A 和 V 靠得更近,视觉上更均匀。
| 对比项 | 字间距(Tracking) | 字偶距(Kerning) |
|---|---|---|
| 作用范围 | 整段文字 | 特定字符对 |
| 是否自动 | 手动设置 | 字体文件自带 |
| 典型场景 | 标题、按钮文字 | Logo、正文排版 |
| 性能影响 | 几乎无 | 逐字符测量时有开销 |
在 Canvas 中,默认是不开启字偶距的。你需要手动设置:
ctx.letterSpacing = 'normal'; // 部分浏览器支持开启字偶距
// 或者用更底层的方式
const text = 'AV';
let x = 100;
for (let i = 0; i < text.length; i++) {
ctx.fillText(text[i], x, y);
// 获取当前字符的宽度,包括字偶距调整
const charWidth = ctx.measureText(text[i]).width;
x += charWidth;
}
但注意,measureText 返回的宽度是否包含字偶距,取决于浏览器实现。在 Chrome 中,默认是包含的。在 Safari 中可能不同。嗯,这里要小心。
4.5 知识体系总览
下面这张图,把字体度量的核心概念串起来了。你可以看到,所有度量都围绕基线展开,行高决定行间距,字间距和字偶距决定字符间距。
4.6 实战:在 Canvas 中精确排版
说了这么多理论,来点实际的。假设我们要在 Canvas 上绘制一段多行文字,每行居中对齐,行高固定为 30px。
function drawText(ctx, text, x, y, lineHeight) {
const lines = text.split('\n');
let currentY = y;
for (let line of lines) {
// 计算半行距偏移,让文字垂直居中
const metrics = ctx.measureText(line);
const fontHeight = metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent;
const halfLeading = (lineHeight - fontHeight) / 2;
// 绘制文字,y 坐标 = 当前基线位置 + 半行距偏移
ctx.fillText(line, x, currentY + halfLeading + metrics.actualBoundingBoxAscent);
// 移动到下一行
currentY += lineHeight;
}
}
// 使用示例
const text = '第一行文字\n第二行文字\n第三行文字';
drawText(ctx, text, 100, 100, 30);
注意,这里用了 actualBoundingBoxAscent 和 actualBoundingBoxDescent,它们是 measureText 返回的实际边界框信息。不同字体、不同字号下,这两个值都不一样。用它们来计算半行距,比用字体大小更精确。
经验之谈:在动画刷新中,每次重绘都调用 measureText 会有性能开销。我的做法是:在初始化时把所有文字的度量信息缓存起来,动画循环中直接读取缓存,避免重复测量。
4.7 常见问题与避坑
- 文字垂直不居中:检查 y 坐标是否设置成了基线位置。很多人误以为 y 是文字框顶部,结果文字偏下。
- 中英文混排行高不一致:中文字符的 ascent 和 descent 与英文字母不同。建议统一用字体大小计算行高,不要依赖实际边界框。
- 字偶距导致动画抖动:如果逐字符绘制时启用了字偶距,每个字符的宽度会微调。在动画中,这种微调可能导致文字位置跳动。我建议在动画场景中关闭字偶距,或者预计算好每个字符的固定宽度。
- 不同浏览器渲染差异:Chrome、Firefox、Safari 对字体的度量计算有细微差别。跨平台项目一定要在每个目标浏览器上测试。
好了,字体度量的核心概念就这些。说白了,就是搞清楚「文字坐在哪条线上」「行与行之间留多少空」「字与字之间怎么排」。把这些搞明白,排版就不会出大问题。