iOS 富文本和HTML相互转换

288 阅读4分钟

why?

主要是苹果自带的HTML和富文本相互转换不好用,如:和安卓端不互通(斜体、下划线...),转出来和H5也有一定的差异

在客户端写这个功能,还不如直接使用web的编辑器,没差异,而且还快

进度

一、已完成

  1. 纯文字的富文本转HTML
  2. HTML转富文本

二、待完成【有人需要再搞】

  1. 带图片的富文本转HTML
  2. 异步实现转换【目前主线程实现,会有卡顿】
  3. ......

代码如下:

import UIKit

fileprivate let color_text = "color"
fileprivate let fontSize_text = "font-size"

class HTMLParser {
    
    /// 富文本转HTML
    /// - Parameter attributedText: 富文本
    /// - Returns: HTML字符串
    class func htmlString(with attributedText: NSAttributedString) -> String {
        if attributedText.length == 0 {
            return ""
        }
        
        var htmlString = ""
        var effectiveRange = NSRange(location: 0, length: 0)
        let html_p_end = HTML.p_end.label
        let html_span_end = HTML.span_end.label
        var temp_html_style : String = ""
        var temp_html_p : String = ""
        
        // 遍历富文本参数
        while effectiveRange.location + effectiveRange.length < attributedText.string.count {
            
            // 获取样式字典
            let attributes = attributedText.attributes(at: effectiveRange.location + effectiveRange.length, effectiveRange: &effectiveRange)
            // 替换空格,获取text
            let string = HTML.space(text: (attributedText.string as NSString).substring(with: effectiveRange)).label
            // html 样式
            let htmlStyleInfo = HTMLInfo(attributes: attributes)
            
            // 段落
            let html_p = HTML.p(htmlStyleInfo.text_align).label
            let html_span = HTML.span(htmlStyleInfo.styles).label
            let html_text = HTML.tag(htmlStyleInfo.tags, text: string).label
            /*
             开始拼接 HTML 标签
             */
            // 拼接第一个<p>标签
            if htmlString.isEmpty {
                htmlString.append(html_p)
                temp_html_p = html_p
            }
            
            if (temp_html_style.count > 0 && temp_html_style != html_span) || temp_html_p != html_p{
                htmlString.append(html_span_end)
            }
            if temp_html_p != html_p {
                htmlString.append(html_p_end)
            }
            
            if !string.isEmpty {
                var haveNewP = false
                if htmlString.hasSuffix(html_p_end){
                    htmlString.append(html_p)
                    temp_html_p = html_p
                    haveNewP = true
                }
                
                if temp_html_style != html_span || haveNewP{
                    htmlString.append(html_span)
                    temp_html_style = html_span
                }
                htmlString.append(HTML.br(htmlString: html_text).label)
            }
        }
        
        if !htmlString.isEmpty{
            htmlString.append(html_span_end)
            htmlString.append(html_p_end)
        }
        return htmlString
    }
    
    
    /// HTML转富文本
    /// - Parameter htmlString: HTML字符串
    /// - Returns: 富文本
    class func attributedText(htmlString: String) -> NSAttributedString? {
        let options: [NSAttributedString.DocumentReadingOptionKey: Any] = [
            .documentType: NSAttributedString.DocumentType.html,
            .characterEncoding: String.Encoding.utf8.rawValue
        ]
        
        guard let data = htmlString.data(using: .utf8),
              let attributedString = try? NSAttributedString(data: data, options: options, documentAttributes: nil) else {
            return nil
        }
        
        var mutableAttributedString = replaceSpecialCharacters(in: attributedString).mutableCopy() as! NSMutableAttributedString
        
        // 处理换行符
        let range = NSRange(location: mutableAttributedString.length - 1, length: 1)
        if mutableAttributedString.string.hasSuffix("\n") {
            mutableAttributedString.replaceCharacters(in: range, with: "")
        }
        
        // 修正字体 font-family: "Times New Roman" --> 系统字体
        mutableAttributedString.enumerateAttribute(.font, in: NSRange(location: 0, length: mutableAttributedString.length), options: .longestEffectiveRangeNotRequired) { value, range, _ in
            if let font = value as? UIFont {
                let italic = font.fontType == .italic || font.fontType == .boldItalic
                let bold = font.fontType == .bold || font.fontType == .boldItalic
                mutableAttributedString.addAttribute(.font, value: font.setItalic(italic, bold: bold), range: range)
            }
        }
        
        // 设置行高
        mutableAttributedString = attrubuteStringSetLineHeight(mutableAttributedString, lineHeight: 27.0)
        
        return mutableAttributedString.copy() as? NSAttributedString
    }
    
    /// 判断是否有一级二级标题
    class func hasTitleFont(with attributedText: NSAttributedString, range: NSRange) -> Bool {
        var effectiveRange = NSRange(location: range.location, length: 0)

        while effectiveRange.location + effectiveRange.length < NSMaxRange(range) {
            let attributes = attributedText.attributes(at: effectiveRange.location + effectiveRange.length, effectiveRange: &effectiveRange)
            
            if let font = attributes[NSAttributedString.Key.font] as? UIFont {
                // 字号
                let fontSize = font.fontDescriptor.fontAttributes[UIFontDescriptor.AttributeName.size] as? CGFloat ?? 0.0
                
                if fontSize >= 22 {
                    return true
                }
            }
        }
        
