Programmer54
Programmer54

Reputation: 174

How to replace characters in AttributedString to preserve new lines after markdown parsing?

I need to parse a markdown using the .full interpretedSyntax so that headings are also supported (.inlineOnly and .inlineOnlyPreservingWhitespace ignore the headings). Unfortunately, there is a side effect that all the new lines are being removed after parsing. My idea is to replace them with a (dummy Unicode character) heart, parse, and replace the hearts with new lines for the final output. Unfortunately, I have not found a way to replace the characters in an AttributedString.

enter image description here

enter image description here

Here is my code / MRE:

struct ContentView: View {

@StateObject var vm = ViewModel()

var body: some View {
    VStack(spacing: 50) {
        VStack {
            Text("before markdown")
                .font(.system(size: 30))
            Text(vm.markdownText)
        }

        VStack {
            Text("after markdown")
                .font(.system(size: 30))
            if let attributedText = vm.attributedText {
                Text(attributedText)
            }
        }
    }
    .padding()
}
}

class ViewModel: ObservableObject {

@Published private(set) var attributedText: AttributedString?

let markdownText = "##### **Lorem ipsum:** dolor sit\\n amet \\n consectetur adipiscing \\n\\n\\n elit"

init() {
    self.attributedText = getAttributedText(text: markdownText)
}
func getAttributedText(text: String) -> AttributedString {
    
    let textWithReplacements = text.replacingOccurrences(of: "\n", with: "\n♥")

    guard let attributedText = try? AttributedString(
        markdown: textWithReplacements,
        options: .init(
            allowsExtendedAttributes: true,
            interpretedSyntax: .full,
            failurePolicy: .returnPartiallyParsedIfPossible
        ),
        baseURL: nil
    ) else {
        return AttributedString(stringLiteral: markdownText)
    }

    var output = attributedText
    let characterView = attributedText.characters
    for i in characterView.indices {
        if characterView[i] == "♥" {
            // TODO - replace hearts with new lines

 //                output.characters.replace("♥", with: "\\n") // crash

 //                output.characters.remove(at: i) // crash

 //                output.characters.insert("\\n", at: i) // will only put one new line although there are multiple hearts...and the hearts remain so still need to be somehow removed

}
}

    return output

}
}

Upvotes: 1

Views: 150

Answers (1)

Sweeper
Sweeper

Reputation: 271105

The way to replace a part of an AttributedString is through its subscript.

attributedString[someRange] = someNewAttributedSubstring

So you can just use range(of:) to find the heart:

var output = attributedText
let newLine = AttributedString("\n")
while let range = output.range(of: "♥") {
    output[range] = newLine[newLine.startIndex..<newLine.endIndex]
}

Note that we also access newLine with a subscript [newLine.startIndex..<newLine.endIndex], in order to get a AttributedSubstring. This arguably looks kind of ugly, but it is just how subscripts work unfortunately.

Replacing a new line with a heart might change how the markdown is parsed. The safest bet would be to replace it with a whitespace character, since a new line is also a white space character. There are many characters to choose from. For example, you can use U+200A HAIR SPACE

// also remember to change the argument to 'range(of:)'!
let textWithReplacements = markdown.replacingOccurrences(of: "\n", with: "\u{200A}\n")

The markdown parser would just add a PresentationIntent when encountering a header, and SwiftUI Text doesn't "understand" that and will just show the header with the same size as everything else. If you want a larger size, you can go through the runs of the AttributedString and look for the presentation intent.

For example, here I have made level 3 headers (### in markdown) have the .title3 font.

for run in output.runs {
    if run.presentationIntent?.components.map(\.kind).contains(.header(level: 3)) == true {
        output[run.range].font = .title3
    }
}

Full code:

struct ContentView: View {
    var body: some View {
        let md = "### **Lorem ipsum:** dolor sit\n amet \n consectetur adipiscing \n\n\n elit"
        Text(getAttributedText(markdown: md))
    }
}

func getAttributedText(markdown: String) -> AttributedString {
    
    let textWithReplacements = markdown.replacingOccurrences(of: "\n", with: "\u{200A}\n")
    
    guard let attributedText = try? AttributedString(
        markdown: textWithReplacements,
        options: .init(
            allowsExtendedAttributes: true,
            interpretedSyntax: .full,
            failurePolicy: .returnPartiallyParsedIfPossible
        ),
        baseURL: nil
    ) else {
        return AttributedString(markdown)
    }
    var replacement = AttributedSubstring()
    var output = attributedText
    let newLine = AttributedString("\n")
    while let range = output.range(of: "\u{200A}") {
        output[range] = newLine[newLine.startIndex..<newLine.endIndex]
    }
        
    for run in output.runs {
        if run.presentationIntent?.components.map(\.kind).contains(.header(level: 3)) == true {
            output[run.range].font = .title3
        }
    }
    
    return output
    
}

Upvotes: 2

Related Questions