做自定义文本渲染的开发者几乎都会遇到同一个问题:同样的字号,不同的字体渲染出来的行高却不一样,基线位置也总是对不上设计稿。这不是bug,而是每个字体文件内部自带一套度量数据(metrics),Core Text在排版时就是依据这套数据来计算行高和基线的。理解并正确读取这些度量值,是掌握macOS文本排版的关键一步。

字体度量的核心概念:ascent、descent、leading
在深入API之前,先要把几个容易混淆的概念厘清。字体坐标系以基线(baseline)为原点,向上为正方向(macOS默认的Core Text坐标系是y轴向上的,这点和UIKit的坐标系相反)。
ascent(上升值)指的是从基线到字体最高字形顶部的距离。注意这里说的是字形,不是em框。比如字母f、l或者带声调的é,它们的顶部决定了ascender的位置。descent(下降值)是从基线向下延伸的深度,像字母g、y、p的下伸部分都落在这一区域。在Core Text中,descent返回的是一个正值,表示基线以下的距离(在y轴向上的坐标系里,字形实际的y坐标是负的,但API返回值取了绝对值)。leading(前导)是字体设计者建议的行间距额外空间,行高通常等于ascent加descent加leading。
另一个重要概念是unitsPerEm(每个em对应的字体设计单位数),常见的TrueType字体是2048,OpenType字体是1000。字体文件中存储的原始度量值都是设计单位,需要除以unitsPerEm再乘以字号才能换算成实际的点数。Core Text的API已经帮你做了这层换算,直接返回以点为单位浮点值。
用Core Text API读取字体度量数据
Core Text没有名为CTFontMetrics的直接结构体,字体度量信息都通过CTFont的一系列函数获取。最常用的有CTFontGetAscender、CTFontGetDescender、CTFontGetLeading,以及获取更完整信息的CTFontCopyTraits和CTFontGetVerticalOffsets。
下面是一段Swift代码,演示如何创建CTFont并读取全部度量信息:
import CoreText
let font = CTFontCreateWithName("Helvetica" as CFString, 16.0, nil)
let ascent = CTFontGetAscender(font) // 上升值
let descent = CTFontGetDescent(font) // 下降值
let leading = CTFontGetLeading(font) // 前导值
let unitsPerEm = CTFontGetUnitsPerEm(font) // 设计单位数
let capHeight = CTFontGetCapHeight(font) // 大写字母高度
let xHeight = CTFontGetXHeight(font) // 小写字母x的高度
let lineHeight = ascent + descent + leading
print("ascent: \(ascent), descent: \(descent), leading: \(leading)")
print("行高: \(lineHeight)")
以Helvetica 16pt为例,ascent约为14.35,descent约为4.11,leading为0,算出来的行高约为18.46。你可以用Font Book或者跨平台的字体查看工具核对字体文件中的hhea表数据,会发现这些值正是从表里换算出来的。
如果需要更细致的基线信息,比如上标基线、下标基线、斜体字的旁侧偏移,可以用CTFontGetOpticalBoundsForGlyph配合CTFontGetBaselineOffsets类函数。另外,CTFontCreatePathForGlyph返回的字形路径坐标也是以基线为原点的,做文字转矢量图形时必须先理解这一点。
Objective-C环境下的度量获取与实践
老项目大量使用Objective-C,写法上略有区别但API完全一致。同时要注意CFRelease的手动内存管理:
#import <CoreText/CoreText.h>
CTFontRef font = CTFontCreateWithName(CFSTR("PingFangSC-Regular"), 14.0, NULL);
CGFloat ascent = CTFontGetAscender(font);
CGFloat descent = CTFontGetDesender(font); // 注意正确函数名是 CTFontGetDescender
CGFloat descentValue = CTFontGetDescender(font);
CGFloat leading = CTFontGetLeading(font);
// 计算一行文字所需的垂直空间
CGFloat lineHeight = ceil(ascent + descentValue + leading);
NSLog(@"行高: %f", lineHeight);
CFRelease(font);
上面代码特意保留了一个常见笔误示范:CTFontGetDescender容易拼错,正确拼写是Descender而不是Desender,编译器会直接报错,但如果不小心写成了其他相近函数,就会得到意料之外的结果。
实际项目中经常需要计算某段文字的总高度。配合CTFramesetter使用时,更推荐直接用CTFramesetterSuggestFrameSizeWithConstraints,它会自动考虑换行后的多行情况:
NSAttributedString *attrString =
[[NSAttributedString alloc] initWithString:@"测量这段文字的高度"
attributes:@{(id)kCTFontAttributeName: (__bridge id)font}];
CTFramesetterRef framesetter =
CTFramesetterCreateWithAttributedString((CFAttributedStringRef)attrString);
CGSize size = CTFramesetterSuggestFrameSizeWithConstraints(
framesetter, CFRangeMake(0, 0), NULL, CGSizeMake(300, CGFLOAT_MAX), NULL);
NSLog(@"建议尺寸: %@", NSStringFromCGSize(size));
CFRelease(framesetter);
中文字体与多字体混排的度量陷阱
处理中文内容时,度量问题会集中爆发。中文字形的笔画往往超出拉丁字体的em框,所以中文字体的ascent和descent普遍偏大。以苹方PingFang SC为例,14pt下ascent加descent约19.6,明显大于Helvetica。这就是为什么很多App切换到中文字体后行距突然变松的原因。
富文本混排时,一行内可能有多个字体和字号,Core Text的做法是取这一行中所有字体度量的最大值来决定行高。实现自定义排版引擎时,你需要遍历该行每个CTRun的属性,用CTRunGetTypographicBounds拿到每个run的ascent、descent:
var ascent: CGFloat = 0
var descent: CGFloat = 0
var leading: CGFloat = 0
let width = CTRunGetTypographicBounds(run, CFRange(location: 0, length: 0),
&ascent, &descent, &leading)
// width是该run的排版宽度,行高则取各行中最大的 ascent + descent
还有一个典型陷阱:有些字体的leading是负数或零,有些字体的descent设计值包含大量内部留白。如果产品要求视觉行高固定,不能简单依赖字体自带度量,常见做法是用kCTParagraphStyleSpecifierLineHeightMultiple或者手动设置行间距,把字体度量和视觉需求解耦。
CTFont与UIFont度量行为的差异
在AppKit和UIKit中,UIFont(macOS 10.15后NSFont与UIFont统一)是对CTFont的封装,但两者的度量API存在微妙差异。UIFont的ascender属性和CTFontGetAscender的返回值在个别字体上并不完全相同,因为UIFont可能会做平台的行高补偿处理。
另外要留意CTLineGetTypographicBounds返回的行高度量,与CTFrameDraw绘制时行的origin.y计算是配套的。如果你自己用CTFrameGetLines拿到CTLine数组后逐行绘制,每行的绘制起点是baseline位置,也就是lineOrigin.y加上ascent才是这一行文字视觉上的顶部:
let lines = CTFrameGetLines(frame) as! [CTLine] var origins = [CGPoint](repeating: .zero, count: lines.count) CTFrameGetLineOrigins(frame, CFRange(location: 0, length: 0), &origins) context.saveGState() context.textPosition = origins[0] // 该行基线位置 CTLineDraw(lines[0], context) context.restoreGState()
总结一下要点:先通过CTFontGetAscender等函数拿到以点为单位的度量值;绘制时记住返回的是基线原点,不是顶部坐标;混排时取各行度量的最大值;中文场景不要迷信字体自带的行高,必要时用段落样式覆盖。掌握这些之后,无论是做富文本编辑器、表格控件还是自定义排版引擎,行高和基线都不再是玄学问题。
CTFontMetricsCore Text字体度量修改时间:2026-09-14 04:27:46