在macOS和iOS的原生开发中,如果只是简单显示一段文字,UILabel或NSTextField完全够用。但一旦涉及杂志级排版、脚本语言渲染、逐字形绘制或者自定义断行策略,就必须下沉到Core Text层。Core Text把排版拆成几个职责清晰的组件:CTTypesetter负责把字符流转换成字形流并给出断行建议,CTLine代表一行,CTRun代表一行中属性连续的字形序列,CTFrame则管理整段排版框架。其中CTTypesetter是整个流水线的起点,理解它的断行算法和字形处理机制,是做高级文本排版绕不开的一关。

一、CTTypesetter的工作流程与基本用法
CTTypesetter接收一个CFAttributedString对象作为输入,这个富文本字符串里携带了字体、字号、颜色、语言等各种属性。Typesetter会遍历这些字符,把每个字符映射为字形(glyph),处理字符到字形的复杂转换,比如一个带变音符的字符可能被替换为组合字形,阿拉伯语、天城文等复杂文字系统更会经历大规模的字形重排。最终CTTypesetter通过CTTypesetterCreateWithAttributedString创建,再用CTTypesetterSuggestLineBreak确定每行放多少内容。
最基础的用法如下面的Swift代码所示,先构造带属性的字符串,创建typesetter,然后在循环里反复询问断行位置,逐行生成CTLine并绘制:
import CoreText
let attributedString = NSMutableAttributedString(
string: "Core Text 排版引擎:CTTypesetter 断行测试 Hello World"
)
// 设置全局字体属性
let font = CTFontCreateWithName("PingFang SC" as CFString, 16, nil)
attributedString.setAttribute(.font, value: font)
// 关闭连字,观察断行差异
attributedString.setAttribute(.ligature, value: 0)
let typesetter = CTTypesetterCreateWithAttributedString(attributedString)
let stringLength = attributedString.length
var lineOrigin = 0
let lineWidth: Double = 300 // 每行最大宽度
while lineOrigin < stringLength {
// 核心API:给定起始位置和行宽,返回本行应包含的字符数
let count = CTTypesetterSuggestLineBreak(
typesetter, lineOrigin, lineWidth, .double
)
let lineString = attributedString.attributedSubstring(
from: NSMakeRange(lineOrigin, count)
)
let line = CTLineCreateWithAttributedString(lineString)
print("行内容:\(lineString.string)")
lineOrigin += count
}
这里有个容易混淆的点:CTTypesetterSuggestLineBreak返回的是字符个数(UTF-16编码单元数),而不是字形个数,所以外部循环必须用字符串索引推进。另外第三个参数doubleWidth影响的是CJK文本的断行倾向,传.double时韩文类宽字符倾向占两列,中文场景下这个参数的选择会影响标点悬挂等细节表现。
二、断行算法的原理与自定义控制
Core Text默认使用类Knuth-Plass的断行模型,综合权衡行的松紧程度,尽量避免某一行特别松下一行特别挤的情况。它还内置了大量Unicode断行规则:不能出现在行首的字符(如中文逗号、句号、右引号)、不能出现在行尾的字符(如左引号、左括号),以及对英文单词内部的处理策略。这些默认行为对绝大多数场景已经足够好,但总有一些场景需要干预。
第一种干预手段是调整字符串属性。给某个不可拆分的片段整体设置kCTRunDelegateAttributeName,或者把需要整体换行的部分(比如内嵌的公式、表情组合)包成一个原子单元,typesetter就不会在它内部断行。第二种手段是在获得断行建议后自己做后处理:比如检测到行尾正好是行尾禁则字符时,手动回退一个字符的宽度。下面的代码演示了这种校验思路:
func suggestLineWithValidation(
_ typesetter: CTTypesetter,
origin: Int,
attributed: NSAttributedString,
width: Double
) -> CFIndex {
var count = CTTypesetterSuggestLineBreak(typesetter, origin, width, .double)
let forbiddenLineStart: Set<Character> = [",", "。", "!", "?", "”", "、"]
if origin + count < attributed.length {
let nextIndex = attributed.string.index(
attributed.string.startIndex, offsetBy: origin + count
)
if forbiddenLineStart.contains(attributedString(nextIndex)) {
count = max(0, count - 1) // 回退一个字符,避免标点悬挂到行首
}
}
return count
}
需要注意的是,CTTypesetterSuggestLineBreakWithOffset变体允许传入一个偏移量,用于实现首行缩进这类场景——首行可用的宽度比后续行少一个缩进值时,用这个API比手动截断更精确。而如果要实现超长英文单词的截断(加连字符截词),Core Text本身不支持自动连字符断词,需要自己加载连字词典或者借助kCTHyphenationFactorAttributeName,该属性设置为0到1之间的值可以控制英文断词的积极程度,对中文无效果。
三、连字处理与字形替换
连字(ligature)是指多个字符被渲染成一个合并字形,最经典的是fi、fl组合。Core Text通过kCTLigatureAttributeName控制,值为0关闭所有连字,1开启必需连字,2开启全部连字(包括ff、ffi等可选组合)。系统字体如Helvetica Neue、SF Pro都包含这些OpenType特性。字形替换的范围比连字更广,包括小型大写字母、老式数字、上下标、花体替代等,这些都是通过OpenType特性开关实现的。
在Core Text层控制OpenType特性的方式是构造字体特性属性并合并到字体描述中,代码如下:
import CoreText
func fontWithSmallCaps(_ baseFont: CTFont) -> CTFont {
// 定义要开启的特性:小型大写字母 smcp = 1
let features: [[CFString: Any]] = [
[kCTFontFeatureTypeIdentifier: kCTFontFeatureTypeLowerCase as Any,
kCTFontFeatureSelectorIdentifier: kCTFontFeatureSelectorUpperAndLowerCase as Any]
]
var attributes: [CFString: Any] = [:]
attributes[kCTFontFeaturesAttribute] = features
// 合并生成新的字体描述符
let desc = CTFontDescriptorCreateWithAttributes(attributes as CFDictionary)
return CTFontCreateCopyWithAttributes(baseFont, 0, nil, desc)
}
语言环境对字形替换同样有影响。kCTLanguageAttributeName设为zh-Hans、ja或en等,会让typesetter为同一字符挑选不同的本地化字形——最直观的例子是中文和日文环境下“骨”“直”等汉字的细微笔画差异。此外,kCTCharacterShapeAttributeName可以控制简繁体转换,这在需要强制字形形态的出版场景里很有用。
拿到CTLine之后,可以通过CTLineGetGlyphRuns遍历其中的CTRun,用CTRunGetGlyphs检查实际的字形索引,验证连字和替换是否生效。一个fi连字在run里会表现为一个字形对应两个字符索引,这也是调试文本渲染问题最直接的证据。
四、性能优化与工程实践建议
CTTypesetter的创建是有开销的,尤其是长文本包含复杂脚本时。实践中应当缓存typesetter实例,同一份富文本多次分页或重排时复用它;分页场景下优先使用CTFramesetter——它内部封装了typesetter并管理整帧排版,除非确实需要逐行干预断行,否则不必手工驱动CTTypesetter。
绘制层面也有讲究。在视图的drawRect或layer的绘制回调中,翻转坐标系后直接调用CTLineDraw,配合CTLineGetTypographicBounds获取行高。如果文本是静态的,把绘制结果缓存到CGLayer或位图上,滚动时只做位图搬运,性能可以提升一个量级。另外,在后台线程排版、主线程只做绘制是Core Text的传统优化套路,它的API在iOS 4之后基本都是线程安全的(只要不同线程不共享可变对象),这为复杂文档的异步分页提供了条件。
总结一下技术选型思路:普通界面文本用UIKit/AppKit控件;需要图文混排、自定义交互用TextKit;只有当需要像素级控制字形、跨平台共享排版代码(Core Graphics层面)、或者性能要求极端苛刻时,才值得直接使用CTTypesetter这一层。掌握它之后,断行、连字、字形替换这些看似黑盒的行为都会变得透明可控。
Core TextCTTypesetter文本排版修改时间:2026-08-31 16:33:09