NSAttributedString doesn't format HTML tables properly on Sonoma

This issue has already been reported to Apple via the Feedback Assistant as FB12401598 and a code-level support incident has been opened to follow up, but so far I haven't heard anything.

The problem is that the NSAttributedString used to be able to load HTML files correctly via the [NSAttributedString initWithHTML:options:documentAttributes:] method (or its Swift equivalent). As of the developer beta of Sonoma, however, this no longer works. The method loads the attributedString but HTML tables are completely ignored. Every cell in the table just appears on a new line.

The app I'm working on has a bunch of HTML templates that get drawn inside another View using an NSAttributedString. This has worked for years but no longer works on Sonoma.

Does anyone know a decent workaround for correctly drawing some formatted text whose formatting is specified via HTML? I'm currently exploring the idea of converting the HTML files to RTF on an older system and using RTF, but the RTF format isn't nearly as simple as HTML.

Here's a screenshot of a simple project and Safari showing the same HTML side by side

Accepted Reply

I have developed a workaround. Basically, you can copy each cell, using the first table object found for a given set of table cells. It's ugly, but does seem to work. Including some code below to show how you can do it.

class ViewController: NSViewController {
    
    @IBOutlet var textView: NSTextView!
    
    override func viewDidAppear() {
        super.viewDidAppear()

        let html =
            """
                <html>
                    <head>
                        <title>Testing Tables</title>
                        <style>
                            td {
                                border: 1px solid;
                                padding: 10px;
                            }
                        </style>
                    </head>
                    <body>
                        <table>
                            <tr>
                                <td>Cell 1</td>
                                <td>Cell 2</td>
                            </tr>
                            <tr>
                                <td>Cell 3</td>
                                <td>Cell 4</td>
                            </tr>
                        </table>
                    </body>
                </html>
            """
        let data = html.data(using: .utf8)!
        let string = NSMutableAttributedString(html: data, documentAttributes: nil)!
        let wholeRange = NSMakeRange(0, string.length)
        var table: NSTextTable?
        string.enumerateAttribute(.paragraphStyle, in: wholeRange) { value, range, _ in
            guard let paragraphStyle = value as? NSParagraphStyle else { return }
            let tableBlocks = paragraphStyle.textBlocks.compactMap { $0 as? NSTextTableBlock }
            for block in tableBlocks {
                if table == nil { table = block.table } // Keep first table found
                let newBlock = block.copy(for: table!)
                let newStyle = paragraphStyle.mutableCopy() as! NSMutableParagraphStyle
                newStyle.textBlocks = [newBlock]
                string.addAttribute(.paragraphStyle, value: newStyle, range: range)
            }
            if tableBlocks.isEmpty { table = nil }
        }
                
        textView.textStorage!.setAttributedString(string)
    }

}

extension NSTextTableBlock {
    
    func copy(for table: NSTextTable) -> NSTextTableBlock {
        let newBlock = NSTextTableBlock(table: table, startingRow: startingRow, 
            rowSpan: rowSpan, startingColumn: startingColumn, columnSpan: columnSpan)
        newBlock.backgroundColor = backgroundColor
        newBlock.verticalAlignment = verticalAlignment
        
        for edge: NSRectEdge in [.minX, .minY, .maxX, .maxY] {
            for layer: NSTextBlock.Layer in [.border, .margin, .padding] {
                let width = width(for: layer, edge: edge)
                let valueType = widthValueType(for: layer, edge: edge)
                newBlock.setWidth(width, type: valueType, for: layer, edge: edge)
            }
            newBlock.setBorderColor(borderColor(for: edge), for: edge)
        }
        
        for dimension: NSTextBlock.Dimension in [.height, .maximumHeight, .maximumWidth, .minimumHeight, .minimumWidth, .width] {
            let value = value(for: dimension)
            let valueType = valueType(for: dimension)
            newBlock.setValue(value, type: valueType, for: dimension)
        }
        
        return newBlock
    }
    
}

Replies

Thank you for filing a Feedback ID.

Does using +[WKWebView loadFromHTMLWithString:options:completionHandler:] work any better? (Please update the FB ID with this info if it works better or differently.)

