ZhgChg.Li

iOS NSAttributedString HTMLレンダリング|DocumentType.htmlの代替技術を解説

iOS開発者向けにNSAttributedStringのDocumentType.htmlの問題点を解決。HTMLレンダリングの代替手法で表示品質とパフォーマンスを向上させる具体的な方法を紹介します。

iOS NSAttributedString HTMLレンダリング|DocumentType.htmlの代替技術を解説
本記事は AI による翻訳です。お気づきの点があればお知らせください。

iOS NSAttributedString の HTML レンダリングを自作する

iOS NSAttributedString DocumentType.html の代替案

Photo by Florian Olivo

Photo by Florian Olivo

[TL;DR] 2023/03/12

別の方法で開発し直した ZMarkupParser HTML String を NSAttributedString に変換するツール の技術的な詳細や開発ストーリーは「 手作りHTMLパーサーのあれこれ 」をご覧ください。

起源

昨年のiOS 15リリース以来、アプリは長期間にわたりあるクラッシュ問題でトップを占めています。データによると、過去90日間(2022/03/11〜2022/06/08)で合計2.4K回以上のクラッシュが発生し、1.4K人以上のユーザーに影響を及ぼしています。

この大量クラッシュ問題はデータ上、公式がiOS 15.2以降のバージョンで修正(または発生率を減少)したと考えられ、データは減少傾向を示しています。

最も影響を受けるバージョン: iOS 15.0.X ~ iOS 15.X.X

また、iOS 12やiOS 13でも断続的にクラッシュが発生していることが判明しているため、この問題は以前から存在していたと思われます。ただし、iOS 15の初期バージョンでは発生率がほぼ100%に達していました。

クラッシュの原因:

<compiler-generated> 行 2147483647 専用 @nonobjc NSAttributedString.init(data:options:documentAttributes:)

NSAttributedString の init 時に Crashed: com.apple.main-thread EXC_BREAKPOINT 0x00000001de9d4e44 でクラッシュする問題。

操作がメインスレッドではない可能性もあります。

再現方法:

この問題が大量に発生したとき、開発チームは頭を悩ませました。クラッシュログを何度も検証しましたが問題は見つからず、ユーザーがどのような状況で発生しているのか分かりませんでした。ところが、ある偶然のタイミングで「省電力モード」に切り替えたところ、問題が発生しました!! WTF !!!

解答

一通り調べたところ、ネット上に同様の事例が多数あり、App Developer Forumsでも最初の同じクラッシュ問題の質問を見つけ、公式からの回答を得ました:

  • これは既知のiOS Foundationのバグです:iOS 12から存在しています

  • 複雑で使用制限のないHTMLをレンダリングする場合:WKWebViewを使用してください

  • レンダリング制約あり:自分でHTMLパーサーとレンダーを作成可能

  • 直接Markdownをレンダリング制約として使用:iOS ≥ 15 NSAttributedStringはMarkdown形式を直接使用して文字をレンダリングできます

レンダリング制約 とは、アプリ側で対応可能なレンダリング形式を限定することを意味し、例えば 太字、斜体、ハイパーリンク のみをサポートする場合などです。

補足. 複雑なHTMLのレンダリング — 文章に図解効果を作りたい場合

後端と共同で調整可能なインターフェース:

{
  "content":[
    {"type":"text","value":"第1段のプレーンテキスト"},
    {"type":"text","value":"第2段のプレーンテキスト"},
    {"type":"text","value":"第3段のプレーンテキスト"},
    {"type":"text","value":"第4段のプレーンテキスト"},
    {"type":"image","src":"https://zhgchg.li/logo.png","title":"ZhgChgLi"},
    {"type":"text","value":"第5段のプレーンテキスト"}
  ]
}

Markdownと組み合わせてテキストレンダリングをサポートすることも可能で、またはMediumの方法を参考にしてください:

"Paragraph": {
    "text": "code in text, and link in text, and ZhgChgLi, and bold, and I, only i",
    "markups": [
      {
        "type": "CODE",
        "start": 5,
        "end": 7
      },
      {
        "start": 18,
        "end": 22,
        "href": "http://zhgchg.li",
        "type": "LINK"
      },
      {
        "type": "STRONG",
        "start": 50,
        "end": 63
      },
      {
        "type": "EM",
        "start": 55,
        "end": 69
      }
    ]
}

意味は code in text, and link in text, and ZhgChgLi, and bold, and I, only i この部分の:

- 5文字目から7文字目をコード(`Text`形式で囲む)としてマークする
- 18文字目から22文字目をリンク([Text](URL)形式で囲む)としてマークする
- 50文字目から63文字目を太字(*Text*形式で囲む)としてマークする
- 55文字目から69文字目を斜体(_Text_形式で囲む)としてマークする

規範があり記述可能な構造があれば、アプリはネイティブ方式でレンダリングでき、パフォーマンスとユーザー体験の最適化が実現できます。

UITextViewでのテキスト回り込みの問題については、以前の記事をご参照ください:iOS UITextView 文繞圖編輯器 (Swift)

なぜ?

