基于 WordPress 开源的 AztecEditor-iOS库实现原生富文本编辑器
AztecEditor-iOS 基于 UITextView 提供了一个性能良好的用户界面组件。Android 端有对应的 SDK。
它由两个主要部分组成:
- TextView:用户界面组件,用于呈现和编辑内容,可预览文本展示样式和对应HTML。
 - 转换器:实现HTML 和 NSAttributedString 的相互转换。
 
Aztec提供了丰富的富文本编辑功能,包括字体格式化(粗体、斜体、下划线、删除线、颜色等),引用块(可自定义样式),媒体文件(图片、视频)的展示和编辑,自定义标签(包括但不限于@、#等特殊字符可触发的自定义功能)等等。
鉴于 Aztec 的官方文档相当的简洁,开发者使用起来并没有那么方便,编者在实现编辑器的过程当中主要是参考了官方Demo。
这里不针对 Aztec 源码做过多解析,只对如何使用以及如何进行定制化功能做一些说明,都是笔者在实际开发当中使用到的。
插件和短码系统
Aztec 提供的插件和短码系统,可以让我们通过自定义插件和短码来实现定制功能。
插件的实现主要是基于Plugin类和Processor协议,搭配短码解析系统来进行功能定制。短码方案直接采用了 WordPress-Editor 的方案,这个方案主要由ShortcodeAttributeSerializer、ShortcodeProcessor和HTMLProcessor组成,ShortcodeProcessor内部通过正则匹配,可以识别形如[tag id="" type="" content=""]的自定义短码,搭配ShortcodeAttributeSerializer和HTMLProcessor来进行正反方向的解析。
基于 Aztec 的插件和短码系统,我们可以扩展出用于自己项目的HTML与富文本相互转换的完全定制化系统,实际上 WordPress-Editor 的 Demo 就是这么做的。
![]()
open class Plugin {
    // MARK: - Customizers
    public let inputCustomizer: PluginInputCustomizer?
    public let outputCustomizer: PluginOutputCustomizer?
    // MARK: - Initializers
    public init(inputCustomizer: PluginInputCustomizer? = nil, outputCustomizer: PluginOutputCustomizer? = nil) {
        self.inputCustomizer = inputCustomizer
        self.outputCustomizer = outputCustomizer
    }
    /// Method plugins can use to execute extra code when loaded.
    ///
    open func loaded(textView: TextView) {}
  
    // MARK: - Equatable
    public static func ==(lhs: Plugin, rhs: Plugin) -> Bool {
        return type(of: lhs) == type(of: rhs)
    }
}
插件的功能主要依靠PluginInputCustomizer和PluginOutputCustomizer来实现。我们在初始化编辑器时进行加载即可。
let plugins = [RichEditorPlugin(), PasteboardPlugin()]
for plugin in plugins {
    textView.load(plugin)
}
/// PluginInputCustomizer定义
public protocol PluginInputCustomizer {
    func process(html: String) -> String
    func process(htmlTree: RootNode)
    func converter(for elementNode: ElementNode) -> ElementConverter?
}
/// PluginOutputCustomizer定义
public protocol PluginOutputCustomizer {
    func process(html: String) -> String
    func process(htmlTree: RootNode)
    func convert(_ paragraphProperty: ParagraphProperty) -> ElementNode?
    func convert(_ attachment: NSTextAttachment, attributes: [NSAttributedString.Key: Any]) -> [Node]?
    func converter(for elementNode: ElementNode) -> ElementToTagConverter?
}
PluginInputCustomizer 可以和短码系统组合来为输入过程提供操作 HTML 的函数。
PluginOutputCustomizer为输出过程提供操作 HTML 的函数。比如发布时,自定义输出 HTML 内容。
在获取 HTML 时,Aztec 会调用HTMLConverter的html(from attributedString: NSAttributedString, prettify: Bool = false) -> String函数来进行解析,将富文本转换成 HTML。
其内部借助 AttributedStringParser 和 ElementConverter(如ImageAttachmentToElementConverter、VideoAttachmentToElementConverter)生成节点树,再通过HTMLSerializer的serialize(_ node: Node, prettify: Bool = false) -> String函数,将节点树处理成 HTML,以上过程的每个节点都可以通过遵循PluginOutputCustomizer来进行干预。
class RichEditorPlugin: Plugin {
    init() {
        super.init(inputCustomizer: EditorInputCustomizer(),
                   outputCustomizer: EditorOutputCustomizer())
    }
}
class EditorInputCustomizer: PluginInputCustomizer {
    private var processor: PipelineProcessor = {
        var processors = [VideoProcessor.previewProcessor, ImageProcessor.previewProcessor]
        processors.append(contentsOf: CustomTagType.previewProcessors)
        return PipelineProcessor(processors)
    }()
  
    func process(html: String) -> String {
        return processor.process(html)
    }
    func process(htmlTree: Aztec.RootNode) {}
  
    func converter(for elementNode: Aztec.ElementNode) -> (any Aztec.ElementConverter)? {
        return nil
    }
}
class EditorOutputCustomizer: PluginOutputCustomizer {
    private var processor: PipelineProcessor = {
        var processors = [VideoProcessor.postProcessor, ImageProcessor.postProcessor]
        processors.append(contentsOf: CustomTagType.postProcessors)
        return PipelineProcessor(processors)
    }()
    func process(html: String) -> String {
        return processor.process(html)
    }
    func process(htmlTree: Aztec.RootNode) {}
  
    func convert(_ paragraphProperty: Aztec.ParagraphProperty) -> Aztec.ElementNode? {
        return nil
    }
    func convert(_ attachment: NSTextAttachment, attributes: [NSAttributedString.Key: Any]) -> [Aztec.Node]? {
        return nil
    }
    func converter(for elementNode: Aztec.ElementNode) -> (any Aztec.ElementToTagConverter)? {
        return nil
    }
}
文本格式化
工具条
Aztec 提供了工具条FormatBar和FormatBarItem,在其基础上可做一些有限的自定义。
![]()
在实际开发中,开发者可以使用完全自定义的 UI + Aztec 提供的功能。
文本格式化
实现字体样式改变,比如粗体、斜体、下划线、删除线、引用块、标题可以直接调用 Aztec 提供的函数。插入自定义高亮标签,如 @someone 将在下一节进行说明。
private func handleFormat(_ style: EditorToolType.FormatFontStyle) {
        switch style {
        case .bold: /// 粗体文本
            richTextView.toggleBold(range: richTextView.selectedRange)
        case .italic: /// 斜体文本
            richTextView.toggleItalic(range: richTextView.selectedRange)
        case .underline: /// 下划线
            richTextView.toggleUnderline(range: richTextView.selectedRange)
        case .strikethrough: /// 删除线
            richTextView.toggleStrikethrough(range: richTextView.selectedRange)
        case .blockQuote: /// 引用块
            richTextView.toggleBlockquote(range: richTextView.selectedRange)
        case .headline: /// 标题
            richTextView.toggleHeader(.h1, range: richTextView.selectedRange)
        case .link: /// 添加链接
            delegate?.shouldInsertLink(self)
        case .horizontalRule: /// 分割线
            richTextView.replaceWithHorizontalRuler(at: richTextView.selectedRange)
        default:
            break
        }
    }
实现的样式如下:
![]()
插入自定义标签
在我们项目当中,自定义标签各端约定了固定格式,比如固定格式可以是这样的:[[id, type, content]]。通过插件和短码系统,编写自定义的短码处理器,可以完美实现需求。
短码的构造和解析
ShortcodeAttributeSerializer和HTMLProcessor组合可以将短码解析为 HTML,我们可以在解析过程中自定义 HTML 的内容,保证在编辑器中展示正确的样式。
    static func parsedCustomTag(_ entity: CustomTagEntity) -> String {
      /// 构造符合ShortcodeProcessor要求格式的短码, e.g. [mention type=\"1\" text=\"@iOS test\" id=\"123\"]
        let shortCode = "[\(entity.type.identifier) type=\"\(entity.type.rawValue)\" text=\"\(entity.text)\" id=\"\(entity.id)\"]"
      /// 构造处理器
        let process = CustomTagsProcessor(tagType: entity.type)
      /// 将短码解析成 HTML
        return process.previewProcessor.process(shortCode)
    }
var previewProcessor: Processor {
        let serializer = ShortcodeAttributeSerializer()
        let processor = ShortcodeProcessor(tag: tagType.identifier) { shortCode in
            var html = "<mark style=\"color: #007AF5;\""
            guard let type = shortCode.attributes["type"],
                  let text = shortCode.attributes["text"],
                  let id = shortCode.attributes["id"]
            else {
                return nil
            }
            html += serializer.serialize(key: "type", value: type.value) + " "
            html += serializer.serialize(key: "text", value: text.value) + " "
            html += serializer.serialize(key: "id", value: id.value) + " "
            html += ">\(serializer.serialize(text.value))</mark>;
            return html
        }
        return processor
    }
HTML转自定义标签
ShortcodeAttributeSerializer和ShortcodeProcessor组合可以将 HTML 转为 自定义标签
在编辑器中展示时,为了方便previewProcessor使用了 mark 来标记自定义标签,所以在postProcessor中要使用HTMLProcessor找到 mark 标签进行解析。 Aztec 支持未知标签,底层将未知标签转换成 NSTextAttachment 的子类,实现该功能需要自定义 Attachment 和 Render,下面会讲到。
    var postProcessor: Processor {
        let serializer = ShortcodeAttributeSerializer()
        let processor = HTMLProcessor(for: Element.mark.rawValue) { element in
            guard let type = element.attributes["type"],
                  let text = element.attributes["text"],
                  let id = element.attributes["id"]
            else { return nil }
  
            let html = String(format: "[[%@,%@,%@]]", serializer.serialize(type.value), serializer.serialize(text.value), serializer.serialize(id.value))
            return html
        }
        return processor
    }
使用方法
基于上面的实现,统一了插入自定义标签的方法
enum CustomTagType: Int, CaseIterable {
    case mention = 1
    case link = 2
    case other = 3
    
    var identifier: String {
        switch self {
        case .mention: return "mention"
        case .link: return "link"
        case .other: return "other"
        }
    }
    static var previewProcessors: [Processor] {
        let processors = CustomTagType.allCases.map { CustomTagsProcessor(tagType: $0).previewProcessor }
        return processors
    }
    static var postProcessors: [Processor] {
        let processors = CustomTagType.allCases.map { CustomTagsProcessor(tagType: $0).postProcessor }
        return processors
    }
}
插入时按照上面提供的方案来进行即可。比如插入@someone:我们按照给定的格式提供一个短码 [mention type=\"1\" text=\"@iOS test\" id=\"123\"],经过previewProcessor的处理后会得到如下 HTML,这样可以保证在编辑器中的正确展示。 
<mark style=\"color: #007AF5;\" type=\"1\" text=\"@iOS test\" id=\"123\" >@iOS test</mark>;
上面的 HTML 经过postProcessor的处理后会得到约定好的自定义标签的固定格式[[1,@iOS test,123]],这样可以保证发布时得到正确的内容。
自定义未知HTML标签
Aztec 支持未知HTML标签,其原理是将未知标签转换成 NSTextAttachment 的子类,所以要实现该功能就需要自定义 Attachment 和 Render。
Attachment用来表示未知标签,Render 用来保证正确渲染。Aztec 为我们提供了 HTMLAttachment 和HTMLAttachmentRenderer,但它们可能无法满足我们的需求,所以在使用时需要做定制。
下面是我们的自定义渲染器:
class CustomTagsRender: TextViewAttachmentImageProvider {
    func textView(_ textView: TextView, shouldRender attachment: NSTextAttachment) -> Bool {
        return attachment is HTMLAttachment
    }
    func textView(_ textView: TextView, imageFor attachment: NSTextAttachment, with size: CGSize) -> UIImage? {
        UIGraphicsBeginImageContextWithOptions(size, false, 0)
        guard let attachment = attachment as? HTMLAttachment else {
            return nil
        }
        let message = messageAttributedString(with: attachment)
        let targetRect = boundingRect(for: message, size: size)
        message.draw(in: targetRect)
        let result = UIGraphicsGetImageFromCurrentImageContext()
        UIGraphicsEndImageContext()
        return result
    }
    func textView(_ textView: TextView, boundsFor attachment: NSTextAttachment, with lineFragment: CGRect) -> CGRect {
        guard let attachment = attachment as? HTMLAttachment else {
            return .zero
        }
        let message = messageAttributedString(with: attachment)
        let size = CGSize(width: lineFragment.size.width, height: lineFragment.size.height)
        var rect = boundingRect(for: message, size: size)
        rect.origin.y = UIFont.mainRegular(size: 16).descender
        return rect.integral
    }
}
private extension CustomTagsRender {
    private func boundingRect(for message: NSAttributedString, size: CGSize) -> CGRect {
        let targetBounds = message.boundingRect(with: size, options: [.usesLineFragmentOrigin, .usesFontLeading], context: nil)
        let targetPosition = CGPoint(x: (size.width - targetBounds.width) * 0.5, y: (size.height - targetBounds.height) * 0.5)
        return CGRect(origin: targetPosition, size: targetBounds.size)
    }
    private func messageAttributedString(with attachment: HTMLAttachment) -> NSAttributedString {
        let attributes: [NSAttributedString.Key: Any] = [
            .foregroundColor: UIColor.textLink,
            .font: UIFont.mainRegular(size: 16)
        ]
/// 此处的 tagName 必须与 previewProcessor 和 postProcessor 中使用的标签名一致!!!
        guard let text = extractContent(from: attachment.rawHTML, tagName: "test")
        else {
            return NSAttributedString(string: "[Unknown]", attributes: attributes)
        }
        return NSAttributedString(string: text, attributes: attributes)
    }
    private func extractContent(from html: String, tagName: String) -> String? {
        do {
            let document = try SwiftSoup.parse(html)
            let elements = try document.getElementsByTag(tagName)
            if let element = elements.first() {
                let content = try element.text()
                return content
            }
        } catch {
            print("HTML 解析错误: \(error)")
        }
        return nil
    }
}
效果展示:
![]()
使用该方案可以一键删除 attachment,不支持光标移入,而且 attachment 支持点击,可以做一些扩展操作。缺点就是存在换行展示问题。
插入图片、视频
对于图像、视频、分隔符和表格等嵌入元素,使用的是NSTextAttachment的子类:
插入图片和视频时采用下面的方法,等待上传完成后,通过 identifier 获取对应 attachment,替换必要数据即可。
replaceWithImage(at range: NSRange, sourceURL url: URL, placeHolderImage: UIImage?, identifier: String = UUID().uuidString) -> ImageAttachment
replaceWithVideo(at range: NSRange, sourceURL: URL, posterURL: URL?, placeHolderImage: UIImage?, identifier: String = UUID().uuidString) -> VideoAttachment
图片、视频的自定义属性
Aztec 提供的 Attachment 的属性可能无法满足我们的需求,比如项目需要保留标志视频长度的data-duration属性,这个 Aztec 并没有提供。
幸运的是 Aztec 为我们提供了extraAttributes字段,并且支持通过对extraAttributes进行解析来实现添加任意属性的功能。
    func convert(_ attachment: VideoAttachment, attributes: [NSAttributedString.Key : Any]) -> [Node] {
        let element: ElementNode
        
        ...
        /// 这里将extraAttributes中的自定义属性附加到了 element 上
        for attribute in attachment.extraAttributes {
            element.updateAttribute(named: attribute.name, value: attribute.value)
        }
        element.children = element.children + videoSourceElements(from: attachment)
        return [element]
    }
我们为 VideoAttachment 扩展了自定义属性,然后利用extraAttributes来实现我们的需求,使用时为自定义属性赋值,并在 processor 中进行解析。
extension VideoAttachment {
    @objc var videoId: String? {
        get {
            return extraAttributes[MediaHTMLAttribute.videoAttribute]?.toString()
        }
        set {
            if let nonNilValue = newValue {
                extraAttributes[MediaHTMLAttribute.videoAttribute] = .string(nonNilValue)
            } else {
                extraAttributes.remove(named: MediaHTMLAttribute.videoAttribute)
            }
        }
    }
    var duration: String? {
        get {
            return extraAttributes[MediaHTMLAttribute.videoDuration]?.toString()
        }
        set {
            if let nonNilValue = newValue {
                extraAttributes[MediaHTMLAttribute.videoDuration] = .string(nonNilValue)
            } else {
                extraAttributes.remove(named: MediaHTMLAttribute.videoDuration)
            }
        }
    }
}
图片和视频 processor 输出 HTML 的代码如下:
    static var postProcessor: Processor {
        let serializer = ShortcodeAttributeSerializer()
        let processor = HTMLProcessor(for: "img", replacer: { element in
            var html = "<img "
            if let imgId = element.attributes[MediaAttachment.uploadKey] {
                html += "\(serializer.serialize(key: "id", value: imgId.value))" + " "
            }
            if let src = element.attributes["src"] {
                html += "\(serializer.serialize(key: "src", value: src.value))" + " "
            }
            html += ">"
            return html
        })
        return processor
    }
    static var postProcessor: Processor {
        let serializer = ShortcodeAttributeSerializer()
        let processor = HTMLProcessor(for: "video", replacer: { element in
            var html = "<video "
            if let videoId = element.attributes[MediaAttachment.uploadKey] {
                html += "\(serializer.serialize(key: "id", value: videoId.value))" + " "
            }
            if let src = element.attributes["src"] {
                html += "\(serializer.serialize(key: "src", value: src.value))" + " "
            }
            if let width = element.attributes["width"] {
                html += "\(serializer.serialize(key: "w", value: width.value))" + " "
            }
            if let height = element.attributes["height"] {
                html += "\(serializer.serialize(key: "h", value: height.value))" + " "
            }
            if let posterAttribute = element.attributes["poster"],
               case let .string(posterValue) = posterAttribute.value,
               let posterURL = URL(string: posterValue),
               !posterURL.isFileURL
            {
                html += serializer.serialize(posterAttribute) + " "
            }
            if let duration = element.attributes["duration"] {
                html += "\(serializer.serialize(key: "data-duration", value: duration.value))" + " "
            }
            html += ">"
            return html
        })
        return processor
    }
总结
Aztec能够实现的功能远不止上面介绍到的,它还可以实现列表、自定义引用块、自定义媒体文件展示样式等等,这些功能需要开发者在自行探究中进行持续的发掘。