用Swift中的NSLayoutManager隐藏Markdown字符

12

我正在为Mac应用程序开发一个使用Markdown语法的富文本编辑器。我使用 NSTextStorage 监视 Markdown 语法中的匹配项,然后实时地将样式应用于 NSAttributedString,就像这样:

enter image description here

目前,我已经深陷其中,但我很兴奋能够取得进展。:) 这个教程非常有帮助

下一步,我想在 NSTextView 的字符串呈现时隐藏 Markdown 字符。因此,在上面的示例中,一旦输入了最后一个星号,我希望隐藏* *字符,只看到粗体显示的sample

我正在使用一个NSLayoutManager委托,并且可以看到匹配的字符串,但我不清楚如何使用shouldGenerateGlyphs方法生成修改后的字形/属性。以下是我目前的代码:

func layoutManager(_: NSLayoutManager, shouldGenerateGlyphs _: UnsafePointer<CGGlyph>, properties _: UnsafePointer<NSLayoutManager.GlyphProperty>, characterIndexes _: UnsafePointer<Int>, font _: NSFont, forGlyphRange glyphRange: NSRange) -> Int {
    let pattern = "(\\*\\w+(\\s\\w+)*\\*)" // Look for stuff like *this*
    do {
        let regex = try NSRegularExpression(pattern: pattern)
        regex.enumerateMatches(in: textView.string, range: glyphRange) {
            match, _, _ in
            // apply the style
            if let matchRange = match?.range(at: 1) {
                print(matchRange) <!-- This is the range of *sample*

                // I am confused on how to provide the updated properties below...
                // let newProps = NSLayoutManager.GlyphProperty.null
                // layoutManager.setGlyphs(glyphs, properties: newProps, characterIndexes: charIndexes, font: aFont, forGlyphRange: glyphRange)
                // return glyphRange.length
            }
        }
    } catch {
        print("\(error.localizedDescription)")
    }

    return 0
}

根据我找到的文本范围隐藏星号,我该如何修改这些内容以传递到setGlyphs中?

2个回答

26

2022免责声明

虽然当我最初提交这个答案时,我在运行这段代码时得到了一些不错的结果,但另一个SO用户(Tim S.)警告我,在某些情况下将.null字形属性应用于某些字形可能会导致应用程序挂起或崩溃。

据我所知,这只会发生在.null属性上,并且大约在8192(2^13)字形处...我不知道为什么,而且老实说,它看起来像是一个TextKit bug(或者至少不是TextKit工程师预期的框架使用方式)。

对于现代应用程序,我建议您查看一下TextKit 2,该框架应该可以抽象出字形处理并简化所有这些东西(免责声明:我还没有尝试过它)。


前言

我实现了这种方法来实现我的应用程序中类似的功能。请记住,这个API的文档非常不好,因此我的解决方案基于试验和错误,而不是对所有移动部件的深入理解。

简而言之:它应该可以工作,但使用风险自负:)

还要注意,我在这个答案中详细讲述了很多细节,希望可以让任何Swift开发人员都可以看懂,即使没有Objective-C或C的背景。您可能已经知道以下某些内容。

关于TextKit和字形

重要的一点是要理解的是,一个字形是一个或多个字符的视觉表示,正如在WWDC 2018 Session 221“TextKit最佳实践”中所解释的那样:

slide of session 221 explaining the difference between characters and glyphs

我建议您观看整个演讲。虽然在理解layoutManager(_:shouldGenerateGlyphs:properties:characterIndexes:font:forGlyphRange:)方法的特定情况下不是非常有用,但它提供了有关TextKit的一般信息。

理解shouldGenerateGlyphs

因此,从我所理解的来看,每次NSLayoutManager即将生成新的字形以在渲染之前,它都会给您一个机会通过调用layoutManager(_:shouldGenerateGlyphs:properties:characterIndexes:font:forGlyphRange:)方法来修改此字形。

修改字形