実際の解決策に入る前に、まず問題の本質に立ち返りたいと思います。個人的には、この問題の主な原因はApple自体ではなく、公式のバグがあくまで引き金に過ぎないと考えています。

問題の主な原因は App 側が Web としてレンダリングされていること です。利点は、Web 開発が速く、同じ API エンドポイントでクライアントを区別せずに HTML を渡せるため、柔軟に任意のコンテンツを表示できる点です。欠点は、HTML が App の一般的なインターフェースでなく、App エンジニアが HTML を理解しているとは限らないこと、パフォーマンスが非常に悪いこと、メインスレッドでしか動作しないこと、開発段階で結果を予測できず、対応仕様を確認できないことです。

問題を遡って探ると、多くの場合は元の要件が不明確で、どの仕様をAppがサポートすべきか確定できず、手早く済ませるためにHTMLを直接AppとWebのインターフェースとして使ってしまったことが原因です。

パフォーマンスが非常に悪い

パフォーマンスの補足として、実測で NSAttributedString DocumentType.html を直接使用する方法と自作のレンダリング方法では5〜20倍の速度差があることが確認されています。

より良い

Appで使う以上、より良い方法はApp開発の視点から出発することです。Appにとって要件の変更コストはWebよりもはるかに高いです。効果的なApp開発は仕様に基づく反復的な調整を前提とし、現時点で対応可能な仕様を確定し、後から変更が必要な場合は時間を取って仕様を拡張します。すぐに変更できるわけではないため、コミュニケーションコストを減らし、作業効率を高められます。

  • 要件範囲の確認

  • サポートされている仕様の確認

  • インターフェース仕様の確認(Markdown/BBCodeなどを使い続けるか、HTMLを使う場合も構いませんが、例えば <b>/<i>/<a>/<u> のみなど制約を設け、プログラム側で開発者に明確に伝える必要があります)

  • 自作のレンダリング機構の実装

  • メンテナンスおよびサポート仕様の継続

[2023/02/27 更新] [TL;DR]:

更新した方法では、XMLParserを使用せず、許容度は0です:

<br> / <Congratulation!> / <b>Bold<i>Bold+Italic</b>Italic</i> 以上の3つの可能なケースは、XMLParserで解析するとエラーをスローして空白が表示されます。
XMLParserを使う場合、HTML文字列は完全にXMLルールに準拠している必要があり、ブラウザやNSAttributedString.DocumentType.htmlのようにエラーを許容して正常表示することはできません。

純粋な Swift で開発し、Regex を使って HTML タグを解析し、Tokenization を経てタグの正確性(終了タグのないタグやずれたタグの修正)を分析・修正し、抽象構文木に変換します。最終的に Visitor パターンを使って HTML タグと抽象スタイルを対応させ、最終的な NSAttributedString を得ます。なお、いかなるパーサーライブラリも使用しません。

— —

どうやって?

木已成舟、話を戻しますが、現在HTMLでNSAttributedStringをレンダリングしています。では、先述のクラッシュやパフォーマンス問題をどう解決すればよいのでしょうか?

インスパイアされたもの

Strip HTML HTMLを除去する

HTMLレンダリングについて話す前に、まずHTMLの除去(Strip HTML)について触れます。前述の「Why?」章で述べたように、アプリがどこでHTMLを受け取り、どのようなHTMLを受け取るかは仕様で明確に定めるべきです。アプリ側が「場合によっては」HTMLを受け取り、それを除去する必要がある、という曖昧な状況ではなく。

昔の上司の名言を借りれば:「これはちょっとおかしいんじゃない?」

オプション1. NSAttributedString

let data = "<div>Text</div>".data(using: .unicode)!
let attributed = try NSAttributedString(data: data, options: [.documentType: NSAttributedString.DocumentType.html, .characterEncoding: String.Encoding.utf8.rawValue], documentAttributes: nil)
let string = attributed.string
  • NSAttributedStringでHTMLをレンダリングし、その後stringを取得すると、きれいなStringになります。

  • 問題は本章の問題と同様で、iOS 15ではクラッシュが発生しやすく、パフォーマンスが悪く、メインスレッドでのみ操作可能です。

オプション2. 正規表現

htmlString = "<div>Test</div>"
// 正規表現を使ってHTMLタグを除去する
htmlString.replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression, range: nil)
  • 最も簡単で効果的な方法

  • Regex は完全に正確ではありません。例えば <p foo=">now what?">Paragraph</p> は有効なHTMLですが、誤って削除されてしまいます。

オプション3. XMLParser

参考 SwiftRichString の方法で、Foundation の XMLParser を使い、HTMLをXMLとして解析して独自にHTMLパーサー&ストリップ機能を実装します。

import UIKit
// 参照: https://github.com/malcommac/SwiftRichString
final class HTMLStripper: NSObject, XMLParserDelegate {

    private static let topTag = "source"
    private var xmlParser: XMLParser
    
    private(set) var storedString: String
    