        return false
    }

    
    class func attrubuteStringSetButeType(_ attributedText: NSAttributedString, range: NSRange, typeAttributes: [NSAttributedString.Key: Any]) -> NSMutableAttributedString {
        
        let mAttributedString = attributedText.mutableCopy() as! NSMutableAttributedString
        var effectiveRange = NSRange(location: range.location, length: 0)
        
        while effectiveRange.location + effectiveRange.length < NSMaxRange(range) {
            
            var attributes = attributedText.attributes(at: effectiveRange.location + effectiveRange.length, effectiveRange: &effectiveRange)
            
            // 对齐方式
            if let paragraphStyle = typeAttributes[NSAttributedString.Key.paragraphStyle] as? NSParagraphStyle {
                attributes[NSAttributedString.Key.paragraphStyle] = paragraphStyle
            }
            // 斜体
            if let obliqueness = typeAttributes[NSAttributedString.Key.obliqueness] as? NSNumber {
                let originalFont = attributes[NSAttributedString.Key.font] as? UIFont ?? UIFont.systemFont(ofSize: 19)
                let bold = originalFont.fontType == .bold || originalFont.fontType == .boldItalic
                attributes[NSAttributedString.Key.font] = originalFont.setItalic(Float(truncating: obliqueness) > 0, bold: bold)
            }
            // 颜色
            if let foregroundColor = typeAttributes[NSAttributedString.Key.foregroundColor] as? UIColor {
                attributes[NSAttributedString.Key.foregroundColor] = foregroundColor
            }
            // 字体
            if let font = typeAttributes[NSAttributedString.Key.font] as? UIFont {
                let originalFont = attributes[NSAttributedString.Key.font] as? UIFont ?? font
                let bold = font.fontType == .bold || font.fontType == .boldItalic
                let italic = originalFont.fontType == .italic || originalFont.fontType == .boldItalic
                attributes[NSAttributedString.Key.font] = font.setItalic(italic, bold: bold)
            }
            // 下划线
            if let underlineStyle = typeAttributes[NSAttributedString.Key.underlineStyle] as? NSNumber {
                attributes[NSAttributedString.Key.underlineStyle] = underlineStyle
            }
            // 删除线
            if let strikethroughStyle = typeAttributes[NSAttributedString.Key.strikethroughStyle] as? NSNumber {
                attributes[NSAttributedString.Key.strikethroughStyle] = strikethroughStyle
            }
            mAttributedString.addAttributes(attributes, range: NSIntersectionRange(effectiveRange, range))
        }
        
        return mAttributedString
    }
    
    // MARK: - private
    
    private class func attrubuteStringSetLineHeight(_ attributedText: NSAttributedString, lineHeight: CGFloat) -> NSMutableAttributedString {
        
        let mAttributedString = attributedText.mutableCopy() as! NSMutableAttributedString
        var effectiveRange = NSRange(location: 0, length: 0)
        while effectiveRange.location + effectiveRange.length < attributedText.string.count {
            
            let attributes = attributedText.attributes(at: effectiveRange.location + effectiveRange.length, effectiveRange: &effectiveRange)
            
            let fontLineHeight = lineHeight
            var tempTypingAttributes = [NSAttributedString.Key: Any]()
            if let p = attributes[NSAttributedString.Key.paragraphStyle] as? NSMutableParagraphStyle {
                let np = NSMutableParagraphStyle()
                np.setParagraphStyle(p)
                np.minimumLineHeight = fontLineHeight
                np.maximumLineHeight = fontLineHeight
                tempTypingAttributes[NSAttributedString.Key.paragraphStyle] = np
            }
            mAttributedString.addAttributes(tempTypingAttributes, range: effectiveRange)
        }
        
        return mAttributedString
    }
    
    private class func replaceSpecialCharacters(in attributedString: NSAttributedString) -> NSAttributedString {
        let mutableAttributedString = attributedString.mutableCopy() as! NSMutableAttributedString
        if mutableAttributedString.length <= 0 {return attributedString}
        // 定义要替换的特殊字符
        let specialCharacter = "\u{2028}"
        let newLineCharacter = "\u{2028}\n"
        mutableAttributedString.mutableString.replaceOccurrences(of: newLineCharacter, with: specialCharacter, options: .literal, range: NSRange(location: 0, length: mutableAttributedString.length))
        
        if mutableAttributedString.length <= 0 {return attributedString}
        
        // 创建正则表达式
        let regex = try! NSRegularExpression(pattern: specialCharacter, options: [])
        // 在整个富文本范围内搜索特殊字符
        let fullRange = NSRange(location: 0, length: mutableAttributedString.length)
        regex.enumerateMatches(in: mutableAttributedString.string, options: [], range: fullRange) { result, _, _ in
            if let result = result {
                mutableAttributedString.replaceCharacters(in: result.range, with: "\n")
            }
        }
        