根据文档,如果您要修改字形,则应在此方法中通过调用setGlyphs(_:properties:characterIndexes:font:forGlyphRange:)来实现。

幸运的是,setGlyphs期望与我们在shouldGenerateGlyphs中传递的完全相同的参数。这意味着理论上您可以使用对setGlyphs的调用来实现shouldGenerateGlyphs,一切都会很好(但这不会非常有用)。

返回值

文档还指出,shouldGenerateGlyphs的返回值应为

func layoutManager(_ layoutManager: NSLayoutManager, shouldGenerateGlyphs glyphs: UnsafePointer<CGGlyph>, properties: UnsafePointer<NSLayoutManager.GlyphProperty>, characterIndexes: UnsafePointer<Int>, font: UIFont, forGlyphRange glyphRange: NSRange) -> Int {
    layoutManager.setGlyphs(glyphs, properties: fixedPropertiesPointer, characterIndexes: characterIndexes, font: font, forGlyphRange: glyphRange)

    return glyphRange.length
}

这个方法等价于仅仅返回0

通过返回0,可以通知布局管理器执行默认处理。

做一些有用的事情

现在,我们如何编辑字形属性,使得该方法执行一些有用的操作(如隐藏字形)?

访问参数值

shouldGenerateGlyphs的大多数参数都是UnsafePointer类型,这是TextKit C API泄漏到Swift层的结果,也是实现此方法的麻烦之一。

关键点是这里所有类型为UnsafePointer的参数都是数组(在C中,SomeType *——或其Swift等效项UnsafePointer<SomeType>——是表示数组的方式),这些数组的长度都为glyphRange.length。这在setGlyphs方法中间接地记录了:

每个数组都有glyphRange.length项

这意味着使用Apple提供的好用的UnsafePointer API,我们可以使用以下循环遍历这些数组中的元素:

for i in 0 ..< glyphRange.length {
    print(properties[i])
}

UnsafePointer会根据传递给下标的任何索引来执行指针算术以访问正确地址上的内存。我建议阅读UnsafePointer文档,这非常酷。

setGlyphs传递有用的内容

现在我们能够打印参数的内容并检查框架为每个字形提供的属性。那么,如何修改这些属性并将结果传递给setGlyphs呢?

首先,需要注意的是,虽然我们可以直接修改properties参数,但这可能是一个坏主意,因为该内存块不归我们所有,我们不知道一旦退出方法后框架会对该内存块做什么。

因此,正确的方法是创建自己的字形属性数组,然后将其传递给setGlyphs

var modifiedGlyphProperties = [NSLayoutManager.GlyphProperty]()
for i in 0 ..< glyphRange.length {
    // This contains the default properties for the glyph at index i set by the framework.
    var glyphProperties = properties[i]
    // We add the property we want to the mix. GlyphProperty is an OptionSet, we can use `.insert()` to do that.
    glyphProperties.insert(.null)
    // Append this glyph properties to our properties array.
    modifiedGlyphProperties.append(glyphProperties)
}

// Convert our Swift array to the UnsafePointer `setGlyphs` expects.
modifiedGlyphProperties.withUnsafeBufferPointer { modifiedGlyphPropertiesBufferPointer in
    guard let modifiedGlyphPropertiesPointer = modifiedGlyphPropertiesBufferPointer.baseAddress else {
        fatalError("Could not get base address of modifiedGlyphProperties")
    }

    // Call setGlyphs with the modified array.
    layoutManager.setGlyphs(glyphs, properties: modifiedGlyphPropertiesPointer, characterIndexes: characterIndexes, font: font, forGlyphRange: glyphRange)
}

return glyphRange.length

properties 数组中读取原始字形属性,并在此基础值上 添加 自定义属性(使用 .insert() 方法)。否则,您将覆盖字形的默认属性,导致奇怪的问题发生(例如我曾看到过换行符 \n 不再插入视觉换行符)。

决定隐藏哪些字形