    // XMLパーサは時々文字列を分割することがあり、これがローカライズに影響する
    // 文字列変換を壊す場合があります。これを回避するために currentString 変数を使って
    // 部分文字列を蓄積し、現在の要素が終了するか新しい要素が開始された時に
    // まとめて読み出します。
    private var currentString: String?
    
    // MARK: - 初期化

    init(string: String) throws {
        let xmlString = HTMLStripper.escapeWithUnicodeEntities(string)
        let xml = "<\(HTMLStripper.topTag)>\(xmlString)</\(HTMLStripper.topTag)>"
        guard let data = xml.data(using: String.Encoding.utf8) else {
            throw XMLParserInitError("UTF8に変換できません")
        }
        
        self.xmlParser = XMLParser(data: data)
        self.storedString = ""
        
        super.init()
        
        xmlParser.shouldProcessNamespaces = false
        xmlParser.shouldReportNamespacePrefixes = false
        xmlParser.shouldResolveExternalEntities = false
        xmlParser.delegate = self
    }
    
    /// 解析して文字列を生成する
    func parse() throws -> String {
        guard xmlParser.parse() else {
            let line = xmlParser.lineNumber
            let shiftColumn = (line == 1)
            let shiftSize = HTMLStripper.topTag.lengthOfBytes(using: String.Encoding.utf8) + 2
            let column = xmlParser.columnNumber - (shiftColumn ? shiftSize : 0)
            
            throw XMLParserError(parserError: xmlParser.parserError, line: line, column: column)
        }
        
        return storedString
    }
    
    // MARK: XMLParserDelegate
    
    @objc func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName qName: String?, attributes attributeDict: [String: String]) {
        foundNewString()
    }
    
    @objc func parser(_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName qName: String?) {
        foundNewString()
    }
    
    @objc func parser(_ parser: XMLParser, foundCharacters string: String) {
        currentString = (currentString ?? "").appending(string)
    }
    
    // MARK: サポート用プライベートメソッド
    
    func foundNewString() {
        if let currentString = currentString {
            storedString.append(currentString)
            self.currentString = nil
        }
    }
    
    // HTMLエンティティ / HTML16進数の処理
    // NSXMLParserがサポートしない文字を指定のエンコードの10進エンティティに置換するための
    // 文字列エスケープを行います。
    // 例えば文字列に '&' が含まれるとパーサがスタイルを壊します。
    // このオプションはデフォルトで有効です。
    // 参照: https://github.com/malcommac/SwiftRichString/blob/e0b72d5c96968d7802856d2be096202c9798e8d1/Sources/SwiftRichString/Support/XMLStringBuilder.swift
    static func escapeWithUnicodeEntities(_ string: String) -> String {
        guard let escapeAmpRegExp = try? NSRegularExpression(pattern: "&(?!(#[0-9]{2,4}\\|[A-z]{2,6});)", options: NSRegularExpression.Options(rawValue: 0)) else {
            return string
        }
        
        let range = NSRange(location: 0, length: string.count)
        return escapeAmpRegExp.stringByReplacingMatches(in: string,
                                                        options: NSRegularExpression.MatchingOptions(rawValue: 0),
                                                        range: range,
                                                        withTemplate: "&amp;")
    }
}


let test = "我<br/><a href=\"http://google.com\">同意</a>提供<b><i>個</i>人</b>身分證字號/護照/居留<span style=\"color:#FF0000;font-size:20px;word-spacing:10px;line-height:10px\">證號碼</span>,以供<i>跨境物流</i>方通關<span style=\"background-color:#00FF00;\">使用</span>,並已<img src=\"g.png\"/>了解跨境<br/>商品之物<p>流需</p>求"

let stripper = try HTMLStripper(string: test)
print(try! stripper.parse())

// 我同意提供個人身分證 字號/護照/居留證號碼,以供跨境物流方通關使用,並已了解跨境商品之物流需求

FoundationのXML Parserを使ってStringを処理し、XMLParserDelegateを実装します。currentStringに文字列を保持しますが、文字列は複数に分割されることがあるため、foundCharactersは複数回呼ばれる可能性があります。didStartElementdidEndElementで文字列の開始と終了を検出し、現在の結果を保存してcurrentStringをクリアします。

  • 利点は、HTMLエンティティを実際の文字に変換できることです。例:&#103; -> g

  • 利点は複雑な実装が可能で、不正なHTMLに遭遇するとXMLParserが失敗することです。例:<br><br/>と書き忘れた場合など。

個人的には単純にHTMLをStripする場合、Option 2. の方が良い方法だと思います。この方法を紹介するのは、HTMLのレンダリングも同じ原理を使っているため、まずはこれを簡単な例として使うからです :)

HTML Render w/XMLParser

XMLParserを使って自作実装し、Stripの原理と同様に、解析したタグに応じて対応するレンダリング方法を追加できます。

要求仕様:

  • 解析したいタグの拡張をサポート

  • タグのデフォルトスタイル設定をサポート 例:<a>タグにリンクスタイルを適用

  • style 属性の解析をサポートしています。HTMLでは style="color:red" のように表示スタイルを指定します。

  • スタイルは文字の太さ、サイズ、下線、行間、文字間隔、背景色、文字色の変更に対応しています。

  • Imageタグ、Tableタグなどの複雑なタグはサポートしていません。

