記事

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](https://unsplash.com/@florianolv?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"}

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%に達していました。

クラッシュの原因:

1
<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のレンダリング — 文飾効果を作成したい場合

バックエンドと共同で調整可能なインターフェース:

1
2
3
4
5
6
7
8
9
10
{
  "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 の方法を参考にする:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
"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 この文章の:

1
2
3
4
- 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 更新] [要約]:

更新された方法では、XMLParserを使用せず、許容誤差を0にしています:

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

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

— —

どうやって?

木は既に水に流れたので、本題に戻ります。現在 HTML を使って NSAttributedString をレンダリングしていますが、先述のクラッシュやパフォーマンス問題をどのように解決すればよいでしょうか?

インスパイアされたもの

Strip HTML HTMLを除去する

HTMLレンダリングの前にまずStrip HTMLについて話しますが、前文の「Why?」章で述べたように、アプリがどこでHTMLを受け取り、どのHTMLを受け取るかは仕様で明確に決めるべきです。アプリ側が「場合によっては」HTMLを受け取るのでStripする、という考え方ではありません。

前の上司の名言を借りれば:「これはやりすぎだろ?」

オプション1. NSAttributedString

1
2
3
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 を取得すると、きれいな文字列になります。

  • 問題は本章の問題と同様で、iOS 15 ではクラッシュしやすく、パフォーマンスが悪く、メインスレッドでしか操作できません。

オプション 2. 正規表現

1
2
htmlString = "<div>Test</div>"
htmlString.replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression, range: nil)
  • 最も簡単で効果的な方法

  • 正規表現は完全に正確であることを保証しません。例えば <p foo=">now what?">Paragraph</p> は有効なHTMLですが、Stripが誤動作します。

オプション 3. XMLParser

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
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 を使って文字列を処理し、XMLParserDelegate を実装します。currentString に文字列を保持しますが、文字列は複数に分割されることがあるため、foundCharacters は繰り返し呼ばれる可能性があります。didStartElementdidEndElement で文字列の開始と終了を検出し、現在の結果を保存して currentString をクリアします。

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

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

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

HTML Render w/XMLParser

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

要件仕様:

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

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

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

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

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

皆さんは自分の仕様やニーズに応じて機能を削減できます。たとえば、背景色の調整が不要な場合は、背景色設定用のインターフェースを実装する必要はありません。

本文はあくまでコンセプトの実装であり、アーキテクチャ上のベストプラクティスではありません。明確な仕様や使用方法がある場合は、設計パターンを適用して、保守性や拡張性を高めることを検討してください。

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

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

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

HTMLTagParser

1
2
3
4
5
6
7
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
// 以下のタグ属性のみサポート
// 色、フォントサイズ、行間、文字間隔、背景色を設定可能

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スタイルには非常に多くの設定項目があるため、対応可能な範囲を列挙する必要があります。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
extension HTMLTagParser {

    func render(attributedString: inout NSMutableAttributedString) {
        defaultStyleRender(attributedString: &attributedString)
    }
    
    func defaultStyleRender(attributedString: inout NSMutableAttributedString) {
        // NSMutableAttributedStringにデフォルトスタイルを設定
        style?.render(attributedString: &attributedString)
        
        // HTMLのstyle属性(例: style="color:red;background-color:black")があればNSMutableAttributedStringに設定・上書きする
        // どの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 抽象実装の適用。

一部のタグパーサー&AttributedStringStyleの実装例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
struct LinkStyle: AttributedStringStyle {
   var font: UIFont? = UIFont.systemFont(ofSize: 14) // フォントサイズ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) // 太字フォントサイズ14
   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()
}

HTMLToAttributedStringParser: XMLParserDelegate のコア実装

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
// 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)
    }
    
    /// 解析して属性付き文字列を生成する。
    func parse(string: String) throws -> NSAttributedString {
        var xmlString = HTMLToAttributedStringParser.escapeWithUnicodeEntities(string)
        
        // <br/> の形式が正しいXMLになるようにする
        // Webでは <br> を <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 から現在のタグを判別し、対応するタグパーサーと定義済みのスタイルを適用できます。

テスト結果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
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 を使って変換しました。


🍺 Buy me a beer on PayPal

👉👉👉 Follow Me On Medium! (1,053+ Followers) 👈👈👈

本記事は Medium にて初公開されました(こちらからオリジナル版を確認)。ZMediumToMarkdown による自動変換・同期技術を使用しています。

Improve this page on Github.

本記事は著者により CC BY 4.0 に基づき公開されています。

© ZhgChgLi. All rights reserved.
閲覧数: 802,415+, 最終更新日時: 2026-01-15 11:14:58 +08:00

本サイトは Chirpy テーマを使用し、Jekyll 上で構築されています。
Medium の記事は ZMediumToMarkdown により変換されています。