先前的实现应该工作良好,但现在我们无条件地隐藏所有生成的字形,如果只有某些字形被隐藏会更加有用(例如当字形为 * 时)。

根据字符值来隐藏

为了实现这一点,您可能需要访问用于生成最终字形的字符。然而,框架并不提供字符本身,而是提供每个生成字形所在字符串中的索引值。您需要遍历这些索引并查找 NSTextStorage 中对应的字符。

不幸的是,这不是一个简单的任务:Foundation 使用 UTF-16 码元来内部表示字符串(NSString 和 NSAttributedString 在底层使用它)。因此,框架提供的 characterIndexes 并不是通常意义下的“字符”索引,而是 UTF-16 码元的索引

大多数情况下,每个 UTF-16 码元将被用于生成唯一的字形,但在某些情况下,多个码元将被用于生成唯一的字形(这被称为 UTF-16 代理对,在处理带有表情符号的字符串时很常见)。我建议您使用一些更“外国”的字符串来测试代码,例如:

textView.text = "Officiellement nous (‍‍‍) vivons dans un cha\u{0302}teau  海"
因此,为了能够比较我们的字符,我们首先需要将它们转换为通常所指的“字符”的简单表示形式:

因此,为了能够比较我们的字符,我们首先需要将它们转换为通常所指的“字符”的简单表示形式:

/// Returns the extended grapheme cluster at `index` in an UTF16View, merging a UTF-16 surrogate pair if needed.
private func characterFromUTF16CodeUnits(_ utf16CodeUnits: String.UTF16View, at index: Int) -> Character {
    let codeUnitIndex = utf16CodeUnits.index(utf16CodeUnits.startIndex, offsetBy: index)
    let codeUnit = utf16CodeUnits[codeUnitIndex]

    if UTF16.isLeadSurrogate(codeUnit) {
        let nextCodeUnit = utf16CodeUnits[utf16CodeUnits.index(after: codeUnitIndex)]
        let codeUnits = [codeUnit, nextCodeUnit]
        let str = String(utf16CodeUnits: codeUnits, count: 2)
        return Character(str)
    } else if UTF16.isTrailSurrogate(codeUnit) {
        let previousCodeUnit = utf16CodeUnits[utf16CodeUnits.index(before: codeUnitIndex)]
        let codeUnits = [previousCodeUnit, codeUnit]
        let str = String(utf16CodeUnits: codeUnits, count: 2)
        return Character(str)
    } else {
        let unicodeScalar = UnicodeScalar(codeUnit)!
        return Character(unicodeScalar)
    }
}

然后我们可以使用这个函数从我们的textStorage中提取字符,并对它们进行测试:

// First, make sure we'll be able to access the NSTextStorage.
guard let textStorage = layoutManager.textStorage else {
    fatalError("No textStorage was associated to this layoutManager")
}


// Access the characters.
let utf16CodeUnits = textStorage.string.utf16
var modifiedGlyphProperties = [NSLayoutManager.GlyphProperty]()
for i in 0 ..< glyphRange.length {
    var glyphProperties = properties[i]
    let character = characterFromUTF16CodeUnits(utf16CodeUnits, at: characterIndex)

    // Do something with `character`, e.g.:
    if character == "*" {
        glyphProperties.insert(.null)
    }
    
    modifiedGlyphProperties.append(glyphProperties)
}
    
// Convert our Swift array to the UnsafePointer `setGlyphs` expects.
modifiedGlyphProperties.withUnsafeBufferPointer { modifiedGlyphPropertiesBufferPointer in
    guard let modifiedGlyphPropertiesPointer = modifiedGlyphPropertiesBufferPointer.baseAddress else {
        fatalError("Could not get base address of modifiedGlyphProperties")
    }

    // Call setGlyphs with the modified array.
    layoutManager.setGlyphs(glyphs, properties: modifiedGlyphPropertiesPointer, characterIndexes: characterIndexes, font: font, forGlyphRange: glyphRange)
}

return glyphRange.length