皆さんはご自身の仕様に合わせて機能を削減できます。例えば、背景色の調整をサポートしない場合は、背景色を設定するためのインターフェースを用意する必要はありません。

本文はあくまで概念実装であり、アーキテクチャ上のベストプラクティスではありません。明確な仕様や使用方法がある場合は、メンテナンス性や拡張性を高めるためにデザインパターンの適用を検討してください。

⚠️⚠️⚠️ 注意 ⚠️⚠️⚠️

再度のご注意ですが、もしあなたのAppが新規であったり、Markdown形式に完全移行できる可能性がある場合は、上記の方法を採用することをおすすめします。本記事で紹介した自作レンダラーは複雑すぎて、Markdownよりパフォーマンスが良くなることはありません。

iOS < 15 でもネイティブMarkdownをサポートしていなくても、Githubで有名な方が作ったMarkdownパーサーのソリューションを見つけることができます。

HTMLTagParser

protocol HTMLTagParser {
    static var tag: String { get } // 解析したいタグ名を宣言, 例: a
    var storedHTMLAttributes: [String: String]? { get set } // Attributed解析結果をここに保存, 例: href, style
    var style: AttributedStringStyle? { get } // このタグに適用したいスタイル
    
    func render(attributedString: inout NSMutableAttributedString) // HTMLからattributedStringへのレンダリングロジックを実装
}

解析可能なHTMLタグの実体を宣言し、拡張と管理を容易にします。

AttributedStringStyle

protocol AttributedStringStyle {
    var font: UIFont? { get set }
    var color: UIColor? { get set }
    var backgroundColor: UIColor? { get set }
    var wordSpacing: CGFloat? { get set }
    var paragraphStyle: NSParagraphStyle? { get set }
    var customs: [NSAttributedString.Key: Any]? { get set } // 万能な設定口。対応できる仕様が確定したら抽象化し、この口を閉じることを推奨
    func render(attributedString: inout NSMutableAttributedString)
}


// 抽象的な実装
extension AttributedStringStyle {
    func render(attributedString: inout NSMutableAttributedString) {
        let range = NSMakeRange(0, attributedString.length)
        if let font = font {
            attributedString.addAttribute(NSAttributedString.Key.font, value: font, range: range)
        }
        if let color = color {
            attributedString.addAttribute(NSAttributedString.Key.foregroundColor, value: color, range: range)
        }
        if let backgroundColor = backgroundColor {
            attributedString.addAttribute(NSAttributedString.Key.backgroundColor, value: backgroundColor, range: range)
        }
        if let wordSpacing = wordSpacing {
            attributedString.addAttribute(NSAttributedString.Key.kern, value: wordSpacing as Any, range: range)
        }
        if let paragraphStyle = paragraphStyle {
            attributedString.addAttribute(NSAttributedString.Key.paragraphStyle, value: paragraphStyle, range: range)
        }
        if let customAttributes = customs {
            attributedString.addAttributes(customAttributes, range: range)
        }
    }
}

Tagで設定可能なスタイルを宣言。

HTMLStyleAttributedParser

// 下記のタグ属性のみサポート
// 色、フォントサイズ、行間、文字間隔、背景色を設定可能

enum HTMLStyleAttributedParser: String {
    case color = "color"
    case fontSize = "font-size"
    case lineHeight = "line-height"
    case wordSpacing = "word-spacing"
    case backgroundColor = "background-color"
    
    func render(attributedString: inout NSMutableAttributedString, value: String) -> Bool {
        let range = NSMakeRange(0, attributedString.length)
        switch self {
        case .color:
            if let color = convertToiOSColor(value) {
                attributedString.addAttribute(NSAttributedString.Key.foregroundColor, value: color, range: range)
                return true
            }
        case .backgroundColor:
            if let color = convertToiOSColor(value) {
                attributedString.addAttribute(NSAttributedString.Key.backgroundColor, value: color, range: range)
                return true
            }
        case .fontSize:
            if let size = convertToiOSSize(value) {
                attributedString.addAttribute(NSAttributedString.Key.font, value: UIFont.systemFont(ofSize: CGFloat(size)), range: range)
                return true
            }
        case .lineHeight:
            if let size = convertToiOSSize(value) {
                let paragraphStyle = NSMutableParagraphStyle()
                paragraphStyle.lineSpacing = size
                attributedString.addAttribute(NSAttributedString.Key.paragraphStyle, value: paragraphStyle, range: range)
                return true
            }
        case .wordSpacing:
            if let size = convertToiOSSize(value) {
                attributedString.addAttribute(NSAttributedString.Key.kern, value: size, range: range)
                return true
            }
        }
        
        return false
    }
    
    // 36px -> 36 に変換
    private func convertToiOSSize(_ string: String) -> CGFloat? {
        guard let regex = try? NSRegularExpression(pattern: "^([0-9]+)"),
              let firstMatch = regex.firstMatch(in: string, options: [], range: NSRange(location: 0, length: string.utf16.count)),
              let range = Range(firstMatch.range, in: string),
              let size = Float(String(string[range])) else {
            return nil
        }
        return CGFloat(size)
    }
    
