NSLayoutManager如何获取字符实际渲染字体(含不支持字符场景)
在使用NSLayoutManager展示多语言翻译文本时,若通过drawGlyph(forRange:, at:)绘制自定义下划线(系统默认下划线无法满足需求),会遇到以下问题:当文本字符属于设置的字体族时显示正常,但遇到字体不支持的字符(如中文、印地语、泰语等),AppKit会自动替换为支持该字符的fallback字体,此时字符高度发生变化,导致下划线位置偏移(例如中文下划线比拉丁字符高约5像素)。
问题核心在于:计算下划线位置时需要依赖实际渲染字体的defaultLineHeight和descender属性,但无法直接获取系统自动选用的fallback字体,使用初始设置的字体计算会得到错误的位置参数。
解决方案
利用NSLayoutManager的attributes(forGlyphAt:effectiveRange:)方法,获取每个glyph对应的实际渲染字体。该方法会返回当前glyph的所有属性,其中包含系统自动选用的fallback字体信息,无论字符属于哪种语言。
单区段下划线绘制(适用于同字体的连续glyph)
替换原代码中依赖初始字体的逻辑,改为获取实际渲染字体后计算位置:
// 获取当前glyph范围对应的实际字体属性 var effectiveCharRange = NSRange() let attributes = layoutManager.attributes(forGlyphAt: glyphRange.location, effectiveRange: &effectiveCharRange) guard let actualFont = attributes[.font] as? NSFont else { return } // 用实际字体计算行高和下划线位置 let height = defaultLineHeight(for: actualFont) var rect = boundingRect(forGlyphRange: glyphRange, in: textContainer) rect.size.height = height var origin = rect.origin // 使用实际字体的descender计算偏移 origin.y += (rect.height + actualFont.descender + 1) // 绘制自定义下划线 let underlineRect = NSRect(origin: origin, size: NSSize(width: rect.width, height: thickness)) let path = NSBezierPath(roundedRect: underlineRect, xRadius: 0, yRadius: 0) path.fill()
多区段下划线绘制(适用于混合多种fallback字体的文本)
如果目标glyph范围跨越了多种不同的fallback字体,需要遍历每个连续的同字体区段,分别计算并绘制下划线:
var currentGlyphLocation = glyphRange.location let endGlyphLocation = glyphRange.location + glyphRange.length while currentGlyphLocation < endGlyphLocation { var effectiveGlyphRange = NSRange() var effectiveCharRange = NSRange() // 获取当前位置glyph的属性及有效范围 let attributes = layoutManager.attributes(forGlyphAt: currentGlyphLocation, effectiveRange: &effectiveGlyphRange, actualCharacterRange: &effectiveCharRange) guard let actualFont = attributes[.font] as? NSFont, // 确保当前有效范围在目标glyph范围内 let currentGlyphRange = NSRange(location: currentGlyphLocation, length: effectiveGlyphRange.length).intersection(glyphRange) else { break } // 计算当前区段的下划线位置 let height = defaultLineHeight(for: actualFont) var rect = layoutManager.boundingRect(forGlyphRange: currentGlyphRange, in: textContainer) rect.size.height = height var origin = rect.origin origin.y += (rect.height + actualFont.descender + 1) // 绘制当前区段的下划线 let underlineRect = NSRect(origin: origin, size: NSSize(width: rect.width, height: thickness)) let path = NSBezierPath(roundedRect: underlineRect, xRadius: 0, yRadius: 0) path.fill() // 移动到下一个区段的起始位置 currentGlyphLocation = currentGlyphRange.location + currentGlyphRange.length }
原理说明
NSLayoutManager在处理字符渲染时,会自动将fallback字体的信息注入到对应glyph的属性字典中。通过attributes(forGlyphAt:effectiveRange:)可以直接获取到每个glyph实际使用的渲染字体,以此为基础计算的下划线位置会完全匹配当前字符的显示高度,解决多语言场景下的错位问题。
内容的提问来源于stack exchange,提问作者Peter R

