44

How can I set a SwiftUI Text to display rendered HTML or Markdown?

Something like this:

Text(HtmlRenderedString(fromString: "<b>Hi!</b>"))

or for MD:

Text(MarkdownRenderedString(fromString: "**Bold**"))

Perhaps I need a different View?

Div
  • 1,363
  • 2
  • 11
  • 28

13 Answers13

38

iOS 15

Text now supports basic Markdown!

struct ContentView: View {
    var body: some View {
        VStack {
            Text("Regular")
            Text("*Italics*")
            Text("**Bold**")
            Text("~Strikethrough~")
            Text("`Code`")
            Text("[Link](https://apple.com)")
            Text("***[They](https://apple.com) ~are~ `combinable`***")
        }
    }
}

Result:

Markdown result


Update: If you store markdown as a String, it won't render — instead, set the type to be LocalizedStringKey.

struct ContentView: View {
    @State var textWithMarkdown: LocalizedStringKey = "***[They](https://apple.com) ~are~ `combinable`***"

    var body: some View {
        Text(textWithMarkdown)
    }
}

Result:

Markdown rendered

aheze
  • 24,434
  • 8
  • 68
  • 125
  • Great! But it does not work, if you put a string containing markdowns in a variable! Is there a solution or is it just a bug to file? – gundrabur Jun 13 '21 at 12:48
  • 1
    @gundrabur most likely a bug (I remember someone asking about this in the WWDC21 digital lounges). See my edit for a workaround – aheze Jun 13 '21 at 16:23
  • 3
    @aheze Markdown only working for string literals is intended, see [this tweet](https://twitter.com/natpanferova/status/1426082374052286470). – George Aug 30 '21 at 03:29
  • 12
    To work around a stored string not being converted to Markdown, instead of converting to an `AttributedString`, you can simply create a `LocalizedStringKey` from the string value and initialize the `Text` view with that `LocalizedStringKey`. i.e. `Text(LocalizedStringKey(textWithMarkdown))` – RanLearns Sep 07 '21 at 16:36
  • 6
    I solved this by just using `Text(.init(yourTextVariable))`. No need for a `markdownToAttributed` function. See answer: https://stackoverflow.com/a/69898689/7653367 – Jacob Ahlberg Nov 16 '21 at 13:23
28

If you don't need to specifically use a Text view. You can create a UIViewRepresentable that shows a WKWebView and simple call loadHTMLString().

import WebKit
import SwiftUI

struct HTMLStringView: UIViewRepresentable {
    let htmlContent: String

    func makeUIView(context: Context) -> WKWebView {
        return WKWebView()
    }

    func updateUIView(_ uiView: WKWebView, context: Context) {
        uiView.loadHTMLString(htmlContent, baseURL: nil)
    }
}

In your body simple call this object like this:

import SwiftUI

struct Test: View {
    var body: some View {
        VStack {
            Text("Testing HTML Content")
            Spacer()
            HTMLStringView(htmlContent: "<h1>This is HTML String</h1>")
            Spacer()
        }
    }
}

struct Test_Previews: PreviewProvider {
    static var previews: some View {
        Test()
    }
}
Tomas
  • 866
  • 16
  • 21
  • My requirement is to display a html data into the along with other text data of a list of items using swiftui. However, whenever I am trying to do the above code, i don't see any view. Could you please let me know what could the reason. – DJ- Jan 13 '20 at 01:12
  • Hi @DJ, It's working on my project, I have updated my answer with a complete SwiftUI File. I mean, you will not see nothing on the "preview screen" but if you press play will work. Let me know if I've answered your question. – Tomas Jan 13 '20 at 13:15
  • 2
    Thanks for your response, it worked for it as well but not within the list . I believe this may be an issue with the sizing within the list.I will try to investigate it further. – DJ- Jan 13 '20 at 22:56
  • @DJ- I tried with UIViewRepresentable attributed multiline text. I am able to get attributed and multiline text Label for setting preferredMaxLayoutWidth from GeometryReader width. but issue with list item sizing text getting overlap on other item. Please add answer if you find the solution, Thanks in Advance. – Rohit Wankhede Jan 15 '20 at 12:34
  • Please try with my other answer. https://stackoverflow.com/a/62281735/1756736 – Tomas Jun 09 '20 at 11:39
  • Perhaps the same as DJ, I have a problem with this in a `ScrollView`. Plus, there's a latency of loading (I'm using a local file). – Chris Prince Dec 13 '20 at 20:43
  • 1
    See changes here. That's fixing for me. https://developer.apple.com/forums/thread/653935 – Chris Prince Dec 13 '20 at 20:56
  • After the implementation of changes by @ChrisPrince , you may use this link of stackoverflow to set the correct font size. https://stackoverflow.com/a/46000849/2641380 – SHS Dec 18 '20 at 06:08