    // HTMLの16進カラーコード #ffffff をUIKitの色に変換
    private func convertToiOSColor(_ hexString: String) -> UIColor? {
        var cString: String = hexString.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()

        if cString.hasPrefix("#") {
            cString.remove(at: cString.startIndex)
        }

        if (cString.count) != 6 {
            return nil
        }

        var rgbValue: UInt64 = 0
        Scanner(string: cString).scanHexInt64(&rgbValue)

        return UIColor(
            red: CGFloat((rgbValue & 0xFF0000) >> 16) / 255.0,
            green: CGFloat((rgbValue & 0x00FF00) >> 8) / 255.0,
            blue: CGFloat(rgbValue & 0x0000FF) / 255.0,
            alpha: CGFloat(1.0)
        )
    }
}

Style Attributed Parserでstyle="color:red;font-size:16px"を解析することを実装しましたが、CSSスタイルには非常に多くの設定可能なスタイルがあるため、サポート範囲を列挙する必要があります。

extension HTMLTagParser {

    func render(attributedString: inout NSMutableAttributedString) {
        defaultStyleRender(attributedString: &attributedString)
    }
    
    func defaultStyleRender(attributedString: inout NSMutableAttributedString) {
        // NSMutableAttributedStringにデフォルトスタイルを設定
        style?.render(attributedString: &attributedString)
        
        // 存在する場合、NSMutableAttributedStringにHTMLのstyle属性(style="color:red;background-color:black")を設定・上書き
        // どのHTMLタグもstyle属性を持てる
        if let style = storedHTMLAttributes?["style"] {
            let styles = style.split(separator: ";").map { $0.split(separator: ":") }.filter { $0.count == 2 }
            for style in styles {
                let key = String(style[0])
                let value = String(style[1])
                
                if let styleAttributed = HTMLStyleAttributedParser(rawValue: key), styleAttributed.render(attributedString: &attributedString, value: value) {
                    print("サポートされていないスタイル属性または値[\(key):\(value)]")
                }
            }
        }
    }
}

HTMLStyleAttributedParser と HTMLStyleAttributedParser の抽象実装を適用する。

一部の Tag Parser と AttributedStringStyle の実装例

struct LinkStyle: AttributedStringStyle {
   var font: UIFont? = UIFont.systemFont(ofSize: 14)
   var color: UIColor? = UIColor.blue
   var backgroundColor: UIColor? = nil
   var wordSpacing: CGFloat? = nil
   var paragraphStyle: NSParagraphStyle?
   var customs: [NSAttributedString.Key: Any]? = [.underlineStyle: NSUnderlineStyle.single.rawValue]
}

struct ATagParser: HTMLTagParser {
    // <a></a>
    static let tag: String = "a"
    var storedHTMLAttributes: [String: String]? = nil
    let style: AttributedStringStyle? = LinkStyle()
    
    func render(attributedString: inout NSMutableAttributedString) {
        defaultStyleRender(attributedString: &attributedString)
        if let href = storedHTMLAttributes?["href"], let url = URL(string: href) {
            let range = NSMakeRange(0, attributedString.length)
            attributedString.addAttribute(NSAttributedString.Key.link, value: url, range: range)
        }
    }
}
struct BoldStyle: AttributedStringStyle {
   var font: UIFont? = UIFont.systemFont(ofSize: 14, weight: .bold)
   var color: UIColor? = UIColor.black
   var backgroundColor: UIColor? = nil
   var wordSpacing: CGFloat? = nil
   var paragraphStyle: NSParagraphStyle?
   var customs: [NSAttributedString.Key: Any]? = [.underlineStyle: NSUnderlineStyle.single.rawValue]
}

struct BoldTagParser: HTMLTagParser {
    // <b></b>
    static let tag: String = "b"
    var storedHTMLAttributes: [String: String]? = nil
    let style: AttributedStringStyle? = BoldStyle()
}
struct SpanTagParser: HTMLTagParser {
    // <span></span>
    static let tag: String = "span"
    var storedHTMLAttributes: [String: String]? = nil
    var style: AttributedStringStyle? = DefaultTextStyle()
}

HTMLToAttributedStringParser: XMLParserDelegate のコア実装

// Ref: https://github.com/malcommac/SwiftRichString
final class HTMLToAttributedStringParser: NSObject {
    
    private static let topTag = "source"
    private var xmlParser: XMLParser?
    
    private(set) var attributedString: NSMutableAttributedString = NSMutableAttributedString()
    private(set) var supportedTagRenders: [HTMLTagParser] = []
    private let defaultStyle: AttributedStringStyle
    
    /// 各フラグメントに適用されるスタイル
    private var renderingTagRenders: [HTMLTagParser] = []

    // XMLパーサーは時々文字列を分割し、ローカライズに敏感な
    // 文字列変換を壊すことがあります。これを回避するために
    // currentString変数で部分文字列を蓄積し、
    // 要素の終了時または新しい要素開始時にまとめて読み出します。
    private var currentString: String?
    
