首页 / 资讯中心 / 文章详情

QFontMetrics 字体度量实战:解决文本居中、截断与布局的可靠方法

QFontMetrics 字体度量实战:解决文本居中、截断与布局的可靠方法 ★ FEATURED ARTICLE
做 Qt 开发这些年凡是自己写过自定义控件、画过自绘界面的朋友基本都会碰到一个共性问题明明代码里把文本画在正中间了显示出来却偏上或者偏左明明按钮文字不多控件宽度却不够用有时候一个省略号处理不好整个布局就乱了。这些问题背后绕不开的都是同一个家伙——QFontMetricsQt 里最被低估的字体度量利器。这篇文章不聊文档里那些干巴巴的接口列表而是从实际踩坑出发把 QFontMetrics 怎么用、什么时候用、为什么这么用讲清楚帮你少走几年弯路。1. 字体度量到底解决什么问题从一次自绘按钮文本偏移说起1.1 那次让我怀疑人生的按钮文字偏上早几年做工业上位机界面客户要求按钮都要做成圆角渐变样式那就只能抛弃 QPushButton 自己用 QPainter 画。当时画完背景直接在矩形区域里调用了painter.drawText(rect, Qt::AlignCenter, 启动)理论上文字应该在正中央但实际效果总感觉文字往上偏了 2 到 3 个像素尤其是中文加英文字体混排的时候偏得更明显。一开始我以为是 DrawText 的坐标计算问题试了Qt::AlignVCenter | Qt::AlignHCenter结果一样。后来把整个绘图区域放大对比才意识到问题根本不出在 drawText而是我对居中的理解太粗放了。QFontMetrics才是这个场景下真正该用的工具它可以告诉我们一个字体里cap height、baseline、ascent、descent 这些关键几何参数到底是什么。文本的视觉中心往往不在矩形几何中心上——大写字母顶部和小写字母底部之间的距离并不是我们直觉以为的那样以矩形中心为轴。那次排查之后我养成了一个习惯凡是绘制涉及文本对齐、截断、换行的代码先问一句这里要不要用 QFontMetrics 度量一下基本能提前挡住大部分视觉 bug。1.2 字体度量要回答的几类核心问题字面意义上字体度量就是量化文本占据空间的几何数据。但落到实际开发里QFontMetrics其实帮我们回答四类问题文本占多宽在既有字体下一个字符串水平铺开需要多少像素。这决定动态控件的宽度、自动缩放字号、文本是否超界。文本占多高单行或多行文本的高度包括行与行之间的间距。这是布局系统里计算控件最小高度、滚动区域尺寸的基础。文本边界在哪字符串的上、下、左、右极限位置也就是boundingRect。这在处理省略号、文字阴影、焦点框的时候特别有用。baseline 到底在哪文本绘制的基准线。任何混排对齐、上下标、多行基线对齐都必须知道 baseline 坐标光靠矩形对齐是不够的。这四个问题不解决你写的 self-painted 控件永远差那么一点原生味。而 Qt 把这一整套几何参数都封装在QFontMetrics里学会了它你才算真正理解文本绘制的地基。2. QFontMetrics 核心接口逐个拆解宽度、高度、行距和边界2.1 宽度度量三兄弟horizontalAdvance、size、boundingRect 怎么选很多人一上来就用fm.width(text)这个接口在 Qt 5.11 之后就被标记废弃了现在一律用horizontalAdvance()。除了它还有size()和boundingRect()很多人分不清三者的区别导致选错接口测出来的宽度对不上。先说结论接口返回内容典型用途注意点horizontalAdvance(text)字符串的水平前进宽度不含任何左右留白精确测量一行文本实际占的宽度不包含文字的 ink 边界只代表 pen 移动的距离size(flags, text)文本整体包围盒的宽和高估算控件尺寸、自动布局flags 控制换行/省略等行为默认单行时 width 可能比 horizontalAdvance 大几个像素boundingRect(text)一个 QRect描述文本的几何边界精确定位文本所占矩形区域矩形原点含义容易混淆详见 2.3实际项目中我要给一个动态按钮设置最优宽度通常用horizontalAdvance加上左右 padding最稳。size(0, text)返回的宽度偶尔会比横向前进宽度大 1~3 像素原因是不同字体引擎对于字符边界 box的定义有差异这个差异在给文本加背景、边框的时候会被放大。2.2 行高家族ascent、descent、lineSpacing 与 height 的真实关系行高是字体度量里最容易让人绕晕的部分。先明确这些概念在字体引擎里的原始定义ascent从基线到字体顶部的距离覆盖大多数拉丁大写字母的高度。descent从基线到字体底部的距离覆盖 g、y 这些下降部。leading两行文本之间的额外空白间距也叫行间隙。这个值可以为零甚至为负。heightQFontMetrics 返回的字体高度是布局时可以直接用的行高。lineSpacing从一行基线到下一行基线的距离。在 Qt 里文档描述 baseline 的关系可以近似写成lineSpacing() ascent() descent() leading() height() ascent() descent() 1 // 某些平台上可能等于 lineSpacing()这个1不是玄学是 Qt 为了保证底线像素完整、避免 sub-pixel 舍入导致最后一行被裁掉。实际开发中如果你要计算多行文本占用的总高度我建议直接使用lineSpacing()而不要自己去加ascent() descent()。因为部分字体带了额外的行距控制手动相加出来的值在换行场景下会偏小导致相邻两行文字视觉上贴在一起。我在一个日志控件里就吃过这个亏改用了lineSpacing()之后滚动区域的间距一下子正常了。2.3 坐标原点在哪看懂 boundingRect 的矩形坐标系boundingRect的坐标系是个经典坑。fm.boundingRect(text)返回的 QRect其坐标是相对于(0,0)的文本整体边界框原点并不是文本矩形区域的左上角而是字体的左上参考点。更直白地说这个矩形顶部是字体上行线的顶部底部是下行线的底部左边是第一个字符的起点右边是最后一个字符的终点。它天然就是绘制文本时应该传给drawText的矩形。但如果你想要某个具体字符串在某个区域内的边界要用重载版本QRect fm.boundingRect(QRect(x, y, w, h), Qt::AlignCenter | Qt::TextSingleLine, Hello);这个版本返回文本在指定矩形内按 flags 布局后的实际矩形区域。注意多行文本时它返回的是包含所有行的整体矩形而不是行内每个片段的边界。在实现代码编辑器的高亮、选中效果时往往还需要QTextLayout去拿更细粒度的 glyph 位置boundingRect只能给到行级别的精读。因此遇到精细排版不要老想着用boundingRect一把梭它能解决的是这个文本整体占多少空间和这个矩形内部文本落在哪里。3. 实战用 QFontMetrics 实现精确居中、省略号和自动换行3.1 垂直居中的正确姿势手算基线而不是靠 AlignCenter 硬刚回到开头那个按钮问题drawText(rect, Qt::AlignCenter, text)为什么偏因为 AlignCenter 是按照文本的 boundingRect 在目标矩形里居中而不同字体的 ascent/descent 比值不一样同一个矩形里字形视觉中心和几何中心天然存在偏差尤其当中英文混排、或者字体带很高的 overshoot 时会很明显。要想真正稳定手动计算基线位置再绘制void paintButtonText(QPainter p, const QRectF rect, const QString text, const QFont font) { QFontMetrics fm(font); // 文本整体高度 ascent descent int textHeight fm.ascent() fm.descent(); // 基线位置让文本块的中心对准矩形中心 int baselineY rect.center().y() (fm.ascent() - textHeight / 2.0); // 水平方向用 horizontalAdvance 居中更稳 int textWidth fm.horizontalAdvance(text); int x rect.center().x() - textWidth / 2.0; p.setFont(font); p.drawText(QPointF(x, baselineY), text); }这里的关键点在于drawText的坐标点指定的是 baseline 的起点而不是文本矩形左上角。所以你需要先算出 baseline 的 y 值。上面公式本质上是先把文本块从 ascent 顶部到 descent 底部垂直居中再把 baseline 定位到块内距离顶部ascent高度的位置。实测下来这套代码在不同分辨率、不同字体下基本都能做到视觉居中比依赖 AlignCenter 稳定得多。3.2 文本截断优化elidedText 能用但自己截断更可控如果只是给 QLabel 加一个setText后显示省略号Qt 提供的elidedText足够用了QString elided fm.elidedText(longText, Qt::ElideRight, maxWidth); label-setText(elided);但elidedText的短板也很明显它只能处理单行且省略号规则固定。文本是veryVeryLongWord在中间省略会更好读它也能做到但如果你想把省略号放在语义分词边界处比如中文里按词截断、URL 里按 / 截断它就没法满足了。我写过一个文件列表控件需要在中英文混合的文件名里保证尾部可辨识。我的做法是先归一化宽度再用horizontalAdvance逐字符相加直到超过目标宽度为止最后手动拼上省略号QString truncateWithEllipsis(const QString text, int maxWidth, const QFontMetrics fm) { if (fm.horizontalAdvance(text) maxWidth) return text; QString suffix ...; int suffixWidth fm.horizontalAdvance(suffix); int available maxWidth - suffixWidth; QString result; for (const QChar ch : text) { if (fm.horizontalAdvance(result ch) available) break; result ch; } return result suffix; }这段代码虽然朴素但可控性强你可以随意插入中英文语义断点逻辑。性能上如果文本长度不长循环调horizontalAdvance也完全可以接受如果频繁调用把每段文本的宽度结果缓存起来就行。3.3 多行文本高度估算让固定宽度下的行数预测不再靠猜自动换行的控件高度计算是 QFontMetrics 的高频应用场景。比如一个气泡消息控件宽度固定 300文字长度 200我们得先估算要占几行再决定整个气泡的高度。最容易出错的是拿fm.height()乘以手动换行数却忽略了有些字符的换行规则和字间距。一个可靠的办法是借助QTextLayout它能根据字符属性和换行规则准确计算出给定宽度下文本所占的行数int estimateHeight(const QString text, int wrapWidth, const QFont font) { QTextLayout layout(text, font); layout.beginLayout(); while (1) { QTextLine line layout.createLine(); if (!line.isValid()) break; line.setLineWidth(wrapWidth); int nextLineStart line.textStart() line.textLength(); if (nextLineStart layout.text().length()) break; } layout.endLayout(); int totalHeight 0; for (int i 0; i layout.lineCount(); i) { totalHeight layout.lineAt(i).height(); } return totalHeight; }这里layout.lineAt(i).height()已经包含了行距比手动把每行高度加起来更准确。想继续用 QFontMetrics 硬算也可以但需要自己处理换行点空格、标点、CJK 允许换行的位置复杂度高不少。我后来所有的多行文本尺寸预测都统一换成了 QTextLayout省心且结果和真正的绘制引擎完全一致。4. 自定义控件里的字体度量决策按钮、标签和进度条4.1 让按钮宽度跟着文字走从 measure 到 setFixedWidth在一些动态筛选栏、工具条里按钮文字可能来自配置文件或者用户自定义宽度必须自适应。新手喜欢给按钮设一个固定宽度或者按字符数估宽结果一换字体就露怯。正解就是QFontMetricsint computeButtonWidth(const QString text, const QFont font, int leftPadding, int rightPadding) { QFontMetrics fm(font); return fm.horizontalAdvance(text) leftPadding rightPadding; }注意horizontalAdvance只计算字符前进步宽不含边界。如果你按钮还带图标、下拉箭头之类要把这些的固定宽度也加进去。如果你用了样式表中的 paddingQFontMetrics 算出来的宽度还要考虑 style sheet 实际渲染的 margin/border最好给一个安全余量。4.2 进度条文字的里应外合文字与图形同步度量自绘进度条要同时显示百分比文本和进度条本身一个常见需求是进度条只有 30%文字只有 30% 三个字符我们希望文字始终居中于整个进度条而不是居中于当前进度区域。这个直接drawText(rect, AlignCenter, text)就能满足。但更麻烦的是文字既要跟着进度走又要保证不被进度条边界裁掉。比如进度条左半边是已完成的蓝色右半边是灰色如果文字刚好压在分界线上我们希望文字中间那条分界线正好是进度值的边界。这就必须分两步走先用horizontalAdvance测出文字中心位置让中心点跟随进度分界点移动再判断文字是否超出整个控件范围超出就改用elidedText或者裁剪绘制区域。QRectF textRect QRectF(0, 0, fm.horizontalAdvance(text), fm.ascent() fm.descent()); // 让文字中心点在 progressBoundaryX 处 double textX progressBoundaryX - textRect.width() / 2.0; textX qMax(0.0, qMin(rect.width() - textRect.width(), textX)); double baselineY rect.center().y() (fm.ascent() - textRect.height() / 2.0); p.drawText(QPointF(textX, baselineY), text);这种既要跟随进度又要限位的场景在进度条、滑杆、仪表盘里很常见关键就是不要用一个矩形硬塞文本而是把绘制坐标拆解成横向起点和基线两个独立变量分别由不同逻辑控制。4.3 中英文混排与特殊字符的宽度陷阱中文字符的宽度通常是拉丁字符的两倍左右但这只是相对稳定的规律并不是标准。同一字号下Qt 和 框架 的宽度比会因为字体不同而变化。另一个容易被忽视的是 kerning字距调整。比如 AV 这个组合horizontalAdvance(AV)可能小于horizontalAdvance(A) horizontalAdvance(V)因为字体渲染引擎会压缩两个字形之间的空隙。因此需要按字符宽度做截断、居中、固定宽度对齐的代码千万不要按字符数来估算宽度也不要单独测量每个字符再相加除非你能显式关闭 kerning。QFontMetrics::horizontalAdvance已经是处理了字距后的整体宽度直接用整字符串测量最可靠。如果你要自己截断默认会把空格视为不可换行的部分遇到长 URL 时也要额外处理否则文本会比预想的更早溢出。5. 三个高频坑位高DPI、字体回退和 QFontMetricsF 的取舍5.1 高DPI下度数会骗人逻辑像素与物理像素的纠缠Windows 上开启高 DPI 缩放后QFontMetrics返回的值是基于逻辑像素device independent pixels的而 QPainter 在绘制时会对整个画布应用 DPR 缩放。大多数情况下你只需要继续用逻辑像素做布局绘制交给 Qt 的透明度处理即可。但有些人会在高 DPI 下忍不住补一句painter.scale(devicePixelRatioF(), devicePixelRatioF());一旦你手动 scale 了再调用fm.horizontalAdvance之后直接拿原始像素值去绘制就可能出现两倍误差。正确做法是二选一要么完全相信 QPainter 的坐标系所有度量值都不乘比例因子要么你在自定义控件内统一使用物理像素构造一个使用物理字号的 QFont并手动进行坐标换算。混用逻辑像素和物理像素是 DPI 相关 bug 的最大来源。我在一个跨平台的报表控件里吃过这个亏现象是 4K 屏幕上文字和分隔线总对不齐排查半天发现代码里有人写了int x rect.x() * dpr后面的绘制又用逻辑坐标于是线条比文字宽了一倍。后来把所有缩放手动逻辑统一去掉desc 一清二楚。5.2 字体回退带来的虚假安全感度量和绘制不是一个字体QFontMetrics 是基于当前 QFont计算的如果当前字体不支持某个字符Qt 会悄悄回退到系统里其它字体来渲染这个字符。但 QFontMetrics 在计算宽度时行为并不总是考虑回退后的字形叠加长度就可能对不上。典型例子微软雅黑Microsoft YaHei不支持某些 Emoji 或数学符号你用微软雅黑构建 QFontMetrics 去量一个带 ? 的字符串测出来的宽度跟 QPainter 实际用 fallback 字体画出来的宽度可能有明显差异最终结果就是文本被截断或者末尾溢出边界。要识别这类问题最好在绘制时用QFontMetrics测完之后再用QTextLayout或者QFont::substitute()检查目标字符是否发生了回退。如果发现 fallback 后会有差异直接用 QTextLayout 做整体布局因为 QTextLayout 会把 fallback 字形也纳入测量准确性会高得多。5.3 QFontMetricsF什么时候值得多花一点性能换精确QFontMetrics返回intQFontMetricsF返回qreal。在绝大多数 UI 场景里整数值已经足够因为最终 drawText 的坐标也是 qreal但像素最终还得整数化。但有两个场景建议你用浮点版连续缩放动画字号从 12 平滑变到 18中间如果每一步都取整视觉上会出现抖动而 QFontMetricsF 可以给出平滑的连续值。DPI 奇偶问题在 125%、150% 这类缩放下逻辑坐标乘缩放因子后会出现 0.5 的尾数QFontMetrics 取整可能导致左侧对齐的文本边界差 1 像素。使用QFontMetricsF的接口和整数版几乎一致只是返回类型不同。构造时传入同一个 QFont 即可。需要注意的是浮点版在底层会使用更多的计算资源如果你只是做一次性的控件尺寸估算整数版足够没必要为了 float 看起来高级 而多消耗性能。6. 性能潜规则不要在 paintEvent 里反复造 QFontMetrics6.1 构造QFontMetrics的隐性成本QFontMetrics fm(font);这行看似轻巧实际上会把字体对象解析到字体引擎层查询字体文件里的很多度量表。你对同一个 QFont 连续构造两次第二次并不会因为参数相同就自动命中缓存多数情况下仍会重复完成同样的查表过程。虽然构造一次也就是微秒级但如果在paintEvent或者高频定时器回调里反复构造CPU 占用和 UI 线程抖动就会明显上来。6.2 用成员变量与结果缓存把开销降到最低正确姿势是把 QFontMetrics 对象作为控件成员变量在字体变化时更新class MyWidget : public QWidget { public: void setDisplayFont(const QFont f) { m_font f; m_fm QFontMetrics(f); // 构造一次 update(); } protected: void paintEvent(QPaintEvent*) override { QPainter p(this); p.setFont(m_font); // 直接使用 m_fm 测量 int textWidth m_fm.horizontalAdvance(m_text); // ... } private: QFont m_font; QFontMetrics m_fm; QString m_text; };另外如果你在同一时刻需要对大量文本做宽度测量最好用QHashQString, int缓存结果。对于那些几乎不变的长列表还可以把每行的宽度在数据加载时就算好存进 model 里绘制时直接取不再调用 QFontMetrics。我在一个千行日志表格里就是这样做的绘制性能提升了肉眼可见的幅度。6.3 更复杂的排版测量交给 QTextLayoutQFontMetrics说到底只是度量工具它不是排版引擎。当你遇到这些场景一个段落里有不同字体、颜色、大小的 span需要精确的 pos 与 cursor 映射需要知道每个字符或每个 glyph 的具体绘制位置想使用双向文本、或者基于 script 的复杂文本布局QTextLayout是更正确的选择。它内部会拿着 QFont 做完整的文本整形shaping、字形选取和换行并且产出的 line/glyph 信息能直接用于绘制。本质上QLabel 的 rich text 和 Qt Quick Text 底层也都是它。虽然上手门槛比 QFontMetrics 高一点但理解它之后自绘文本的能力会上一个档次。7. QML/Quick 世界里的字体度量Painter 之外的另一种玩法7.1 Quick 里的 FontMetrics 附加类型怎么用如果你已经在用 QML 开发界面也需要知道 Qt Quick 里有对应的FontMetrics类型。它把 QFontMetrics 的能力暴露给 QML常见用法是在组件里声明Rectangle { id: root property string label: Qt FontMetrics { id: fm font.family: root.font.family font.pixelSize: 18 } width: fm.advanceWidth(label) 20 height: fm.height 20 Text { text: root.label anchors.centerIn: parent } }注意这里的advanceWidth是单字符串前进宽度对应 C 里的horizontalAdvance。你还可以通过 binding 去获取ascent、descent、lineSpacing这些属性用来做自定义行的 baseline 计算。比如在实现一个富文本列表时让每项的高度严格等于fm.lineSpacing这样多个 Text 之间的距离就能精确匹配字体设计。7.2 简单布局直接用 Text 的隐式尺寸就够了在 QML 里很多场景根本不需要手动 FontMetrics。Text 类型自带implicitWidth、implicitHeight、paintedWidth、contentWidth等属性只要你不强制设置 width/height布局系统就会自动根据文本内容给出尺寸。比如做一个简短的标签按钮Text { text: 确定 font.pixelSize: 16 // 不需要手动量宽度默认 implicitWidth 就是文本真实宽度 }我要么在 QML 中直接用隐式尺寸要么在 C 中用 QTextLayout用 QFontMetrics 的场景通常集中在必须在 QML 里动态计算某个固定宽度矩形内文本是否溢出然后决定省略号这种偶尔才做一次的操作上。一旦发现 QML 里频繁用 FontMetrics 做复杂计算就要反思是不是应该把逻辑下沉到 C 里或者直接用 Text 的更多内建能力。如果你打算长期和自绘文本、复杂布局打交道建议把 QFontMetrics 和 QTextLayout 放在一起学习。前者解决尺寸和位置后者解决排版和逐字形控制很多让人头疼的字符宽度和基线问题本质上都是因为只掌握了其中一个。实际操作中我也还在持续踩坑以上这些是我目前总结出来最值得注意的经验希望对你有所帮助。
阅读完成 · 觉得有帮助?
咨询建站