        return mutableAttributedString.copy() as! NSAttributedString
    }
    
}

// MARK: - HTML 样式信息
fileprivate struct HTMLInfo {
    /// 字符的 HTML style
    struct textStyles {
        var color : String
        var fontSize : CGFloat
        var fontStyle : String
    }
    var styles : textStyles = textStyles(color: "#1A1A1A", fontSize: 19, fontStyle: "normal")
    /// 字符的 HTML tag
    var tags = [String]()
    /// 段落方向
    var text_align = "left"
    
    init(attributes: [NSAttributedString.Key : Any]) {
        
        if let style = attributes[.paragraphStyle] as? NSParagraphStyle {
            text_align = getAlignString(style.alignment)
        }
        
        let font = attributes[NSAttributedString.Key(rawValue: "NSOriginalFont")] as? UIFont ?? attributes[.font] as? UIFont
        if let font = font {
            // 字色 16 进制
            let textColor = ((attributes[.foregroundColor] as? UIColor) ?? .black).doraemon_HexString()
            // 字号
            let fontSize = font.fontDescriptor.fontAttributes[.size] as? CGFloat ?? UIFont.systemFontSize
            // 字体样式
            var fontStyle = "normal"
            
            // 斜体
            if font.fontType == .boldItalic || font.fontType == .italic {
                fontStyle = "italic"
            }
            
            styles = textStyles(color: textColor, fontSize: fontSize, fontStyle: fontStyle)
        }
        
        // 粗体
        if let traits = (attributes[.font] as? UIFont)?.fontDescriptor.symbolicTraits, (traits.rawValue & UIFontDescriptor.SymbolicTraits.traitBold.rawValue) != 0 {
            tags.append("b")
        }
        // 下划线
        if let underline = attributes[.underlineStyle] as? NSNumber, underline != 0 {
            tags.append("u")
        }
        // 删除线
        if let strikethrough = attributes[.strikethroughStyle] as? NSNumber, strikethrough != 0 {
            tags.append("del")
        }
    }
    
    /// 段落方向转字符串
    private func getAlignString(_ alignment: NSTextAlignment) -> String {
        switch alignment {
        case .left:
            return "left"
        case .center:
            return "center"
        case .right:
            return "right"
        default:
            return "left"
        }
    }
}

// MARK: - HTML 标签
fileprivate enum HTML {
    case p(_ textAlign: String)
    case p_end
    case span(_ styles: HTMLInfo.textStyles)
    case span_end
    case tag(_ tags: [String], text: String)
    case br(htmlString: String)
    case space(text: String)
    
    // 获取HTML标签
    var label: String {
        switch self {
        case .p(let textAlign):
            return "<p style=\"text-align:\(textAlign);\">"
        case .p_end:
            return "</p>"
        case .span(let styles):
            return "<span style=\"color:\(styles.color);font-size:\(styles.fontSize)px;font-style:\(styles.fontStyle);\">"
        case .span_end: 
            return "</span>"
        case .tag(let tags, let text):
            var htmlString = ""
            tags.forEach { htmlString.append("<\($0)>") }
            htmlString.append(text)
            tags.forEach { htmlString.append("</\($0)>") }
            return htmlString
        case .br(let htmlString):
            return htmlString.replacingOccurrences(of: "\n", with: "<br>")
        case .space(var text):
            text = text.replacingOccurrences(of: " ", with: "&nbsp;")
            return text.replacingOccurrences(of: "\u{00A0}", with: "&nbsp;")
        }
    }
    
}

extension UIColor {
    func doraemon_HexString() -> String {
        let components = self.cgColor.components
        if let components = components {
            let red = Int(components[0] * 255.0)
            let green = Int(components[1] * 255.0)
            let blue = Int(components[2] * 255.0)
            return String(format: "#%02X%02X%02X", red, green, blue)
        } else {
           return ""
        }
        
    }
}



public extension UIFont {
    
    enum FontType {
        case normal
        case boldItalic
        case bold
        case italic
    }
    /// 字体类型
    var fontType: FontType {
        let desc = self.description.replacingOccurrences(of: " ", with: "")
        let b = desc.contains("font-weight:bold") || desc.contains("font-weight:Bold")
        let i = desc.contains("font-style:italic") || desc.contains("font-style:Italic")
        if b && i {
            return .boldItalic
        }
        if b { return .bold }
        if i { return .italic}
        return .normal
    }
    
    func setItalic(_ italic: Bool, bold: Bool) -> UIFont{
        if italic && bold {
            // 粗斜体
            return UIFont.init(descriptor: .init(name: ".AppleSystemUIFontBoldItalic", matrix: CGAffineTransform.init(1, 0, 0.2, 1, 0, 0)), size: self.pointSize)
        }else if italic {
            // 斜体
            return UIFont.init(descriptor: .init(name: ".AppleSystemUIFontItalic", matrix: CGAffineTransform.init(1, 0, 0.2, 1, 0, 0)), size: self.pointSize)
        }else if bold{
            // 粗体
            return UIFont.boldSystemFont(ofSize: self.pointSize)
        }
            // 普通字体
        return UIFont.systemFont(ofSize: self.pointSize)
    }
}