    // MARK: - 初期化

    init(defaultStyle: AttributedStringStyle) {
        self.defaultStyle = defaultStyle
        super.init()
    }
    
    func register(_ tagRender: HTMLTagParser) {
        if let index = supportedTagRenders.firstIndex(where: { type(of: $0).tag == type(of: tagRender).tag }) {
            supportedTagRenders.remove(at: index)
        }
        supportedTagRenders.append(tagRender)
    }
    
    /// 解析してAttributedStringを生成する
    func parse(string: String) throws -> NSAttributedString {
        var xmlString = HTMLToAttributedStringParser.escapeWithUnicodeEntities(string)
        
        // <br/> が正しいXML形式か確認
        // Webでは<br>で表示されることがあるが、<br>は有効なXMLではない
        xmlString = xmlString.replacingOccurrences(of: "<br>", with: "<br/>")
        
        let xml = "<\(HTMLToAttributedStringParser.topTag)>\(xmlString)</\(HTMLToAttributedStringParser.topTag)>"
        guard let data = xml.data(using: String.Encoding.utf8) else {
            throw XMLParserInitError("UTF8に変換できません")
        }
        
        let xmlParser = XMLParser(data: data)
        xmlParser.shouldProcessNamespaces = false
        xmlParser.shouldReportNamespacePrefixes = false
        xmlParser.shouldResolveExternalEntities = false
        xmlParser.delegate = self
        self.xmlParser = xmlParser
        
        attributedString = NSMutableAttributedString()
        
        guard xmlParser.parse() else {
            let line = xmlParser.lineNumber
            let shiftColumn = (line == 1)
            let shiftSize = HTMLToAttributedStringParser.topTag.lengthOfBytes(using: String.Encoding.utf8) + 2
            let column = xmlParser.columnNumber - (shiftColumn ? shiftSize : 0)
            
            throw XMLParserError(parserError: xmlParser.parserError, line: line, column: column)
        }
        
        return attributedString
    }
}

// MARK: Private Method

private extension HTMLToAttributedStringParser {
    func enter(element elementName: String, attributes: [String: String]) {
        // elementName = タグ名、例: a,span,div...
        guard elementName != HTMLToAttributedStringParser.topTag else {
            return
        }
        
        if let index = supportedTagRenders.firstIndex(where: { type(of: $0).tag == elementName }) {
            var tagRender = supportedTagRenders[index]
            tagRender.storedHTMLAttributes = attributes
            renderingTagRenders.append(tagRender)
        }
    }
    
    func exit(element elementName: String) {
        if !renderingTagRenders.isEmpty {
            renderingTagRenders.removeLast()
        }
    }
    
    func foundNewString() {
        if let currentString = currentString {
            // currentString != nil、例: <i>currentString</i>
            var newAttributedString = NSMutableAttributedString(string: currentString)
            if !renderingTagRenders.isEmpty {
                for (key, tagRender) in renderingTagRenders.enumerated() {
                    // スタイルを適用
                    tagRender.render(attributedString: &newAttributedString)
                    renderingTagRenders[key].storedHTMLAttributes = nil
                }
            } else {
                defaultStyle.render(attributedString: &newAttributedString)
            }
            attributedString.append(newAttributedString)
            self.currentString = nil
        } else {
            // currentString == nil、例: <br/>
            var newAttributedString = NSMutableAttributedString()
            for (key, tagRender) in renderingTagRenders.enumerated() {
                // スタイルを適用
                tagRender.render(attributedString: &newAttributedString)
                renderingTagRenders[key].storedHTMLAttributes = nil
            }
            attributedString.append(newAttributedString)
        }
    }
}

// MARK: Helper

extension HTMLToAttributedStringParser {
    // htmlエンティティ / html16進数を処理
    // NSXMLParserでサポートされていない文字を
    // 10進エンティティに置換してエスケープ処理を行う
    // 例えば文字列に'&'が含まれるとパーサーがスタイルを壊す
    // このオプションはデフォルトで有効
    // ref: https://github.com/malcommac/SwiftRichString/blob/e0b72d5c96968d7802856d2be096202c9798e8d1/Sources/SwiftRichString/Support/XMLStringBuilder.swift
    static func escapeWithUnicodeEntities(_ string: String) -> String {
        guard let escapeAmpRegExp = try? NSRegularExpression(pattern: "&(?!(#[0-9]{2,4}\\|[A-z]{2,6});)", options: NSRegularExpression.Options(rawValue: 0)) else {
            return string
        }
        
        let range = NSRange(location: 0, length: string.count)
        return escapeAmpRegExp.stringByReplacingMatches(in: string,
                                                        options: NSRegularExpression.MatchingOptions(rawValue: 0),
                                                        range: range,
                                                        withTemplate: "&amp;")
    }
}

// MARK: XMLParserDelegate