10

Since I have found another solution I would like to share it with you.

Create a new View Representable

struct HTMLText: UIViewRepresentable {

   let html: String
    
   func makeUIView(context: UIViewRepresentableContext<Self>) -> UILabel {
        let label = UILabel()
        DispatchQueue.main.async {
            let data = Data(self.html.utf8)
            if let attributedString = try? NSAttributedString(data: data, options: [.documentType: NSAttributedString.DocumentType.html], documentAttributes: nil) {
                label.attributedText = attributedString
            }
        }

        return label
    }
    
    func updateUIView(_ uiView: UILabel, context: Context) {}
}

And use it later like this:

HTMLText(html: "<h1>Your html string</h1>")
Tomas
  • 866
  • 16
  • 21
  • how to increase font size? – Di Nerd Apps Jun 16 '20 at 12:45
  • Hi @DiNerd, in the parameter "options:" of the NSAttributedString you should add a new option for the font, like this: NSAttributedString(data: data, options: [.documentType: NSAttributedString.DocumentType.html, .font: UIFont.boldSystemFont(ofSize: 36)], documentAttributes: nil) – Tomas Jun 16 '20 at 13:58
  • Who do you use when text is not fitting in one line? I added this lines, but it did not work: label.lineBreakMode = .byWordWrapping, label.numberOfLines = 0 – Ramis Jan 15 '21 at 07:05
  • Hi @Ramis check out this answer i think could help https://stackoverflow.com/a/58474880/129889 – Tomas Jan 16 '21 at 08:08
  • This is great thank you! I found an issue with the width of the label, it was expanding horizontally and not vertically. It turned out it's because the label was inside a ScrollView. The answer here helped fixing this if anyone has the same issue: https://stackoverflow.com/a/62788230/408286 – mota Mar 02 '21 at 17:12
8

Since iOS 15, Text can have an AttributedString parameter.

No UIViewRepresentable necessary

Since NSAttributedString can be created from HTML, the process is straight forward:

import SwiftUI

@available(iOS 15, *)
struct TestHTMLText: View {
    var body: some View {
        let html = "<h1>Heading</h1> <p>paragraph.</p>"

        if let nsAttributedString = try? NSAttributedString(data: Data(html.utf8), options: [.documentType: NSAttributedString.DocumentType.html], documentAttributes: nil),
           let attributedString = try? AttributedString(nsAttributedString, including: \.uiKit) {
            Text(attributedString)
        } else {
            // fallback...
            Text(html)
        }
    }
}

@available(iOS 15, *)
struct TestHTMLText_Previews: PreviewProvider {
    static var previews: some View {
        TestHTMLText()
    }
}

The code renders this:

Rendered HTML example

Gerd Castan
  • 6,275
  • 3
  • 44
  • 89
  • you can't apply SwiftUI parameters to the resulting `Text` though. For example `.font` – meowmeowmeow Jun 29 '23 at 01:04
  • This solution was the only one that would work and allowed me to use other languages. The only modification needed was to change Data(html.utf8) -to- html.data(using: .utf16)! – TheMason Aug 31 '23 at 02:20
6

You can try to use the package https://github.com/iwasrobbed/Down, generate HTML or MD from you markdown string, then create a custom UILabel subclass and make it available to SwiftUI like in the following example:

struct TextWithAttributedString: UIViewRepresentable {

    var attributedString: NSAttributedString

    func makeUIView(context: Context) -> ViewWithLabel {
        let view = ViewWithLabel(frame: .zero)
        return view
    }

    func updateUIView(_ uiView: ViewWithLabel, context: Context) {
        uiView.setString(attributedString)
    }
}

class ViewWithLabel : UIView {
    private var label = UILabel()