注意,在代理对的情况下,循环将执行两次(一次在主代理上,一次在辅助代理上),你最终会比较相同的结果字符两次。虽然如此,由于需要对生成的字形的“部分”应用相同的修改,这是可以接受的。

根据TextStorage字符串属性隐藏

这并不是你在问题中要求的,但为了完整起见(并且因为这是我在我的应用程序中所做的),这里介绍一下如何访问textStorage字符串属性以隐藏某些字形(在此示例中,我将隐藏所有具有超文本链接的文本部分):

// First, make sure we'll be able to access the NSTextStorage.
guard let textStorage = layoutManager.textStorage else {
    fatalError("No textStorage was associated to this layoutManager")
}

// Get the first and last characters indexes for this glyph range,
// and from that create the characters indexes range.
let firstCharIndex = characterIndexes[0]
let lastCharIndex = characterIndexes[glyphRange.length - 1]
let charactersRange = NSRange(location: firstCharIndex, length: lastCharIndex - firstCharIndex + 1)

var hiddenRanges = [NSRange]()
textStorage.enumerateAttributes(in: charactersRange, options: []) { attributes, range, _ in
    for attribute in attributes where attribute.key == .link {
        hiddenRanges.append(range)
    }
}

var modifiedGlyphProperties = [NSLayoutManager.GlyphProperty]()
for i in 0 ..< glyphRange.length {
    let characterIndex = characterIndexes[i]
    var glyphProperties = properties[i]

    let matchingHiddenRanges = hiddenRanges.filter { NSLocationInRange(characterIndex, $0) }
    if !matchingHiddenRanges.isEmpty {
        glyphProperties.insert(.null)
    }

    modifiedGlyphProperties.append(glyphProperties)
}

// Convert our Swift array to the UnsafePointer `setGlyphs` expects.
modifiedGlyphProperties.withUnsafeBufferPointer { modifiedGlyphPropertiesBufferPointer in
    guard let modifiedGlyphPropertiesPointer = modifiedGlyphPropertiesBufferPointer.baseAddress else {
        fatalError("Could not get base address of modifiedGlyphProperties")
    }

    // Call setGlyphs with the modified array.
    layoutManager.setGlyphs(glyphs, properties: modifiedGlyphPropertiesPointer, characterIndexes: characterIndexes, font: font, forGlyphRange: glyphRange)
}

return glyphRange.length

为了理解它们之间的区别,我建议阅读Swift文档中关于“字符串和字符”的部分。请注意,这里框架所称的“character”与Swift中所称的Character(或“扩展字形集群”)不同。在TextKit框架中,“character”是一个UTF-16代码单元(由Swift中的Unicode.UTF16.CodeUnit表示)。


更新2020-04-16:使用.withUnsafeBufferPointermodifiedGlyphProperties数组转换为UnsafePointer。这样就不需要在内存中保留数组的实例变量了。


感谢Guillaume Algis提供的详细解释!我一直在苦苦挣扎layoutManager(_:shouldGenerateGlyphs:properties:characterIndexes:font:forGlyphRange:)几天了,你的解释帮助我最终理解了它。如果您能看一下我发布的代码并对其发表评论,我将不胜感激。 - Optimalist

11
我决定提交另一个解决方案,因为关于这个主题的信息非常少,也许有人会觉得有用。起初,我对layoutManager(_:shouldGenerateGlyphs:properties:characterIndexes:font:forGlyphRange:)感到完全困惑,直到我找到Guillaume Algis非常详细的解释(如上所述)。再加上在WWDC 2018演示中25'18"处的幻灯片"TextKit最佳实践"以及学习不安全指针的工作原理,这对我来说就行了。
我的解决方案并没有直接处理隐藏markdown字符;相反,它使用具有特定值(DisplayType.excluded)的自定义属性(displayType)隐藏字符。(那是我需要的。)但代码相当优雅,所以可能会很有启发性。
以下是自定义属性定义:
extension NSAttributedString.Key { static let displayType = NSAttributedString.Key(rawValue: "displayType") }