extension HTMLToAttributedStringParser: XMLParserDelegate {
    func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName qName: String?, attributes attributeDict: [String: String]) {
        foundNewString()
        enter(element: elementName, attributes: attributeDict)
    }
    
    func parser(_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName qName: String?) {
        foundNewString()
        guard elementName != HTMLToAttributedStringParser.topTag else {
            return
        }
        
        exit(element: elementName)
    }
    
    func parser(_ parser: XMLParser, foundCharacters string: String) {
        currentString = (currentString ?? "").appending(string)
    }
}

Strip のロジックを適用すると、分解した構造を組み合わせて elementName から現在のタグを判別し、対応するタグパーサーを適用して定義済みのスタイルを適用できます。

テスト結果

let test = "私<br/><a href=\"http://google.com\">同意</a>して<b><i>個人</i></b>の身分証番号/パスポート/在留<span style=\"color:#FF0000;font-size:20px;word-spacing:10px;line-height:10px\">証番号</span>を提供し、<i>越境物流</i>の通関<span style=\"background-color:#00FF00;\">利用</span>のために<img src=\"g.png\"/>理解しています越境<br/>商品の物<p>流の必要性</p>"
let render = HTMLToAttributedStringParser(defaultStyle: DefaultTextStyle())
render.register(ATagParser())
render.register(BoldTagParser())
render.register(SpanTagParser())
//...
print(try! render.parse(string: test))

