在 Swift 中使用 NSLayoutManager 隐藏 Markdown 字符

Cli*_*rum 8 nslayoutmanager nstextview nstextstorage textkit

我正在使用 Markdown 语法的 Mac 应用程序中的富文本编辑器。我NSTextStorage过去常常在 Markdown 语法中观察匹配项,然后NSAttributedString像这样实时地将样式应用到:

在此处输入图片说明

在这一点上,我已经在这方面不知所措,但我很高兴能够取得进展。:)本教程非常有帮助

作为下一步,我想在呈现 's 字符串时隐藏 Markdown 字符NSTextView。所以在上面的例子中,一旦最后一个星号被输入,我希望* *字符被隐藏,只是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
}
Run Code Online (Sandbox Code Playgroud)

如何setGlyphs根据我发现隐藏星号的文本范围修改要传递的内容?

Gui*_*gis 21

前言

我实现了这个方法来在我的应用程序中实现类似的东西。请记住,这个 API 的文档非常少,所以我的解决方案是基于反复试验而不是对这里所有活动部分的深入理解。

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

另请注意,我在此答案中详细介绍了很多细节,希望让任何 Swift 开发人员都能使用它,即使是没有 Objective-C 或 C 背景的开发人员。您可能已经了解下文详细介绍的一些内容。

在 TextKit 和 Glyphs 上

需要了解的重要事项之一是字形是一个或多个字符的视觉表示,如 WWDC 2018 Session 221“TextKit Best Practices”中所述:

第 221 节的幻灯片解释了字符和字形之间的区别

我建议看整个演讲。在理解如何layoutManager(_:shouldGenerateGlyphs:properties:characterIndexes:font:forGlyphRange:)工作的特定情况下,它不是很有帮助,但它提供了大量关于 TextKit 一般如何工作的信息。

理解 shouldGenerateGlyphs

所以。据我了解,每次 NSLayoutManager 将在渲染它们之前生成一个新的字形时,它都会让您有机会通过调用layoutManager(_:shouldGenerateGlyphs:properties:characterIndexes:font:forGlyphRange:).

修改字形

根据文档,如果您想修改字形,您应该通过调用setGlyphs(_:properties:characterIndexes:font:forGlyphRange:).

幸运的是,我们setGlyphs期望在shouldGenerateGlyphs. 这意味着理论上你可以shouldGenerateGlyphs通过调用来实现setGlyphs,一切都会很好(但这不会非常有用)。

返回值

该文档还说,的返回值shouldGenerateGlyphs应该是“存储在此方法中的实际字形范围”。它没有多大意义,因为预期的返回类型是Int而不是NSRange人们所期望的。通过反复试验,我认为框架希望我们在这里返回已修改的字形数量glyphRange,从索引 0 开始(稍后会详细介绍)。

此外,“存储在此方法中的字形范围”是指对 的调用setGlyphs,它将在内部存储新生成的字形(imo 这措辞非常糟糕)。

一个不太有用的实现

所以这是一个正确的实现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
}
Run Code Online (Sandbox Code Playgroud)

它也应该等同于0从方法中返回:

通过返回 0,它可以指示布局管理器进行默认处理。

做有用的事

那么现在,我们如何编辑我们的字形属性来让这个方法做一些有用的事情(比如隐藏字形)?

访问参数值

的大部分参数shouldGenerateGlyphsUnsafePointer。那是 Swift 层中的 TextKit C API 泄​​漏,并且首先使实现此方法变得麻烦的事情之一。

一个关键点是这里的所有 type 参数UnsafePointer都是数组(在 C 中,SomeType *——或者它的 Swift 等价物UnsafePointer<SomeType>——是我们表示数组的方式),并且这些数组都是 lengthglyphRange.length。这间接记录在setGlyphs方法中:

每个数组都有 glyphRange.length 项

这意味着使用UnsafePointerApple提供的漂亮API,我们可以使用如下循环迭代这些数组的元素:

for i in 0 ..< glyphRange.length {
    print(properties[i])
}
Run Code Online (Sandbox Code Playgroud)

在幕后,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
Run Code Online (Sandbox Code Playgroud)

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  ?"
Run Code Online (Sandbox Code Playgroud)

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

/// 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)
    }
}
Run Code Online (Sandbox Code Playgroud)

然后我们可以使用这个函数从我们的 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
Run Code Online (Sandbox Code Playgroud)

请注意,在代理对的情况下,循环将执行两次(一次在主要代理上,一次在跟踪代理上),您最终将比较相同的结果字符两次。这很好,因为您需要在生成的字形的两个“部分”上应用您想要的相同修改。

基于 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
Run Code Online (Sandbox Code Playgroud)

要了解它们之间的区别,我建议您阅读有关“字符串和字符”的 Swift 文档。还要注意的是什么框架所说的“性格”这里是一样的东西斯威夫特调用Character(或“扩展字形集群”)。同样,TextKit 框架的“字符”是一个 UTF-16 代码单元(在 Swift 中由 表示Unicode.UTF16.CodeUnit)。


2020 年 4 月 16 日更新:利用.withUnsafeBufferPointermodifiedGlyphProperties数组转换为UnsafePointer。它消除了拥有数组实例变量以使其在内存中保持活动状态的需要。


Opt*_*ist 8

我决定提交另一个解决方案,因为关于这个主题的信息很少,也许有人会发现它很有用。最初我完全被layoutManager(_:shouldGenerateGlyphs:properties:characterIndexes:font:forGlyphRange:)搞糊涂了,直到我找到了 Guillaume Algis 非常透彻的解释(上图)。再加上 WWDC 2018 演示文稿“TextKit 最佳实践”中 25'18" 处的幻灯片,以及研究不安全指针的工作原理,这对我有用

我的解决方案不直接处理隐藏降价字符;相反,它隐藏给定displayType具有特定值 ( DisplayType.excluded)的自定义属性 ( ) 的字符。(这正是我所需要的。)但是代码相当优雅,所以它可能具有指导意义。

这是自定义属性定义:

extension NSAttributedString.Key { static let displayType = NSAttributedString.Key(rawValue: "displayType") }
Run Code Online (Sandbox Code Playgroud)

要检查某些内容,可以将其放入视图控制器的 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
Run Code Online (Sandbox Code Playgroud)

最后,这是完成所有工作的函数:

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 }
}
Run Code Online (Sandbox Code Playgroud)

对于字符和字形计数不匹配的情况,上面的代码似乎很健壮:attribute(_:at:effectiveRange:)只使用charIndexes,并且mutableProperties只使用glyphRange。此外,由于mutableProperties给出了与propsmain 函数相同的类型(实际上,它是可变的和可选的),以后不需要转换它。

  • @ErikAigner,谢谢您的澄清。自从我发布上述内容以来已经过去了 10 个月,所以我只谈一下我在代码中的评论以及我记得它是如何工作的。如果我没记错的话,每次检测到字符属性(包括“.displayType”)发生变化时都会调用“shouldGenerateGlyphs”。这就是为什么没有必要检查范围内的每个字符的原因。我也不记得遇到过任何意外行为。无论如何,如果有机会,我会再次测试。否则,我很想知道您是否发现它无法按预期工作。 (2认同)
  • @ErikAigner,我刚刚确认代码按预期工作。如果您仍然认为这是错误的,请告诉我怎么做。如果没有,道歉就好了。:-P (2认同)