<https://developer.apple.com/documentation/foundation/nsattributedstring/3182827-loadfromhtmlwithstring/>

That should be defined in the #import <WebKit/NSAttributedString.h> header.

Thanks for the suggestion. I tried using the WebKit NSAttributedString category method and unfortunately, I get the same result.

I updated the FeedBack Assistant report to reflect these findings as well.

We are seeing this too. What seems to be happening is that the NSTextTableBlock objects representing each cell have different table pointers. In other words, each cell gets put into its own table, leading them to appear under one another.

Interestingly, if you convert this attributed string to RTF, and back again, you get something else. You still get separate tables for each cell you input, but it creates empty cells so that the original cells at least have the correct column index, like this...

Conclusion: the HTML input seems to generate a new table object for each cell, instead of a single table object for all cells.

Submitted this a while back under FB13254682. Have added above FB to that.

I have developed a workaround. Basically, you can copy each cell, using the first table object found for a given set of table cells. It's ugly, but does seem to work. Including some code below to show how you can do it.

class ViewController: NSViewController {
    
    @IBOutlet var textView: NSTextView!
    
    override func viewDidAppear() {
        super.viewDidAppear()

        let html =
            """
                <html>
                    <head>
                        <title>Testing Tables</title>
                        <style>
                            td {
                                border: 1px solid;
                                padding: 10px;
                            }
                        </style>
                    </head>
                    <body>
                        <table>
                            <tr>
                                <td>Cell 1</td>
                                <td>Cell 2</td>
                            </tr>
                            <tr>
                                <td>Cell 3</td>
                                <td>Cell 4</td>
                            </tr>
                        </table>
                    </body>
                </html>
            """
        let data = html.data(using: .utf8)!
        let string = NSMutableAttributedString(html: data, documentAttributes: nil)!
        let wholeRange = NSMakeRange(0, string.length)
        var table: NSTextTable?
        string.enumerateAttribute(.paragraphStyle, in: wholeRange) { value, range, _ in
            guard let paragraphStyle = value as? NSParagraphStyle else { return }
            let tableBlocks = paragraphStyle.textBlocks.compactMap { $0 as? NSTextTableBlock }
            for block in tableBlocks {
                if table == nil { table = block.table } // Keep first table found
                let newBlock = block.copy(for: table!)
                let newStyle = paragraphStyle.mutableCopy() as! NSMutableParagraphStyle
                newStyle.textBlocks = [newBlock]
                string.addAttribute(.paragraphStyle, value: newStyle, range: range)
            }
            if tableBlocks.isEmpty { table = nil }
        }
                
        textView.textStorage!.setAttributedString(string)
    }

}

extension NSTextTableBlock {
    
    func copy(for table: NSTextTable) -> NSTextTableBlock {
        let newBlock = NSTextTableBlock(table: table, startingRow: startingRow, 
            rowSpan: rowSpan, startingColumn: startingColumn, columnSpan: columnSpan)
        newBlock.backgroundColor = backgroundColor
        newBlock.verticalAlignment = verticalAlignment
        
        for edge: NSRectEdge in [.minX, .minY, .maxX, .maxY] {
            for layer: NSTextBlock.Layer in [.border, .margin, .padding] {
                let width = width(for: layer, edge: edge)
                let valueType = widthValueType(for: layer, edge: edge)
                newBlock.setWidth(width, type: valueType, for: layer, edge: edge)
            }
            newBlock.setBorderColor(borderColor(for: edge), for: edge)
        }
        
        for dimension: NSTextBlock.Dimension in [.height, .maximumHeight, .maximumWidth, .minimumHeight, .minimumWidth, .width] {
            let value = value(for: dimension)
            let valueType = valueType(for: dimension)
            newBlock.setValue(value, type: valueType, for: dimension)
        }
        
        return newBlock
    }
    
}

Great workaround!
I ported the Swift to some Objective C for our old project, but it seems to work just fine there as well. Thanks!

Note that in 4.2, the attributed string no longer generates a new table for each cell, so the above workaround no longer works. That doesn't mean the attributed string properly parses the HTML, though. As can be seen from the below image (which uses the same simple html as before), there seem to now be issues with alignment (both horizontal and vertical):

I'll add this to my Feedback Assistant report.