// Result:
// 私{
//     NSColor = "UIExtendedGrayColorSpace 0 1";
//     NSFont = "\".SFNS-Regular 14.00 pt. P [] (0x13a012970) fobj=0x13a012970, spc=3.79\"";
//     NSParagraphStyle = "Alignment 4, LineSpacing 3, ParagraphSpacing 0, ParagraphSpacingBefore 0, HeadIndent 0, TailIndent 0, FirstLineHeadIndent 0, LineHeight 0/0, LineHeightMultiple 0, LineBreakMode 0, Tabs (\n    28L,\n    56L,\n    84L,\n    112L,\n    140L,\n    168L,\n    196L,\n    224L,\n    252L,\n    280L,\n    308L,\n    336L\n), DefaultTabInterval 0, Blocks (\n), Lists (\n), BaseWritingDirection -1, HyphenationFactor 0, TighteningForTruncation NO, HeaderLevel 0 LineBreakStrategy 0 PresentationIntents (\n) ListIntentOrdinal 0 CodeBlockIntentLanguageHint ''";
// }同意{
//     NSColor = "UIExtendedSRGBColorSpace 0 0 1 1";
//     NSFont = "\".SFNS-Regular 14.00 pt. P [] (0x13a012970) fobj=0x13a012970, spc=3.79\"";
//     NSLink = "http://google.com";
//     NSUnderline = 1;
// }して{
//     NSColor = "UIExtendedGrayColorSpace 0 1";
//     NSFont = "\".SFNS-Regular 14.00 pt. P [] (0x13a012970) fobj=0x13a012970, spc=3.79\"";
//     NSParagraphStyle = "Alignment 4, LineSpacing 3, ParagraphSpacing 0, ParagraphSpacingBefore 0, HeadIndent 0, TailIndent 0, FirstLineHeadIndent 0, LineHeight 0/0, LineHeightMultiple 0, LineBreakMode 0, Tabs (\n    28L,\n    56L,\n    84L,\n    112L,\n    140L,\n    168L,\n    196L,\n    224L,\n    252L,\n    280L,\n    308L,\n    336L\n), DefaultTabInterval 0, Blocks (\n), Lists (\n), BaseWritingDirection -1, HyphenationFactor 0, TighteningForTruncation NO, HeaderLevel 0 LineBreakStrategy 0 PresentationIntents (\n) ListIntentOrdinal 0 CodeBlockIntentLanguageHint ''";
// }個人{
//     NSColor = "UIExtendedGrayColorSpace 0 1";
//     NSFont = "\".SFNS-Bold 14.00 pt. P [] (0x13a013870) fobj=0x13a013870, spc=3.46\"";
//     NSUnderline = 1;
// }の身分証番号/パスポート/在留{
//     NSColor = "UIExtendedGrayColorSpace 0 1";
//     NSFont = "\".SFNS-Regular 14.00 pt. P [] (0x13a012970) fobj=0x13a012970, spc=3.79\"";
//     NSParagraphStyle = "Alignment 4, LineSpacing 3, ParagraphSpacing 0, ParagraphSpacingBefore 0, HeadIndent 0, TailIndent 0, FirstLineHeadIndent 0, LineHeight 0/0, LineHeightMultiple 0, LineBreakMode 0, Tabs (\n    28L,\n    56L,\n    84L,\n    112L,\n    140L,\n    168L,\n    196L,\n    224L,\n    252L,\n    280L,\n    308L,\n    336L\n), DefaultTabInterval 0, Blocks (\n), Lists (\n), BaseWritingDirection -1, HyphenationFactor 0, TighteningForTruncation NO, HeaderLevel 0 LineBreakStrategy 0 PresentationIntents (\n) ListIntentOrdinal 0 CodeBlockIntentLanguageHint ''";
// }証番号{
//     NSColor = "UIExtendedSRGBColorSpace 1 0 0 1";
//     NSFont = "\".SFNS-Regular 20.00 pt. P [] (0x13a015fa0) fobj=0x13a015fa0, spc=4.82\"";
//     NSKern = 10;
//     NSParagraphStyle = "Alignment 4, LineSpacing 10, ParagraphSpacing 0, ParagraphSpacingBefore 0, HeadIndent 0, TailIndent 0, FirstLineHeadIndent 0, LineHeight 0/0, LineHeightMultiple 0, LineBreakMode 0, Tabs (\n    28L,\n    56L,\n    84L,\n    112L,\n    140L,\n    168L,\n    196L,\n    224L,\n    252L,\n    280L,\n    308L,\n    336L\n), DefaultTabInterval 0, Blocks (\n), Lists (\n), BaseWritingDirection -1, HyphenationFactor 0, TighteningForTruncation NO, HeaderLevel 0 LineBreakStrategy 0 PresentationIntents (\n) ListIntentOrdinal 0 CodeBlockIntentLanguageHint ''";
// }を越境物流の通関のために{
//     NSColor = "UIExtendedGrayColorSpace 0 1";
//     NSFont = "\".SFNS-Regular 14.00 pt. P [] (0x13a012970) fobj=0x13a012970, spc=3.79\"";
//     NSParagraphStyle = "Alignment 4, LineSpacing 3, ParagraphSpacing 0, ParagraphSpacingBefore 0, HeadIndent 0, TailIndent 0, FirstLineHeadIndent 0, LineHeight 0/0, LineHeightMultiple 0, LineBreakMode 0, Tabs (\n    28L,\n    56L,\n    84L,\n    112L,\n    140L,\n    168L,\n    196L,\n    224L,\n    252L,\n    280L,\n    308L,\n    336L\n), DefaultTabInterval 0, Blocks (\n), Lists (\n), BaseWritingDirection -1, HyphenationFactor 0, TighteningForTruncation NO, HeaderLevel 0 LineBreakStrategy 0 PresentationIntents (\n) ListIntentOrdinal 0 CodeBlockIntentLanguageHint ''";
// }利用{
//     NSBackgroundColor = "UIExtendedSRGBColorSpace 0 1 0 1";
//     NSColor = "UIExtendedGrayColorSpace 0 1";
//     NSFont = "\".SFNS-Regular 14.00 pt. P [] (0x13a012970) fobj=0x13a012970, spc=3.79\"";
//     NSParagraphStyle = "Alignment 4, LineSpacing 3, ParagraphSpacing 0, ParagraphSpacingBefore 0, HeadIndent 0, TailIndent 0, FirstLineHeadIndent 0, LineHeight 0/0, LineHeightMultiple 0, LineBreakMode 0, Tabs (\n    28L,\n    56L,\n    84L,\n    112L,\n    140L,\n    168L,\n    196L,\n    224L,\n    252L,\n    280L,\n    308L,\n    336L\n), DefaultTabInterval 0, Blocks (\n), Lists (\n), BaseWritingDirection -1, HyphenationFactor 0, TighteningForTruncation NO, HeaderLevel 0 LineBreakStrategy 0 PresentationIntents (\n) ListIntentOrdinal 0 CodeBlockIntentLanguageHint ''";
// }し、越境商品の物流の必要性を理解しています{
//     NSColor = "UIExtendedGrayColorSpace 0 1";
//     NSFont = "\".SFNS-Regular 14.00 pt. P [] (0x13a012970) fobj=0x13a012970, spc=3.79\"";
//     NSParagraphStyle = "Alignment 4, LineSpacing 3, ParagraphSpacing 0, ParagraphSpacingBefore 0, HeadIndent 0, TailIndent 0, FirstLineHeadIndent 0, LineHeight 0/0, LineHeightMultiple 0, LineBreakMode 0, Tabs (\n    28L,\n    56L,\n    84L,\n    112L,\n    140L,\n    168L,\n    196L,\n    224L,\n    252L,\n    280L,\n    308L,\n    336L\n), DefaultTabInterval 0, Blocks (\n), Lists (\n), BaseWritingDirection -1, HyphenationFactor 0, TighteningForTruncation NO, HeaderLevel 0 LineBreakStrategy 0 PresentationIntents (\n) ListIntentOrdinal 0 CodeBlockIntentLanguageHint ''";
// }

表示結果:

完了!

これで XMLParser を使って独自に HTML レンダリング機能を実装し、拡張性と仕様の明確さを保つことができました。コード上で管理でき、現在アプリが対応している文字列レンダリングの種類を把握できます。

完全なGithubリポジトリは以下の通りです

この記事は個人ブログでも同時に公開しています: [こちらをクリック]

ご質問やご意見がありましたら、こちらからご連絡ください

Post は Medium から ZMediumToMarkdown によって変換されました。

GitHub で編集
この記事を改善
本記事は Medium で初公開
オリジナルを読む
この記事をシェア
リンクをコピー · SNS でシェア
ZhgChgLi
著者

ZhgChgLi

An iOS, web, and automation developer from Taiwan 🇹🇼 who also loves sharing, traveling, and writing.

コメント