Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Hide Markdown Characters with NSLayoutManager in Swift

I am working on a rich text editor in a Mac app that uses Markdown syntax. I use NSTextStorage to watch for matches in Markdown syntax, then apply styles to the NSAttributedString in real time like this:

enter image description here

At this point, I'm already in way over my head on this stuff, but I'm excited to be making progress. :) This tutorial was very helpful.

As a next step, I want to hide the Markdown characters when the NSTextView's string is rendered. So in the example above, once the last asterisk is typed, I want the * * characters to be hidden and just see sample in bold.

I'm using an NSLayoutManager delegate and I can see the matched string, but I'm unclear on how to generate the modified glyphs/properties using the shouldGenerateGlyphs method. Here what I have so far:

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
}

How do I modify the stuff to pass into setGlyphs based on the range of the text I've found to hide the asterisks?

like image 948
Clifton Labrum Avatar asked Aug 12 '19 20:08

Clifton Labrum


People also ask

What is swift-Markdown?

GitHub - apple/swift-markdown: A Swift package for parsing, building, editing, and analyzing Markdown documents. Failed to load latest commit information. Swift Markdown is a Swift package for parsing, building, editing, and analyzing Markdown documents.

How to format Markdown cells in Jupyter notebooks?

Here’s how to format Markdown cells in Jupyter notebooks. Headings: Use #s followed by a blank space for notebook titles and section headings: Emphasis: Use this code: Bold: __string__ or **string**, Italic: _string_ or *string*, Strikethrough: ~~string~~

How do you insert a line break in Markdown?

Line breaks: Sometimes Markdown doesn’t make line breaks when you want them. Put two spaces at the end of the line, or use this code for a manual line break: <br> Indented quoting: Use a greater-than sign ( > ) and then a space, then type the text.

What font do you use for text in Markdown?