    override init(frame: CGRect) {
        super.init(frame:frame)
        self.addSubview(label)
        label.numberOfLines = 0
        label.autoresizingMask = [.flexibleWidth, .flexibleHeight]
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    func setString(_ attributedString:NSAttributedString) {
        self.label.attributedText = attributedString
    }

    override var intrinsicContentSize: CGSize {
        label.sizeThatFits(CGSize(width: UIScreen.main.bounds.width - 50, height: 9999))
    }
}

I have kind of success with that but cannot get the frame of the label subclass right. Maybe I need to use GeometryReader for that.

blackjacx
  • 9,011
  • 7
  • 45
  • 56
  • Could you please give an example about how to use your code? I tried this with no success: TextWithAttributedString(attributedString: DownView(frame: .zero, markdownString: "").accessibilityAttributedValue!) .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity) – Mauricio Zárate Jan 04 '20 at 08:48
  • Can you please let us know how do we call this ? Can we just say TextWithAttributedString(attributedString:"
    Hello check
    ")
    – DJ- Jan 13 '20 at 22:54
  • 1
    Yes it was intended to call it using `TextWithAttributedString(attributedString:"# Hello SwiftUI")` but in the meantime I switched to another approach wich actually displays something but is also not optimal yet. If I make real progress I'll post a new answer here. – blackjacx Jan 14 '20 at 14:37
  • @blackjacx - I tried with UIViewRepresentable attributed multiline text. I am able to get attributed and multiline text Label. Setting label's preferredMaxLayoutWidth from GeometryReader width. But issue with list item sizing text getting overlap on other item. Please add answer if you find the solution, Thanks in Advance. – Rohit Wankhede Jan 15 '20 at 12:40
  • @blackjacx this doesnt convert the MD nor HTML - just outputs the raw string in the label - what am I missing? – daihovey Sep 27 '20 at 00:52
  • nvm, i just found down.toAttributedString() :) – daihovey Sep 27 '20 at 00:58
6

Some people advise to use WKWebView or UILabel, but these solutions are terribly slow or inconvenient. I couldn't find a native SwiftUI solution, so I implemented my own (AttributedText). It's quite simple and limited in its functionality, but it works quickly and satisfies my needs. You can see all features in the README.md file. Feel free to contribute if the existing functionality is not enough for you.

Code example

AttributedText("This is <b>bold</b> and <i>italic</i> text.")

Result

Example

Iaenhaall
  • 404
  • 5
  • 9
5

Text can just display Strings. You can use a UIViewRepresentable with an UILabel and attributedText.

Probably attributedText text support will come later for SwiftUI.Text.

Div
  • 1,363
  • 2
  • 11
  • 28
Ugo Arangino
  • 2,802
  • 1
  • 18
  • 19
5

New since Swift 5.7 - convert from "basic" HTML

Swift 5.7 brought new functionalities related to regex. A new RegexBuilder was implemented in addition to the existing regex support, and that makes it easier to extrapolate the strings in HTML tags.

With little work, we can build a converter from "basic" HTML codes to markdown. By "basic" I mean:

  • they contain line breaks, bold, italic (no attributes)
  • they can contain hyperlinks, and that's the complicated part of the converter below
  • they do not contain headers, scripts, id attributes...

Of course, with more effort, anything can be achieved, but I'm going to stick with the basic example.

The String extension:


extension String {
    func htmlToMarkDown() -> String {
        
        var text = self
        
        var loop = true

        // Replace HTML comments, in the format <!-- ... comment ... -->
        // Stop looking for comments when none is found
        while loop {
            
            // Retrieve hyperlink
            let searchComment = Regex {
                Capture {
                    
                    // A comment in HTML starts with:
                    "<!--"
                    
                    ZeroOrMore(.any, .reluctant)
                    
                    // A comment in HTML ends with:
                    "-->"
                }
            }
            if let match = text.firstMatch(of: searchComment) {
                let (_, comment) = match.output
                text = text.replacing(comment, with: "")
            } else {
                loop = false
            }
        }

        // Replace line feeds with nothing, which is how HTML notation is read in the browsers
        var text = self.replacing("\n", with: "")
        
        // Line breaks
        text = text.replacing("<div>", with: "\n")
        text = text.replacing("</div>", with: "")
        text = text.replacing("<p>", with: "\n")
        text = text.replacing("<br>", with: "\n")

        // Text formatting
        text = text.replacing("<strong>", with: "**")
        text = text.replacing("</strong>", with: "**")
        text = text.replacing("<b>", with: "**")
        text = text.replacing("</b>", with: "**")
        text = text.replacing("<em>", with: "*")
        text = text.replacing("</em>", with: "*")
        text = text.replacing("<i>", with: "*")
        text = text.replacing("</i>", with: "*")
        
        // Replace hyperlinks block
        
        loop = true
        
        // Stop looking for hyperlinks when none is found
        while loop {
            
            // Retrieve hyperlink
            let searchHyperlink = Regex {

                // A hyperlink that is embedded in an HTML tag in this format: <a... href="<hyperlink>"....>
                "<a"

                // There could be other attributes between <a... and href=...
                // .reluctant parameter: to stop matching after the first occurrence
                ZeroOrMore(.any)
                
                // We could have href="..., href ="..., href= "..., href = "...
                "href"
                ZeroOrMore(.any)
                "="
                ZeroOrMore(.any)
                "\""
                
                // Here is where the hyperlink (href) is captured
                Capture {
                    ZeroOrMore(.any)
                }
                
                "\""

                // After href="<hyperlink>", there could be a ">" sign or other attributes
                ZeroOrMore(.any)
                ">"
                
                // Here is where the linked text is captured
                Capture {
                    ZeroOrMore(.any, .reluctant)
                }
                One("</a>")
            }
                .repetitionBehavior(.reluctant)
            
            if let match = text.firstMatch(of: searchHyperlink) {
                let (hyperlinkTag, href, content) = match.output
                let markDownLink = "[" + content + "](" + href + ")"
                text = text.replacing(hyperlinkTag, with: markDownLink)
            } else {
                loop = false
            }
        }

        return text
    }
}

Usage:

HTML text:

let html = """
<div>You need to <b>follow <i>this</i> link</b> here: <a href="https://example.org/en">sample site</a></div>
"""

Markdown conversion:

let markdown = html.htmlToMarkDown()
print(markdown)

// Result:
// You need to **follow *this* link** here: [sample site](https://example.org/en)

In SwiftUI:

Text(.init(markdown))

What you see:

enter image description here

HunterLion
  • 3,496
  • 1
  • 6
  • 18
3

iOS 15 Supports Basic Markdown, but it does not include headings or images. Here is an answer if you want to include basic headings & images in text:

Text("Body of text here with **bold** text") // This will work as expected

But:

let markdownText = "Body of text here with **bold** text".
Text(markdownText) // This will not render the markdown styling

But you can fix that by doing:

Text(.init(markdownText)) // This will work as expected, but you won't see the headings formatted

BUT SwiftUI markdown doesn't support the headings (#, ##, ###, etc.) so if you want "# heading \nBody of text here with **bold** text" everything will render properly, minus the heading, you will still see "# heading".

