做Windows客户端开发,尤其是涉及自绘控件、报表打印或者排版引擎的时候,总会遇到一个绕不开的问题:怎么让文本在界面上排得刚刚好。用固定像素拍脑袋,换台机器高分屏下就乱套;用GetTextMetrics去量,才能真正拿到当前字体在特定设备上的“真实尺寸”。这是Win32 GDI里被低估却极其实用的函数,这篇文章就把它的用法、背后逻辑和实际项目里的坑一次讲透。
1. 内容整体设计与思路拆解
1.1 为什么文本布局不能靠猜,必须量字体
很多人刚开始写界面程序时,觉得字体嘛,给个大小比如12号,行距、高度自然是确定的。等真正做过自绘控件、做过报表、做过编辑器的行高计算,就会明白这个想法有多天真。同一个字号,宋体和微软雅黑的实际行高不一样,同一个字体在不同DPI下渲染尺寸也不一样,甚至同一个字体在同一台机器上,屏幕DC和打印机DC下量出来的度量值都有差别。
GetTextMetrics函数就是用来解决这个问题的。它的作用是把当前选入设备上下文(DC)的那个字体,把它在“那个DC对应的物理设备”上的各项度量参数全部取出来,填进一个TEXTMETRIC结构体里。注意关键词是“当前选入”和“那个DC对应”,这两个词是整个函数理解上的分水岭:它量的是“此刻这笔字写出来到底是什么尺寸”,而不是“这个字体名字理论上是什么尺寸”。
这个函数对于做原生Windows界面开发的人来说,就好比裁缝做衣服之前先量顾客的肩宽袖长。不做这一步,后面所有的排版都是盲人摸象。实际项目里,凡是涉及文本垂直居中的计算、多行文本行距的精准控制、表格行高的统一规划,几乎都绕不开它。
1.2 GetTextMetrics在GDI字体体系中的定位
Win32的GDI体系里,字体相关操作大体分几个层次。最上层是CreateFont或CreateFontIndirect创建字体,中间层是SelectObject把字体选入DC,再往下就是各种字体信息查询接口。GetTextMetrics正处在“字体已经选入DC”之后、“文本实际绘制”之前这个窗口期,它回答的是“接下来这句文本写出来会有多宽多高、基线在哪儿、什么风格”。
相比GetTextExtentPoint32这类只关心“一段具体文本的宽高”的函数,GetTextMetrics回答的是“这个字体本身的属性”。一个是测量句子,一个是测量字体,两者的关系类似于“用尺子量这段文字占多大地方”和“查看印刷厂对这套字体的出厂规格说明”。实际排版时,前者用于计算一段文本的折行,后者用于计算行高、基线偏移、字符间距等全局参数。
值得强调的是,GetTextMetrics返回的是TEXTMETRIC,而GetTextMetricsW是它的宽字符版本。在多字节字符集(MBCS)的代码里可能会用到前者,但在当前主流的Unicode工程里,GetTextMetricsW是更常见的调用形式。这一点后面会有代码示例。
1.3 一套度量结构,支撑多种核心场景
TEXTMETRIC结构体里的字段非常多,但项目的日常开发中真正高频使用的集中在几个关键字段上:
tmHeight:字符高度,等于tmAscent加上tmDescent,用于计算行高。tmAscent:基线以上部分(字母顶部到基线)的距离,用于绘制文本时的垂直定位。tmDescent:基线以下部分(字母底部或下伸部分到基线)的距离。tmAveCharWidth:平均字符宽度,适合估算字符串占宽、计算大致容量。tmMaxCharWidth:最宽字符的宽度,适合计算表格列宽上限。tmInternalLeading:字符内部的前导空间,会影响带重音符号的字符能否完整显示。tmExternalLeading:行与行之间的额外间距,编辑器计算行距时尤其关注。tmPitchAndFamily:低字节表示字体的间距属性(是否等宽),高字节表示字体族。tmWeight:字体粗细,值从400正常体到700粗体。
理解这些字段的含义与用途,才能在具体场景里选对参数,而不是一股脑把整个结构体打出来调试看半天。下面这一节专门把每个字段掰开揉碎讲清楚。
2. 核心细节解析与实操要点
2.1 TEXTMETRIC结构体逐字段拆解
TEXTMETRIC这个结构体在wingdi.h中定义,核心字段如下表所示。注意老版GDI结构体里的tmCharset字段,在Windows的统一字符编码时代更多使用tmCharSet,部分编译器里两者存在映射关系,代码在跨编译器环境编译时容易在此处报警告。
| 字段 | 含义 | 典型用途 |
|---|---|---|
tmHeight | 字符总高度 | 计算行高、控件高度 |
tmAscent | 基线到顶部的距离 | 顶部对齐时决定首行位置 |
tmDescent | 基线到底部的距离 | 底部对齐时决定末行位置 |
tmInternalLeading | 字符内的前导空间 | 判断重音符号是否会被截断 |
tmExternalLeading | 行间额外间距 | 多行文本行距计算 |
tmAveCharWidth | 平均字符宽度 | 估算宽度、列表列宽 |
tmMaxCharWidth | 最大字符宽度 | 最坏情况宽度预算 |
tmWeight | 字重 | 判断字体是否为粗体 |
tmItalic | 是否斜体 | 样式判断 |
tmUnderlined | 是否有下划线 | 样式判断 |
tmStruckOut | 是否有删除线 | 样式判断 |
tmFirstChar/tmLastChar | 支持的字符范围(旧版字段) | 字符集兼容性检测 |
tmPitchAndFamily | 间距属性和字体族 | 判断等宽字体 |
关于tmPitchAndFamily,低字节的TMPF_FIXED_PITCH这个宏名字其实是个历史包袱,它的取值与字面含义相反——如果该位为0,才表示是等宽字体,为1反而是比例字体。很多新手在这里被坑过,第一次用tmPitchAndFamily & TMPF_FIXED_PITCH去判断等宽字体时得出完全相反的结果。正确的写法是if (!(tm.tmPitchAndFamily & TMPF_FIXED_PITCH)),才表示等宽字体。
2.2 一个最容易踩的坑:tmWeight的值不等于磅值
tmWeight字段表示字体粗细,取值范围是0到1000。很多人想当然以为FW_BOLD就是700磅,但在TrueType和OpenType字体里,字重的计算方式是tmWeight = 字体本身的USWeightClass值。如果在代码里用CreateFont创建了一个FW_SEMIBOLD(600)的字体,再通过GetTextMetrics读出来的tmWeight极可能是600,也可能因为系统字体链接机制被映射成700,具体取决于字体是否真实包含了对应的字重。
这给实际开发带来的影响是:如果代码里通过判断tmWeight >= FW_BOLD来决定是否加粗显示,在某些字体被系统“合成粗体”的情况下判断会失效。比较可靠的替代方案是直接检查CreateFont时传入的nWeight值,而不是事后从TEXTMETRIC反推。
2.3 度量值的两个关键依赖:DC与DPI
前面强调了“当前选入DC”,这里的DC并不是虚拟概念,它直接决定度量结果的真实性。屏幕DC的度量值以当前DPI为基准,默认96 DPI下和150%缩放下拿到的tmHeight完全不同。而打印机DC的度量值以打印机硬件分辨率为基础,同一套代码绘制到屏幕和绘制到打印机,文字的实际物理尺寸会不一致。
处理方式是在自绘控件里始终使用GetDC或BeginPaint拿到的窗口DC,而不是用CreateCompatibleDC创建的兼容DC直接量字体。兼容DC的默认度量精度可能与显示DC有细微出入,某些驱动下会出现1像素的偏差,累积到多行文本排版时基本看不出问题,但对表格线框绘制这种需要像素级对齐的场景,确实可能暴露瑕疵。
2.4 中文环境下的特殊考量
TEXTMETRIC原本按西文字体设计,处理中文时有一些特殊表现需要留意。中文字体(如宋体、微软雅黑)的tmHeight和tmAscent通常远大于西文字体,因为汉字本身形体饱满、几乎没有下行部分,同时为了保证和拉丁字母混排时的视觉平衡,字体的内部前导空间会明显偏大。
这意味着如果直接用tmHeight作为文本显示控件的高度,中文文本上下会出现额外留白。实际的编辑器开发中,很多人会在tmHeight基础上做修正,比如减去部分tmInternalLeading,让中文行高看起来更紧凑。具体系数没有标准答案,参考tmInternalLeading / 2或者设定一个固定像素值,是两种常见的工程做法。当然,如果追求的是系统原生控件外观,直接用tmHeight反而是最稳妥的。
3. 实操过程与核心环节实现
3.1 最基础的三行调用
先看最简单的调用流程。核心实际只有三件事:拿到DC,选入字体,调用函数。
HDC hdc = GetDC(hwnd); // 或者 BeginPaint(hwnd, &ps) HFONT hFont = (HFONT)GetStockObject(DEFAULT_GUI_FONT); HFONT hOldFont = (HFONT)SelectObject(hdc, hFont); TEXTMETRIC tm = { 0 }; GetTextMetrics(hdc, &tm); SelectObject(hdc, hOldFont); ReleaseDC(hwnd, hdc);这个流程中的SelectObject和SelectObject恢复顺序不能反,尤其要注意在代码出现分支提前返回的场景里配好恢复逻辑。实际开发中常见的内存泄漏问题往往不是DC没释放,而是字体对象没恢复选择,导致后续绘制用了别人残留的字体。
3.2 完整示例:根据字体度量动态计算静态控件尺寸
实际项目中,有一个非常典型的应用是:创建一个静态文本控件,要求文字内容完整显示且控件高度恰好等于一行文本高度。盲目把高度写成20、24这类魔法数字,遇到用户把系统字体调大或切换到高DPI后,文字会被裁切或显示不完整。
这里给出一个完整的计算逻辑:
void CreateOneLineTextControl(HWND hwndParent, const wchar_t* text) { HDC hdc = GetDC(hwndParent); HFONT hFont = CreateFont(-16, 0, 0, 0, FW_NORMAL, FALSE, FALSE, FALSE, DEFAULT_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, CLEARTYPE_QUALITY, DEFAULT_PITCH | FF_DONTCARE, L"Segoe UI"); HFONT hOldFont = (HFONT)SelectObject(hdc, hFont); TEXTMETRIC tm = { 0 }; GetTextMetricsW(hdc, &tm); int cx = 0; int cy = tm.tmHeight - tm.tmInternalLeading; // 去除内部前导,贴合中文文本 SelectObject(hdc, hOldFont); ReleaseDC(hwndParent, hdc); // 再计算文本宽度 HDC hdcCalc = GetDC(NULL); SelectObject(hdcCalc, hFont); SIZE size = { 0 }; GetTextExtentPoint32W(hdcCalc, text, lstrlenW(text), &size); cx = size.cx; SelectObject(hdcCalc, hOldFont); ReleaseDC(NULL, hdcCalc); DeleteObject(hFont); // 这里用cx / cy作为控件尺寸创建控件 }这个示例里有两个值得注意的细节:第一,cy没有直接使用tmHeight,而是减去了tmInternalLeading。原因是tmHeight是按字体设计规格返回的完整行高,包含了字符内部为拼音符号、重音符号预留的空间,而普通GB2312中文文本几乎不会用到那些空间,直接作为控件高度会在四周留下过大的空隙。第二,宽度计算用的是GetTextExtentPoint32W而不是tmAveCharWidth乘字数,是因为平均字符宽度只适合估算,最终呈现效果保证文本完整显示还是需要精确测量。
当然,如果做的是文本编辑器而不是单个控件,行高直接用tmHeight + tmExternalLeading才是标准做法,这样能保证多行文本之间有稳定的行距。两种场景的服务目标不同,需要分别对待。
3.3 进阶应用:自绘控件的字体度量缓存策略
自绘控件在主窗口消息循环里会收到频繁的WM_PAINT,如果在每次绘制时都调用GetTextMetrics,虽然GDI本身开销不大,但考虑到自绘控件往往涉及多个DC操作,加上字体对象反复创建销毁,在大量控件同时重绘的界面里仍然会产生可感知的性能损耗。
工程上的惯用手法是建立字体度量缓存。在控件初始化或字体变更事件里做一次测量,把结果保存到成员变量,绘制时直接读取缓存。代码结构大致如下:
void MyControl::UpdateFontMetrics() { HDC hdc = ::GetDC(m_hwnd); HFONT hOldFont = (HFONT)::SelectObject(hdc, m_hFont); ::GetTextMetrics(hdc, &m_tm); ::SelectObject(hdc, hOldFont); ::ReleaseDC(m_hwnd, hdc); m_metricsValid = TRUE; } void MyControl::OnPaint() { PAINTSTRUCT ps; HDC hdc = ::BeginPaint(m_hwnd, &ps); if (!m_metricsValid) { UpdateFontMetrics(); } // 使用 m_tm 进行排版 ::EndPaint(m_hwnd, &ps); }注意这里要做失效处理:当WM_SETTINGCHANGE、WM_DPICHANGED或字体被外部修改时,必须把m_metricsValid置为FALSE,让下次绘制时重新测量。这个细节如果漏掉,缓存就会在系统DPI变化后返回旧值,出现界面错乱的隐蔽bug,调试起来通常比较费劲。
3.4 在打印机DC上的应用差异
打印机DC是比较容易被忽略的环节。做报表打印时,如果在屏幕DC上量字体,再拿这个结果直接控制打印位置,打印质量会出问题。正确的做法是创建打印机DC后,选入字体并重新调用GetTextMetrics,以打印机DC返回的值为基准做排版。
HDC hdcPrinter = CreateDCW(L"WINSPOOL", printerName, NULL, NULL); HFONT hFont = CreateFont(-MultDiv(12, 96, 1440), ...); // 按打印机逻辑分辨率换算 SelectObject(hdcPrinter, hFont); TEXTMETRIC tmPrinter = { 0 }; GetTextMetrics(hdcPrinter, &tmPrinter); // 以 tmPrinter 为准执行后续排版核心原因是打印机DC的度量单位是设备单位,与屏幕不一样。同样一个12号的字体,在300DPI打印机上返回的tmHeight数值会比96DPI屏幕大好几倍,直接用屏幕度量值去排打印版式会在物理上错位。这也是很多打印排版代码在屏幕预览正常、打印却偏移的原因所在。
4. 常见问题与排查技巧实录
4.1 拿到的度量值全是零
如果GetTextMetrics返回FALSE,且GetLastError没有具体信息,最常见的原因是调用之前DC里没有选入任何有效字体。在没有显式选入字体的DC中,GDI会使用系统默认字体,但某些特殊DC(比如GetDC(NULL)取得的屏幕DC、未初始化的内存DC)上,默认字体可能是System字体,度量值能拿到,但不是你预期的字体。
更隐蔽的一个情况是传入的DC句柄已经被释放。某些封装类把DC生命周期限定在某个作用域内,退出作用域后DC失效,但代码里仍然在后续函数中使用这个句柄调用GetTextMetrics,此时函数返回FALSE。排查方向是检查DC的创建和释放位置是否成对,以及是否错误地在ReleaseDC之后继续使用DC句柄。
4.2 中文系统上字体返回值异常
有项目反馈在中文系统上,使用中文界面字体(默认“微软雅黑”或“宋体”)时,tmAveCharWidth值偏小,导致用这个值估算表格列宽时中文内容显示不下。这其实是正常现象,tmAveCharWidth是西文字母的平均宽度,中文字符的宽度在这个度量体系里通常接近tmMaxCharWidth,远大于平均值。估算中文文本宽度时,不能只用tmAveCharWidth乘以中文字符数,需要按中英文混合内容分别估算,更准确的做法是直接对目标文本调用GetTextExtentPoint32。
实测过微软雅黑字体在96DPI下,tmAveCharWidth大约为7像素,tmMaxCharWidth约14像素,而一个中文汉字的实际宽度通常是12像素。如果一个表格列宽按“字长 × tmAveCharWidth”估算,五六个中文汉字就会明显溢出。这个案例经常被拿来做反面教材。
4.3 DPI缩放后度量值没变
Windows高DPI环境下的一个经典坑:程序没有声明DPI感知时,系统会对窗口进行位图拉伸,GetTextMetrics返回的仍然是虚拟化的96DPI度量值,但实际绘制在屏幕上却是拉伸后的效果,两者不一致。
如果程序适配了DPI感知,在WM_DPICHANGED之后重新获取度量值即可。如果使用的是旧版Compatibility模式(系统缩放),建议逐步迁移到Per-Monitor V2 DPI Aware模式,让GetTextMetrics的真实返回值对应于物理像素,然后按照返回值重新计算行高和字符宽度。这个调整会影响到所有用固定像素写死的布局代码,属于比较大的改动,但长期来看是唯一靠谱的方向。
4.4 用GetTextMetrics检测等宽字体的误区
前面提过TMPF_FIXED_PITCH的命名反直觉问题,这里再补充一点。与它相对的TMPF_VECTOR位表示字体是否为矢量字体(TrueType/OpenType),旧式点阵字体这一位通常为0。如果项目要确保“字符严格等宽”这一属性,最可靠的方式不是依赖GetTextMetrics的这两个标志位,而是直接校验tmMaxCharWidth == tmAveCharWidth。这个判断对等宽字体一定成立,对比例字体几乎不可能成立,逻辑上更干净。
实测包括Consolas、Courier New、中文等宽字体里的部分字体,这个等值判断全部通过。使用这个方式判断时注意不要被字体自身的微调机制干扰,某些字体在同一字符集内可能有细微的比例差异,但不会在整数像素上体现出来。
4.5 字体被替换导致度量值不符合预期
这是非常特殊但确实发生过的情况:系统字体链接和字体替换机制会影响GetTextMetrics的返回值。比如请求“宋体”但系统里没有该字体时,字体引擎会替换成其他中文字体,tmFaceName会变成实际使用的字体名称。在代码里比较tmFaceName与期望字体名的逻辑需要小心,不能假定CreateFont传入的名字一定原样返回。
排查这类问题时,可以临时在GetTextMetrics后把tm.tmFaceName输出到日志。如果看到实际名称与请求名称不一致,就说明走到了字体替换路径,需要确认目标字体是否真实安装、CreateFont的字符集参数是否与目标字体兼容。字体替换现象多发生在某些精简版系统或公司管控环境里,Windows组件缺失导致原始字体被移除了。
5. 实操总结与经验备忘
随手整理几条我在多个项目里反复验证过的经验:
- 字体度量只能相信“当前DC当前字体”下的真实返回值,不要从字体名称或字号做经验推算,屏幕和打印机的差异非常大。
tmHeight包含内部前导,直白的行高计算常常让文本上下留白太多。区分场景:编辑器和多行文本用tmHeight + tmExternalLeading;自绘单行控件和静态文本常减掉部分tmInternalLeading。- 中英文混排时,不要用
tmAveCharWidth推中文宽度,中文文本的实际宽度更接近字符数 ×tmAveCharWidth的1.5到2倍。精确宽度直接用GetTextExtentPoint32测量。 tmPitchAndFamily判断等宽字体时小心宏名与结果相反,稳妥做法是直接比较tmMaxCharWidth与tmAveCharWidth是否相等。- DPI感知代码中,字体度量值必须随
WM_DPICHANGED刷新,否则缓存值会导致整个界面排版错乱。 - 打印机DC做打印排版时务必重新获取度量值,屏幕DC的结果不能直接跨设备复用。
- 字体对象选入DC后,用完务必恢复原字体再删除字体对象,否则GDI对象泄漏会导致程序运行一段时间后字体全部变成方框。
说实话,GetTextMetrics是我在接触GDI的早期阶段就遇到的函数,当年只是照着文档抄了一遍结构体的打印代码,没觉得有什么特别。直到后来做报表模块被打印偏移折磨了整整两天,才开始真正理解“DC不同,度量不同”这个老法师们反复强调的道理。这些年做原生Windows开发,见过太多同行在文本排版上靠固定像素走天下,遇到高分屏或系统字体调整就崩得一塌糊涂,本质都是没把字体的真实度量当回事。凡是写过自绘界面的开发者,都值得在自己常用的脚本库里存一个封装好的GetTextMetrics工具函数,用一次就会明白,这一步走出去,后续能省下的调试时间绝对值得回票。