ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

字体度量详解:fontsize到底控制什么?真实高度与渲染环境差异

字体度量详解:fontsize到底控制什么?真实高度与渲染环境差异 我先抛一个我绕了很久才明白的事同样设置fontsize64在网页里量出来的文字高度可能是 80 多像素在 Pygame 里render出的 Surface 高度又是 70 多像素用设计软件打字看起来还在变。三个数字对不上一度以为是自己 API 用错了。后来把字体度量font metrics、行高line height、em box 这些概念翻过来反复折腾才算彻底搞清楚fontsize 从来就不是字体的可见 height而是一个“标称字号”它和最终渲染出来的字形高度之间存在一套完整但常被忽略的换算关系。这篇就是用我的实测过程把 fontsize 和字体 height 的关系拆开讲清楚。会覆盖字体度量、不同渲染环境下的差异、垂直居中、跨端对齐以及我在 Pygame、Pillow、CSS、SVG 里逐一验证过的数据和方法。内容偏实操适合做图像处理、游戏 UI、Web 前端或者经常和文字渲染打交道的朋友。1. 从两个反直觉的测量结果说起fontsize 到底控制了什么先说两个真实现象这俩现象是我当时开始查这个问题的直接原因。第一个是在 CSS 里。给一个span设置font-size: 20px然后执行getBoundingClientRect().height量出来的值往往不是 20而是 23 甚至更高。原因很好查浏览器里行高line-height: normal的大约值是 1.2 倍字号左右所以 20px 字号通常能量出 23~24px 的高度。但这里有个更隐蔽的问题即使我把line-height显式设置为20pxheight 也不一定正好是 20因为字体的 ascent 和 descent 之和可能超过 20px导致文本实际绘制区域超过行盒。第二个是在 Pygame 里。我写了这么一段代码import pygame pygame.init() font pygame.font.Font(None, 64) text font.render(Ag, True, (0, 0, 0)) print(text.get_size())当时我的第一反应是get_size()返回的(width, height)里height 应该是 64 或者略小于 64结果实测打印出来(36, 75)这种尺寸——高度比 64 多了 11 像素。这多出来的部分不是 bug而是 Pygame 在选择字号时把字母上升部ascent、下降部descent都算进了 surface 高度里。所以问题来了如果fontsize不直接等于最终高度那它到底控制的是什么我在实际项目里发现很多人包括我自己都会下意识把fontsize当作“字大约占多高”然后在做图像文字拼接、UI 控件自适应时用这个错误的预期去算布局最后出来的效果不是偏上就是高度不够。这里用个生活化的类比fontsize 更像是一个“字号系统的基准刻度”而不是一个实物的长宽。就像选购衬衫时标注的“尺码 42”它不等于衣长也不等于肩宽只是一个用来统一描述的规格。字体里这个规格对应的是字体设计中最核心的 em square。2. 字体度量盘根问底em box、ascent、descent 和 lineGap2.1 em box 只是参照系不是可见字形高度打开任意字体编辑器或者查看字体文件属性都会看到unitsPerEm这个概念。常见的值是 1000PostScript 字体或者 2048TrueType 字体。这个 em 大小才是fontsize真正作用的基准。假设某字体unitsPerEm 1000你设置fontsize100时渲染引擎就把字体的整个 em 坐标空间缩放为 100 像素。注意是缩放而字形只要没画满整个 em 方框实际可见高度就小于 100。反之如果字形的上升部或下降部超出了 em 方框又可能超过 100。所以第一个结论是fontsize 的实际含义是“em 方框被缩放到多高”不是“字形渲染出来有多高”。em 方框在字体设计里可以理解为设计师的整个画板。画板里可以画得很满也可以上下留白。不同字体在画板内的“画法”不同这就是为什么同样的 64pxArial 和宋体渲染出的实际高度会差不少。2.2 ascent 和 descent 决定字形上下边界字形真正能触达的最高点和最低点不直接看 em 方框而是看字体度量里的hheaAscender、hheaDescender在 Web 字体和 OpenType 里也常称为 ascent 和 descent。ascent从基线baseline向上到字形最高处的高度。descent从基线向下到字形最低处的深度一般是个负数或者以正数表示“向下延伸的长度”。把这两个值加起来就是“字体延伸的最短必要高度”。这也是 Pygame 里 surface 高度、Pillow 里getmetrics()的上升下降、CSS 里字形实际绘制区域的共同来源。实际操作中我常用 Pillow 跑一下from PIL import ImageFont font ImageFont.truetype(Arial.ttf, 60) ascent, descent font.getmetrics() print(ascent, descent, ascent descent)在我本机的 Arial 60px 下返回的ascent descent通常会比 60 多出 8%~15%。不同字体比例不同有些字体的 descent 特别大比如高棉文、天城文等文字系统即使不包含这些字符字体度量里仍然带着很大的 descent 值这也是为什么有时候你只是渲染中文文本height 却异常偏大。2.3 lineGap 和行高height 的第三种来源光看 ascent descent 还不够还有lineGap。lineGap 是排版引擎在换行时额外加入的空隙确保两行文字不会贴得太近。CSS 里line-height: normal就会参考这个值字体内部也会内置一个默认的行距比例。所以在任何渲染环境里一个文本行实际占用的总高度layout height通常可以表示为line_height ascent descent lineGap这也是为什么 CSS 里设置font-size: 20px后getBoundingClientRect().height很容易得到 23px、24px 之类数值——浏览器按默认行高把 lineGap 也加进去了。到这里已经有三个“高度”很容易混em 高度fontsize 本体字形高度ascent descent行盒高度ascent descent lineGap后面所有场景的 height 问题基本都是这三者之间发生错位导致的。3. 动手测量在 Pillow、Pygame、浏览器里读出真实 height3.1 在 Pillow 中测量Pillow 是 Python 图像处理最常用的库文字水印、验证码、海报生成都会用到。要拿到真实 height不要只依赖textbbox()还需要getmetrics()。from PIL import Image, ImageDraw, ImageFont font ImageFont.truetype(msyh.ttc, 80) ascent, descent font.getmetrics() print(ascent , ascent, descent , descent, sum , ascent descent) img Image.new(RGB, (400, 200), white) draw ImageDraw.Draw(img) bbox draw.textbbox((0, 0), Ag, fontfont) print(text bbox , bbox)实测下来getmetrics()返回的是字体级别的全局度量不依赖具体文本内容textbbox()返回的是当前字符串几个字符计算出的实际包围盒。二者用处不同要算行高、垂直居中用getmetrics()更稳。要画一个文本的精确背景框用textbbox()更接近字形可见范围。我之前踩过一个坑用textbbox()的高度去设置行距导致每行之间忽大忽小因为不同行的字符 ascender 不同比如全大写字母的行和带小写下伸部的行包围盒高度不一样。正确做法是先按getmetrics()算固定行高再用textbbox()做局部微调。3.2 在 Pygame 中测量Pygame 的字体模块相对简单但也正因为简单不理解度量时最容易出错。import pygame pygame.init() font pygame.font.Font(None, 64) print(font metrics:, font.metrics(Ag)) print(font.size:, font.size(Ag)) print(ascent:, font.get_ascent(), descent:, font.get_descent())这段输出会很清楚font.metrics(Ag)返回一个列表每个字符是(minx, maxx, miny, maxy, advance)这是字符具体的绘制边界。font.size()返回字符串渲染在 surface 上需要的尺寸高度一般是 ascent descent 向下取整后的值。font.get_ascent()和font.get_descent()返回字体级别的上下度量。实测中 64 号字的get_ascent()加get_descent()的绝对值通常大于 64所以render()出来 surface 的高度会比传进去的 64 大。这不是 Pygame 的 bug而是 Pygame 采用了字体实际的字形延伸高度而不是简单按 em 缩放这样渲染结果不会裁切掉上伸部或下伸部。做游戏 UI 时如果你希望两个按钮文字在视觉高度上对齐建议统一用同一字体的get_ascent()做基线对齐而不是直接按surface.get_height()对齐。直接按 surface 高度对齐会发现带下伸部的文字和全中文或全大写文字的位置会有细微错动。3.3 在浏览器里测量浏览器里的情况稍微复杂因为有嵌套上下文、行盒构建和字体回退。最直接的测量方式是创建一个不换行的元素然后读它的高度。const span document.createElement(span); span.style.cssText font-size: 20px; line-height: normal; display: inline-block; white-space: nowrap;; span.textContent Ag; document.body.appendChild(span); console.log(span.getBoundingClientRect().height);把line-height从normal改成20px再跑一次高度会从 23 左右变成 20 到 24 之间不等。为什么改成20px还不一定正好 20因为浏览器渲染文本时行内格式化会根据字体 fallback 链上的最大 ascent 和 descent 重新计算实际行内盒子如果某个回退字体度量偏大行盒会被撑大。对于需要像素级还原的场景我的经验是不要依赖元素的高度改用Range对象读取精确的绘制边界const range document.createRange(); range.selectNodeContents(span); const rects range.getClientRects(); console.log(rects[0].height);Range.getClientRects()返回的是文本绘制像素边界更接近“字形真正占了多少空间”而元素高度是行盒高度两者在字体度量和行高设置不同时会明显不一致。3.4 用一套脚本对比不同字体在相同 fontsize 下的 height我写了个简单的对照脚本分别用三种字体渲染同样的fontsize80并把各项高度打印出来from PIL import ImageFont fonts [ C:/Windows/Fonts/arial.ttf, C:/Windows/Fonts/simhei.ttf, C:/Windows/Fonts/msyh.ttc, ] for path in fonts: try: font ImageFont.truetype(path, 80) ascent, descent font.getmetrics() bbox font.getbbox(Ag) print(f{path}: ascent{ascent}, descent{descent}, fsum{ascent descent}, bbox_height{bbox[3] - bbox[1]}) except Exception as e: print(path, load failed, e)在我机器上相同 80px 下不同字体的 bbox 高度和 sum 都不同。Arial 相对收敛中文字体因为有更复杂的字形区块和更大的行距设计数值往往更“胖”一些。这就解释了为什么做图文混排时中文和英文混排会出现行高“跳动”的感觉——不是排版引擎抽风而是字体回退后度量变了。4. 实战按 height 精确控制文本布局4.1 用字体的 ascent/descent 做文字垂直居中最常见的需求是把一段文字精确放到图片中央或者放到一个指定高度的容器中央。新手做法是拿到文本渲染的宽高后用(容器高 - 文本高) / 2作为左上角 y。这个做法在大字号下经常偏上。正确做法是用字体度量计算基线位置再绘制文本。以 Pillow 为例from PIL import Image, ImageDraw, ImageFont font ImageFont.truetype(msyh.ttc, 80) ascent, descent font.getmetrics() box_height 300 baseline_y (box_height - (ascent descent)) / 2 ascent这里baseline_y是文字基线baseline所在的 y 坐标。draw.text()的xy参数在 Pillow 里指的是文本包围盒左上角还是基线取决于anchor参数。用anchorls可以指定左侧基线锚点这样直接把baseline_y传进去即可draw.text((x, baseline_y), 测试文字, fontfont, fillblack, anchorls)按照 ascent descent 作为整体高度来居中的效果明显比用textbbox高度更平衡。因为文本包围盒高度受字符形状影响会忽大忽小而字体度量是全局统一的。4.2 固定行高时leading 到底该取多少做多行文本渲染时如果没有固定行高要求最简单的方式是按ascent descent lineGap自然排版。但很多设计稿会给出固定行高比如“字号 20px行高 28px”。这时候需要在每行之间插值。有两种做法做法一行高固定为 28则每行 y 增加 28绘制文本时始终把 baseline 定在行内部合适位置。此时需要重新计算行内基线偏移量通常是(28 - (ascent descent)) / 2 ascent。做法二直接调整行距leading也就是在上一行底部和下一行顶部之间额外塞入一个固定值。我的经验是如果设计稿强调行高必须一致尽量用“行高 行内基线偏移”的思路因为浏览器和文字引擎在严格模式下的行为就是这个。如果只是需要看起来舒适用ascent descent 4px这种偷懒公式也不是不行但不同字号下视觉效果会不稳定。在代码里一个可用的通用多行绘制函数是这样的def draw_multiline_text(draw, font, texts, start_x, start_y, line_height, fillblack): ascent, descent font.getmetrics() inner_offset max(0, (line_height - (ascent descent)) / 2) baseline_y start_y inner_offset ascent for text_line in texts: draw.text((start_x, baseline_y), text_line, fontfont, fillfill, anchorls) baseline_y line_height这个函数以固定行高为基准每一行都先算出本行基线核心就是依赖字体的 ascent 和 descent 做行内偏移。4.3 跨端输出图片、PDF、HTML 时的高度对齐在实际业务中同一段文案可能需要在后端渲染成图片同时前端 Web 端也要展示。要保持视觉高度一致最稳的路径是统一用同一种字体文件并且三端都使用字体的 metrics 而不是各自平台默认值。我做的一个实际项目里后端生成分享海报时用 Pillow 渲染中文黑体大字前端页面也用 WebFont 加载同一字体。最初两边字号都写 80但海报上文字高度和网页里总是差几个像素。排查后发现问题出在Pillow 的ImageFont.truetype会使用字体的 hhea 度量。浏览器行盒高度还受line-height影响。PDF 生成工具又有独立的字体嵌入逻辑。解决办法是先把字体度量数据导出成一份 JSON放进项目公共配置{ fontSize: 80, ascent: 76, descent: 20, lineGap: 4 }然后所有端都依据这个数据计算实际高度和基线而不是让各自引擎自行解释 fontsize。这样对齐误差基本控制在 1px 以内偶尔出现的差异来自字体渲染的亚像素舍入。5. SVG 与跨领域字段提醒height 的另一种打开方式5.1 SVG 里 text 的 font-size 和 getBBox()SVG 中设置文字字号的方式和 CSS 类似font-size80表示 em 大小 80 用户单位。但 SVG 里的文本范围计算并不像 HTML 那样直接返回行高而是通过getBBox()获取当前图形元素的包围盒。svg xmlnshttp://www.w3.org/2000/svg width800 height600 viewBox0 0 800 600 text idt1 x50 y100 font-size80Ag/text /svgconst t document.getElementById(t1); const bbox t.getBBox(); console.log(bbox.height);注意 SVG 的getBBox()返回的是几何包围盒不包含行高和 lineGap它更接近字形的可见边界。而 SVG 的y坐标默认代表基线位置所以在 SVG 中做文字垂直定位时直接按视觉中心来算很容易出问题需要先查 bbox再根据 baseline 调整。这也是为什么很多动态生成 SVG 图表的代码里会看到y值带一段经验性的偏移量——那通常就是为了补偿 ascent。关于 viewBox 还要多说一句width800 height600 viewBox0 0 800 600时用户单位和屏幕像素是一一对应的font-size80也就是 80 用户单位高。但如果 viewBox 和 width/height 比例不一致整个坐标系会被缩放这时font-size的实际像素大小就不是 80 了而是会跟着 viewBox 变换。这个坑在做 SVG 响应式适配时特别常见文本高度会随容器变形。5.2 当 height 和字体无关PCD 的 “height given (0) but no width”开头列出的热搜词里有条loading map.pcd [pcl::pcdreader::readheader] height given (0) but no width!看起来和字体毫无关系却正好能说明“height 这个词在不同领域语义完全不同”。PCDPoint Cloud Data文件里的 height 字段指的是点云按行排列时的“行数”width 是“列数”。对于无组织点云PCL 用 height1 表示此时 width 等于点的总数。而如果文件的 height 被写成 0PCL 读取器就会提示height given (0) but no width这不是文字渲染也不涉及字形高度纯粹是数据结构维度不一致。我第一时间把它和 fontsize 联系在一起是因为在做底层图形相关开发时类似的“字段语义错位”问题特别容易发生。比如某个接口希望你传的是字形总高度你却传了 fontsize某个解析器希望 height 是扫描线行数你却传了屏幕像素高度。遇到height、width、size这类字段第一件事永远是确认它背后代表的是什么空间、什么单位。5.3 字段语义迁移先查上下文再套用经验在 SVG、Pillow、Pygame、PCD、PDF 这些不同环境里都出现height字段但它们各自的含义如下环境height 字段来源实际含义CSSline-height/ 元素高度行盒高度含 ascent descent lineGapPillowgetmetrics()ascent descent字体级延伸高度PygameSurface.get_height()渲染后的位图高度基于字体级度量SVGgetBBox()当前字形几何包围盒不含行距PCD头文件 height点云行数 / 组织方式与字体无关同样叫 height背后的“参照物”完全不同。我在排查图像错位问题时第一件事就是列出当前接口里所有与高度相关的字段逐个确认它们是字体度量的哪一层避免拿 A 层的值去套 B 层的公式。6. 调试经验与常见误区清单6.1 中英文混排、emoji、全角字符会造成高度不统一字体渲染引擎遇到当前字体里不存在的字符时会走 fallback也就是从系统其他字体找字形。这些 fallback 字体的 ascent、descent、lineGap 可能完全不同导致一行文字内部出现了多个字体度量整体行高会被撑到最大。比如一行里同时有普通中文和 iPhone 上常见的 emojiemoji 字体Apple Color Emoji 等往往带有夸张的 ascent 和 descent行高一下就变大了。这在 Pygame 里更明显因为 Pygame 对彩色 emoji 支持有限fallback 行为在不同系统上不一致导致同一个字符串在不同平台渲染尺寸不同。我的建议是如果项目对行高要求严格尽量控制字体范围避免在关键文本里混入 emoji如果一定要混排至少不要依赖某个平台的默认 fallback指定一个已知度量的 fallback 字体并手动测量混合字符串的实际包围盒。6.2 三种获取 height 的方式结果不一致该以哪个为准实际工作里同一个字体、同一个字号不同 API 给出的 height 可能不同。常见的有字体级ascent descent这是固定值不随字符变化。字符串级textbbox/metrics随字符内容变化。行盒高度 / surface 高度包含或排除 lineGap 视环境而定。我的决策准则很简单使用场景推荐依据判断文本行高、做多行排版用字体级ascent descent作为基础绘制精确背景框、点击热区用字符串实际的textbbox对齐容器视觉中心用字体级度量计算基线再叠加文本级微调以这个表格为基准可以避免大多数“高度不准”的问题。核心思路是排版用全局度量绘制细节用局部度量两者结合而不是拿一个 API 打天下。6.3 我的一个持续踩坑记录文字底部总被裁剪还有一个很常见的问题就是在 Pillow 里绘制文字后保存图片发现底部被裁剪。原因是很多中文字体的 descent 虽然存在但某些字符不会用到最深的下降部导致textbbox()计算的包围盒比实际渲染区域小保存时按 bbox 裁切把超出部分截掉。修复方法是保存前预留足够的上下边距或者在裁切时不要直接用textbbox()而是以getmetrics()的ascent descent为安全高度。这也是我在生成海报、水印图片时学到的教训宁可让画布外圈多留 10 像素也不要相信 bbox 完全不越界。7. 个人经验总结先确认你真的需要“精确 height”还是“视觉 height”文章写到末尾我不太想做那种面面俱到的结论梳理因为fontsize和height的关系本身没有一条万能公式可以套到底。真正有用的经验是在不同场景里精度需求不同采取的策略也不同。如果只是想把一段文字在图片上画出来并且不裁切、不溢出用字体级ascent descent来预留空间就完全足够如果要做文字垂直居中的控件必须算基线位置不能用简单的矩形中心对齐如果要跨端复现同一份设计稿那就老老实实把字体度量数据化放到公共配置里让每端按同一套数据计算。我自己在实际操作中的体会是几乎所有“文字高度不对”的 bug最后都能归因到“把字体级度量、文本级包围盒、行盒高度三者的含义搞混了”。这篇文章里给的测量脚本和计算方式是我每次遇到布局问题都会先跑一遍的标准动作试完基本就能定位问题出在哪个环节。如果看完还有点模糊可以先把下面这份最小检查清单存下来下次遇到此类问题逐条过一遍就够了先确认当前环境里的height是哪个层面的值em 高度、字体级高度还是行盒高度。再确认fontsize到底代表什么是 em 基准还是某个渲染引擎自定义的近似高度。做垂直定位时优先用基线计算而不是左上角坐标相减。跨端时统一字体文件并统一读取字体度量。遇到 SVG 或 PCD 这类特殊场景先看字段文档再谈字号换算。这是我现阶段能给出的最直接的答案希望对你有用。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进