Monospace font: Surround text with a back single quotation mark (`). Use monospace for file path and file names and for text users enter or message text users see. Line breaks: Sometimes Markdown doesn’t make line breaks when you want them.


2 Answers

2022 Disclaimer

While I had some good results running this piece of code when I originally submitted this answer, another SO user (Tim S.) warned me that in some cases applying the .null glyph properties to some glyphs make cause the app the hang or crash.

From what I could gather this only happens with the .null property, and around glyph 8192 (2^13)... I have no idea why, and honestly it looks like a TextKit bug (or at least not something the TextKit engineers did expect the framework to be used for).

For modern apps, I'd advise you to take a look a TextKit 2, which is supposed to abstract away glyphs handling and simplify all that stuff (disclaimer in the disclaimer : I haven't tried it yet).


Foreword

I implemented this method to achieve something similar in my app. Keep in mind that this API is very poorly documented, so my solution is based on trial and error instead of a deep understanding of all the moving parts here.

In short: it should work but use at you own risk :)

Note also that I went into a lot of details in this answer in the hope to make it accessible to any Swift developer, even one without a background in Objective-C or C. You probably already know some of the things detailed hereafter.

On TextKit and Glyphs

One of the things that is important to understand is that a glyph is the visual representation of one or more characters, as explained in WWDC 2018 Session 221 "TextKit Best Practices" :

slide of session 221 explaining the difference between characters and glyphs

I'd recommend watching the whole talk. It's not super helpful in the particular case of understanding how layoutManager(_:shouldGenerateGlyphs:properties:characterIndexes:font:forGlyphRange:) works, but it gives a good amount of info on how TextKit works in general.

Understanding shouldGenerateGlyphs

So. From what I understand, each time NSLayoutManager is about to generate a new glyph before rendering them, it will give you a chance to modify this glyph by calling layoutManager(_:shouldGenerateGlyphs:properties:characterIndexes:font:forGlyphRange:).

Modifying Glyphs

As per the doc, if you want to modify the glyphs you should do so in this method by calling setGlyphs(_:properties:characterIndexes:font:forGlyphRange:).

Lucky for us, setGlyphs expects the exact same arguments as passed to us in shouldGenerateGlyphs. This means that in theory you could implement shouldGenerateGlyphs with just a call to setGlyphs and all would be well (but that wouldn't be super useful).

Return Value

The doc also says that the return value of shouldGenerateGlyphs should be "The actual glyph range stored in this method". It doesn't make much sense, as the expected return type is Int and not NSRange as one might expect. From trial and error, I think the framework expects us here to return the number of modified glyphs in the passed glyphRange, starting at index 0 (more on that later).

Also, "glyph range stored in this method" refers the call to setGlyphs, which will store the newly generated glyphs internally (imo this is very poorly worded).

A Not So Useful Implementation

So here's a correct implementation of shouldGenerateGlyphs (which... does nothing):

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
}

it should also be equivalent to just returning 0 from the method:

By returning 0, it can indicate for the layout manager to do the default processing.

Doing Something Useful

So now, how can we edit our glyphs properties to make this method do something useful (like hiding glyphs)?

Accessing the Arguments Values

Most of the arguments of shouldGenerateGlyphs are UnsafePointer. That's the TextKit C API leaking in the Swift layer, and one of the things that make implementing this method a hassle in the first place.

A key point is that all the arguments of type UnsafePointer here are arrays (in C, SomeType * — or its Swift equivalent UnsafePointer<SomeType> — is the how we represent an array), and those arrays are all of length glyphRange.length. That's indirectly documented in the setGlyphs method:

Each array has glyphRange.length items

What this means is that with the nice UnsafePointer API Apple has given us, we can iterate on the elements of these array with a loop like this:

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

Under the hood, UnsafePointer will do pointer arithmetic to access memory at the right address given any index passed to the subscript. I'd recommend reading the UnsafePointer documentation, this is really cool stuff.

Passing Something Useful to setGlyphs

We're now able to print the content of our arguments, and inspect what properties the framework's given us for each glyph. Now, how do we modify those and pass the result to setGlyphs?

First, it's important to note that while we could modify the properties argument directly, it's probably a bad idea, because that chunk of memory isn't owned by us and we have no idea what the framework will do with this memory once we exit the method.

So the right way to go about this is to create our own array of glyph properties, and then pass that to 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

It's important to read the original glyph properties from the properties array and adding your custom ones to this base value (with the .insert() method). Otherwise you'd overwrite the default properties of your glyphs and weird things would happen (I've seen \n characters not inserting a visual line break anymore for example).

Deciding Which Glyphs to Hide

The previous implementation should work fine, but right now we're unconditionally hiding all generated glyphs, and it would be much more useful if we could hide only some of them (in your case when the glyph is *).

Hiding Based on Characters Values

To do that, you'll probably need to access the characters used to generate the final glyph. However, the framework doesn't give you the characters but their index in the string for each generated glyph. You'll need to iterate over these indexes and look into your NSTextStorage to find the corresponding characters.

Unfortunately, this is not a trivial task: Foundation uses UTF-16 code units to represent strings internally (that's what NSString and NSAttributedString use under the hood). So what the framework gives us with characterIndexes is not the indexes of "characters" in the usual sense of the word, but the indexes of UTF-16 code units.

Most of the time, each UTF-16 code unit will be used to generate a unique glyph, but in some cases multiple code units will be used to generate a unique glyph (this is called a UTF-16 surrogate pair, and is common when handling string with emojis). I'd recommend testing your code with some more "exotic" strings like for example:

textView.text = "Officiellement nous (👨‍👩‍👧‍👧) vivons dans un cha\u{0302}teau 🏰 海"

So, to be able to compare our characters, we first need to convert them to a simple representation of what we usually mean by "character":

/// 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)
    }
}

Then we can use this function to extract the characters from our textStorage, and test them:

// 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

Note that in the case of surrogate pairs, the loop will be executed twice (once on the lead surrogate, and once on the trail surrogate), and you'll end up comparing the same resulting character twice. This is fine though as you need to apply the same modification you want on both "parts" of the generated glyph.

Hiding Based on the TextStorage String Attributes

That's not what you've asked for in your question, but for completion's sake (and because it's what I do in my app), here how you can access your textStorage string attributes to hide some glyphs (in this example I'll hide all the parts of the text with an hypertext link):

// 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

To understand the differences between those, I'd recommend reading the Swift Documentation on "Strings and Characters". Note also that what the framework calls "character" here is not the same as what Swift calls a Character (or "Extended Grapheme Clusters"). Again, "character" for the TextKit framework is an UTF-16 code unit (represented in Swift by Unicode.UTF16.CodeUnit).


Update 2020-04-16: Make use of .withUnsafeBufferPointer to convert the modifiedGlyphProperties array to an UnsafePointer. It removes the need to have an instance variable of the array to keep it alive in memory.

like image 147
Guillaume Algis Avatar answered Oct 12 '22 21:10

Guillaume Algis


I decided to submit another solution because there's very little information on this subject, and maybe someone will find it useful. I was initially completely confused by layoutManager(_:shouldGenerateGlyphs:properties:characterIndexes:font:forGlyphRange:) until I found Guillaume Algis' very thorough explanation (above). That together with the slide at 25'18" into the WWDC 2018 presentation "TextKit Best Practices" and studying up on how unsafe pointers work did the trick for me.

My solution doesn't directly deal with hiding markdown characters; rather, it hides characters given a custom attribute (displayType) with a specific value (DisplayType.excluded). (That's what I needed.) But the code is fairly elegant, so it may be instructive.

Here's the custom attribute definition:

extension NSAttributedString.Key { static let displayType = NSAttributedString.Key(rawValue: "displayType") }

To have something to examine, this can go in ViewDidLoad of the view controller (which is set to be an 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

Finally, here's the function that does all the work:

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 }
}

The above code seems to be robust for situations in which character and glyph counts don't match up: attribute(_:at:effectiveRange:) only uses charIndexes, and mutableProperties only uses glyphRange. Also, as mutablePropertiesis given the same type as propsin the main function (well, actually, it's mutable and an optional), there's no need to convert it later on.

like image 39
Optimalist Avatar answered Oct 12 '22 20:10

Optimalist