So one solution is to break the string into lines, and implement a ForEach loop to check for the headings prefix (#), drop the #, and and create a Text() element with the appropriate styling like so:

let lines = blogPost.blogpost.components(separatedBy: .newlines)

VStack(alignment: .leading) {
                    ForEach(lines, id: \.self) { line in
                                    if line.hasPrefix("# ") {
                                        Text(line.dropFirst(2))
                                            .font(.largeTitle)
                                            .fontWeight(.heavy)
                                    } else if line.hasPrefix("## ") {
                                        Text(line.dropFirst(3))
                                            .font(.title)
                                            .fontWeight(.heavy)
                                    } else if line.hasPrefix("### ") {
                                        Text(line.dropFirst(4))
                                            .font(.headline)
                                            .fontWeight(.heavy)
                                    } else {
                                        Text(.init(line))
                                            .font(.body)
                                    }
                                }
}

This will create a well formated markdown text including headings.

If we want to also add images, first we can create an extension on the URL property:

extension URL {
func isImage() -> Bool {
    let imageExtensions = ["jpg", "jpeg", "png", "gif"]
    return imageExtensions.contains(self.pathExtension.lowercased())
}
}

This method checks if the URL's path extension is one of the common image file extensions (jpg, jpeg, png, or gif) and returns true if it is.

Then, we can alter the ForEach loop like so:

let lines = blogPost.blogpost.components(separatedBy: .newlines)
ForEach(lines, id: \.self) { line in
if line.hasPrefix("# ") {
    Text(line.dropFirst(2))
        .font(.largeTitle)
        .fontWeight(.heavy)
} else if line.hasPrefix("## ") {
    Text(line.dropFirst(3))
        .font(.title)
        .fontWeight(.heavy)
} else if line.hasPrefix("### ") {
    Text(line.dropFirst(4))
        .font(.headline)
        .fontWeight(.heavy)
} else if let imageUrl = URL(string: line), imageUrl.isImage() {
    // If the line contains a valid image URL, display the image
    AsyncImage(url: imageUrl) { phase in
        switch phase {
        case .empty:
            ProgressView()
        case .success(let image):
            image
                .resizable()
                .aspectRatio(contentMode: .fit)
        case .failure:
            Text("Failed to load image")
        @unknown default:
            fatalError()
        }
    }
} else {
    Text(line)
        .font(.body)
}
}

In this updated code, we're checking if the line contains a valid image URL by attempting to create a URL object from the line using URL(string: line) and then calling a custom extension method isImage() on the resulting URL to check if it points to an image.

If the line contains a valid image URL, we use the AsyncImage view to load the image asynchronously from the URL. The AsyncImage view automatically handles loading and caching of the image and provides a placeholder ProgressView while the image is being loaded. Once the image is loaded, we display it using the Image view with the resizable() and aspectRatio(contentMode: .fit) modifiers to resize and scale the image appropriately. If the image fails to load for some reason, we display an error message instead.

Peter Ruppert
  • 1,067
  • 9
  • 24
2

As far as rendering HTML in swiftUI there are a number of solutions, but for rendering it as a generic UILabel via AttributedText, this is what I went with after combining a few other solutions I found.

Here is the UIViewRepresentable which you'll use from your parent swiftUI views:

//Pass in your htmlstring, and the maximum width that you are allowing for the label
//this will, in turn, pass back the size of the newly created label via the binding 'size' variable
//you must use the new size variable frame on an encompassing view of wherever this htmlAttributedLabel now resides (like in an hstack, etc.)
struct htmlAttributedLabel: UIViewRepresentable {
    @Binding var htmlText: String
    var width: CGFloat
    @Binding var size:CGSize
    var lineLimit = 0
    //var textColor = Color(.label)
 
    func makeUIView(context: Context) -> UILabel {
        let label = UILabel()
        label.lineBreakMode = .byWordWrapping
        label.numberOfLines = lineLimit
        label.preferredMaxLayoutWidth = width
        //label.textColor = textColor.uiColor()
        return label
    }

    func updateUIView(_ uiView: UILabel, context: Context) {
    let htmlData = NSString(string: htmlText).data(using: String.Encoding.unicode.rawValue)
    let options = [NSAttributedString.DocumentReadingOptionKey.documentType: NSAttributedString.DocumentType.html]
    DispatchQueue.main.async {
        do {
            let attributedString = try NSMutableAttributedString(data: htmlData!, options: options, documentAttributes: nil)
            //add attributedstring attributes here if you want
            uiView.attributedText = attributedString
            size = uiView.sizeThatFits(CGSize(width: width, height: CGFloat.greatestFiniteMagnitude))
            print("htmlAttributedLabel size: \(size)")
        } catch {
            print("htmlAttributedLabel unexpected error: \(error).")
        }
    }
}

Now, to use this label effectively, you'll need to provide it a maximum width, which you can get from geometry reader. You'll also need to pass in a CGSize binding so the label can tell the parent view how much space it needs to render. You'll in turn use this size to set an encompassing view height, so that the rest of swiftUI can layout around your html label appropriately:

@State var htmlText = "Hello,<br />I am <b>HTML</b>!"
@State var size:CGSize = .zero
var body: some View {
    HStack {
        GeometryReader { geometry in
                                htmlAttributedLabel(htmlText: $htmlText, width: geometry.size.width, size: $size).frame(width:size.width, height: size.height). //the frame is important to set here, otherwise sometimes it won't render right on repeat loads, depending on how this view is presented
                       }
           }.frame(height: size.height) //most important, otherwise swiftui won't really know how to layout things around your attributed label
}

You can also set line limits, or text color, etc., and obviously you can extend this object to take in whatever UIlabel parameters you'd like to use.

smakus
  • 1,107
  • 10
  • 11
  • this works great but I was trying to add Font to this and without luck, any suggestions? Thanks. – clopex Nov 02 '21 at 10:26
1

iOS 14 and above

Late to the party, but I found a solution that also works for iOS 14 without UIViewRepresentable and without having to check the iOS verion.

You simply have to create an extension for Text to add support for NSAttributedString. You can copy the extension from here:

extension Text {
init(_ astring: NSAttributedString) {
    self.init("")
    
    astring.enumerateAttributes(in: NSRange(location: 0, length: astring.length), options: []) { (attrs, range, _) in
        
        var t = Text(astring.attributedSubstring(from: range).string)

        if let color = attrs[NSAttributedString.Key.foregroundColor] as? UIColor {
            t  = t.foregroundColor(Color(color))
        }

        if let font = attrs[NSAttributedString.Key.font] as? UIFont {
            t  = t.font(.init(font))
        }

        if let kern = attrs[NSAttributedString.Key.kern] as? CGFloat {
            t  = t.kerning(kern)
        }
        
        
        if let striked = attrs[NSAttributedString.Key.strikethroughStyle] as? NSNumber, striked != 0 {
            if let strikeColor = (attrs[NSAttributedString.Key.strikethroughColor] as? UIColor) {
                t = t.strikethrough(true, color: Color(strikeColor))
            } else {
                t = t.strikethrough(true)
            }
        }
        
        if let baseline = attrs[NSAttributedString.Key.baselineOffset] as? NSNumber {
            t = t.baselineOffset(CGFloat(baseline.floatValue))
        }
        
        if let underline = attrs[NSAttributedString.Key.underlineStyle] as? NSNumber, underline != 0 {
            if let underlineColor = (attrs[NSAttributedString.Key.underlineColor] as? UIColor) {
                t = t.underline(true, color: Color(underlineColor))
            } else {
                t = t.underline(true)
            }
        }
        
        self = self + t
        
    }
}

}

Here is how to convert your HTML String to an NSAttributedString: Convert HTML to NSAttributedString in iOS

Apfelsaft
  • 5,766
  • 4
  • 28
  • 37
0

For rendering HTML, I use extension of String for convert to Attributed HTML String and extension of UIColor for working with hex color

extension String {
    func htmlAttributedString(
        fontSize: CGFloat = 16,
        color: UIColor = UIColor(Color.theme.body),
        linkColor: UIColor = UIColor(Color.theme.primary),
        fontFamily: String = "Roboto"
    ) -> NSAttributedString? {
        let htmlTemplate = """
        <!doctype html>
        <html>
          <head>
            <link href='https://fonts.googleapis.com/css?family=Roboto' rel='stylesheet'>
            <style>
                body {
                    color: \(color.hexString!);
                    font-family: \(fontFamily);
                    font-size: \(fontSize)px;
                }
                a {
                    color: \(linkColor.hexString!);
                }
            </style>
          </head>
          <body>
            \(self)
          </body>
        </html>
        """

        guard let data = htmlTemplate.data(using: .unicode) else {
            return nil
        }

        guard let attributedString = try? NSAttributedString(
            data: data,
            options: [.documentType: NSAttributedString.DocumentType.html],
            documentAttributes: nil
            ) else {
            return nil
        }

        return attributedString
    }
}

extension UIColor {
    var hexString:String? {
        if let components = self.cgColor.components {
            let r = components[0]
            let g = components[1]
            let b = components[2]
            return  String(format: "#%02x%02x%02x", (Int)(r * 255), (Int)(g * 255), (Int)(b * 255))
        }
        return nil
    }
}

And use it later like this:

import SwiftUI

struct ContentView: View {
    
    @State var htmlText = """
        <a href="example.com">Example</a>
    """
    
    var body: some View {
        if let nsAttrString = htmlText.htmlAttributedString() {
            Text(AttributedString(nsAttrString))
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

tuliptgr
  • 1
  • 1
0

This is a simple extension that uses AttributedString

extension String {
    var htmlToNSAttributed: NSAttributedString {
        guard let data = data(using: .utf8) else { return NSAttributedString(string: self) }
        do {
            return try NSAttributedString(
                data: data,
                options: [
                    .documentType: NSAttributedString.DocumentType.html,
                    .characterEncoding: String.Encoding.utf8.rawValue
                ],
                documentAttributes: nil
            )
        } catch {
            return NSAttributedString(string: self)
        }
    }
    
    var htmlToString: String {
        htmlToNSAttributed.string
    }
    
    var htmlToAttributed: AttributedString {
        do {
            return try AttributedString(htmlToNSAttributed, including: \.swiftUI)
        } catch {
            return AttributedString(stringLiteral: self)
        }
    }
}

Usage

Text(text.htmlToAttributed)