为了有东西可以检查,这可以放在视图控制器的ViewDidLoad中(该视图控制器被设置为NSLayoutManagerDelegate):

textView.layoutManager.delegate = self
        
let text = NSMutableAttributedString(string: "This isn't easy!", attributes:  [.font: UIFont.systemFont(ofSize: 24), .displayType: DisplayType.included])
let rangeToExclude = NSRange(location: 7, length: 3)
text.addAttribute(.displayType, value: DisplayType.excluded, range: rangeToExclude)
textView.attributedText = text

最后,这是执行所有工作的函数:

func layoutManager(_ layoutManager: NSLayoutManager, shouldGenerateGlyphs glyphs: UnsafePointer<CGGlyph>, properties props: UnsafePointer<NSLayoutManager.GlyphProperty>, characterIndexes charIndexes: UnsafePointer<Int>, font aFont: UIFont, forGlyphRange glyphRange: NSRange) -> Int {
        
    // Make mutableProperties an optional to allow checking if it gets allocated
    var mutableProperties: UnsafeMutablePointer<NSLayoutManager.GlyphProperty>? = nil
        
    // Check the attributes value only at charIndexes.pointee, where this glyphRange begins
    if let attribute = textView.textStorage.attribute(.displayType, at: charIndexes.pointee, effectiveRange: nil) as? DisplayType, attribute == .excluded {
            
        // Allocate mutableProperties
        mutableProperties = .allocate(capacity: glyphRange.length)
        // Initialize each element of mutableProperties
        for index in 0..<glyphRange.length { mutableProperties?[index] = .null }
    }
        
    // Update only if mutableProperties was allocated
    if let mutableProperties = mutableProperties {
            
        layoutManager.setGlyphs(glyphs, properties: mutableProperties, characterIndexes: charIndexes, font: aFont, forGlyphRange: glyphRange)
            
        // Clean up this UnsafeMutablePointer
        mutableProperties.deinitialize(count: glyphRange.length)
        mutableProperties.deallocate()
            
        return glyphRange.length
            
    } else { return 0 }
}

上述代码似乎对字符和字形计数不匹配的情况很强大:{{attribute(_:at:effectiveRange:)}}仅使用{{charIndexes}},而{{mutableProperties}}仅使用{{glyphRange}}。此外,由于{{mutableProperties}}被赋予与主函数中的{{props}}相同的类型(实际上是可变的和可选的),因此后续无需进行转换。

这是不正确的,你只查询了第一个字符,然后隐藏了传递给方法的整个字形范围,而不仅仅是标记为“.excluded”的范围。 - user187676
1
@ErikAigner,你写的可能对你有意义,但我不理解。它为什么是错误的?它会产生错误或不可预测的结果吗?代码中是否有不必要或低效的部分?它是否表明了关于shouldGenerateGlyphs如何工作的错误假设?如果我知道你在说什么,我很乐意纠正它。也许你可以发送一小段代码并说明用什么替换。 - Optimalist
你只需要检查范围内第一个字符的.displayType属性。characterIndexes包含传入的glyphRange中所有字符索引,因此您需要枚举完整字符范围中的所有属性。 - user187676
2
@ErikAigner,感谢您的澄清。自我发布上述内容以来已经过去了10个月,所以我只能根据代码中的注释和我的记忆来回答您的问题。如果我没记错的话,每当检测到字符属性(包括.displayType)发生变化时,都会调用shouldGenerateGlyphs函数。这就是为什么不需要检查范围内的每个字符。我也没有遇到任何意外的行为。无论如何,如果有机会,我会再次测试它。否则,我很想知道您是否发现它不能按预期工作。 - Optimalist
3
@ErikAigner,我刚确认代码按预期工作。如果你仍然认为有问题,请告诉我具体在哪里。如果没有问题,那么道歉也是不错的 :-P - Optimalist

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接