記事

HTML解析器開発:ZMarkupParserでNSAttributedStringへの高速変換|手作りレンダリングエンジンの実践

HTML解析に悩む開発者向けに、ZMarkupParserを使ったNSAttributedString変換の手法を解説。手作りレンダリングエンジンで高速かつ正確な描画を実現し、開発効率と表示品質を大幅に向上させます。

HTML解析器開発:ZMarkupParserでNSAttributedStringへの高速変換|手作りレンダリングエンジンの実践

本記事は AI による翻訳をもとに作成されています。表現が不自然な箇所がありましたら、ぜひコメントでお知らせください。

記事一覧


手作りのHTMLパーサーにまつわる話

ZMarkupParser HTML から NSAttributedString へのレンダリングエンジン開発記録

HTML文字列のトークナイズ変換、正規化処理、抽象構文木の生成、Visitorパターン/Builderパターンの適用、そしていくつかの雑談…

続き

去年発表した記事「[ TL;DR] 自行実装 iOS NSAttributedString HTML Render」では、XMLParserを使ってHTMLを解析し、NSAttributedString.Keyに変換する方法を簡単に紹介しました。記事内のコード構成や考え方は非常に散漫で、当時は問題点の記録として残しただけで、このテーマにあまり時間を割いていませんでした。

HTML文字列をNSAttributedStringに変換する

再度この問題を検討すると、APIから取得したHTML文字列をNSAttributedStringに変換し、対応するスタイルを適用してUITextView/UILabelに表示する必要があります。

例:<b>Test<a>Link</a></b>Test Link のように表示できる必要があります。

  • 註1
    HTMLをAppとデータ間の通信やレンダリングの媒体として使用することは推奨されません。HTMLの仕様が非常に柔軟なため、AppはすべてのHTMLスタイルをサポートできず、公式のHTML変換レンダリングエンジンも存在しません。

  • 註2
    iOS 14以降は公式のネイティブAttributedStringを使ってMarkdownを解析したり、apple/swift-markdown Swiftパッケージを導入してMarkdownを解析したりできます。

  • 註3
    弊社のプロジェクトは大規模で、長年HTMLを媒介として使用しているため、現時点ではMarkdownや他のマークアップへ全面的に移行することはできません。

  • 注4
    ここでのHTMLは、ウェブページ全体を表示するためのものではなく、HTMLをスタイル付きMarkdownのように文字列のスタイルをレンダリングするために使っています。
    (ページ全体や画像・表を含む複雑なHTMLをレンダリングする場合は、引き続きWebViewのloadHTMLを使用してください)

強くMarkdownを文字列レンダリングのマークアップ言語として使用することをお勧めします。もしあなたのプロジェクトが私と同じようにHTMLを使わざるを得ず、優れたNSAttributedStringへの変換ツールがない場合は、ぜひ本ツールをご利用ください。

前回の記事を読んだ方は、直接 ZhgChgLi / ZMarkupParser の章に進んでも構いません。

NSAttributedString.DocumentType.html

ネット上で見つかる HTML から NSAttributedString への変換方法は、ほとんどが NSAttributedString に内蔵されている options を使って直接 HTML をレンダリングする方法です。例は以下の通りです:

1
2
3
4
5
6
7
let htmlString = "<b>Test<a>Link</a></b>"
let data = htmlString.data(using: String.Encoding.utf8)!
let attributedOptions:[NSAttributedString.DocumentReadingOptionKey: Any] = [
  .documentType :NSAttributedString.DocumentType.html,
  .characterEncoding: String.Encoding.utf8.rawValue
]
let attributedString = try! NSAttributedString(data: data, options: attributedOptions, documentAttributes: nil)

この方法の問題点:

  • 効率が悪い:この方法は WebView のコアを使ってスタイルをレンダリングし、その後メインスレッドに戻して UI に表示しているため、300文字以上のレンダリングに0.03秒かかります。

  • 文字が欠ける問題:例えば、マーケティング文案で <Congratulation!> を使うと、HTMLタグと認識されて削除されてしまいます。

  • カスタマイズ不可:例えば、HTMLの太字がNSAttributedStringのどの程度の太さに対応するか指定できません。

  • iOS ≥ 12 から断続的にクラッシュする問題で公式の解決策はなし

  • iOS 15 で 大量クラッシュ が発生し、テストの結果、低バッテリー状態では 100% クラッシュすることが判明しました(iOS ≥ 15.2 で修正済み)

  • 文字列が長すぎるとクラッシュします。実測で54,600文字以上の入力で100%クラッシュ(EXC_BAD_ACCESS)します。

対して私たちが最も悩まされたのはやはりクラッシュ問題で、iOS 15のリリースから15.2の修正まで、アプリは常にこの問題でランキングを独占していました。データによると、2022/03/11〜2022/06/08の間に2.4K回以上のクラッシュが発生し、1.4K人以上のユーザーに影響を与えました。

このクラッシュ問題はiOS 12から存在しており、iOS 15ではさらに大きな問題に直面しましたが、iOS 15.2の修正はあくまで応急処置であり、公式には根本的な解決はできていないと推測します。

次に問題となるのはパフォーマンスです。文字列スタイルのマークアップ言語として、App内のUILabel/UITextViewで多用されますが、前述のように1つのLabelで0.03秒かかるため、複数のUILabel/UITextViewになるとユーザー操作時に動作がカクつく原因となります。

XMLParser

第二の方法は前回の記事で紹介したもので、XMLParserを使って対応するNSAttributedStringのキーに解析し、スタイルを適用する方法です。

参考にできるのは SwiftRichString の実装および 前回の記事内容 です。

前回は XMLParser を使って HTML を解析し、対応変換を行うことができることを探究し、実験的な実装を完成させましたが、それを構造化され拡張可能な「ツール」として設計したわけではありません。

この方法の問題点:

  • 容錯率 0: <br> / <Congratulation!> / <b>Bold<i>Bold+Italic</b>Italic</i> 上記の3種類のHTMLの場合、XMLParserで解析すると必ずエラーが発生し、空白が表示されます。

  • XMLParserを使用する場合、HTML文字列は完全にXML規則に準拠している必要があり、ブラウザやNSAttributedString.DocumentType.htmlのようにエラーを許容して正常に表示することはできません。

巨人の肩に立って

以上の二つの方法ではHTMLの問題を完璧かつ優雅に解決できなかったため、既存の解決策がないか探し始めました。

一通り探しましたが、上記のプロジェクトのようなものばかりで、頼れる巨人の肩はありませんでした Orz。

ZhgChgLi/ZMarkupParser

巨人の肩に乗れないので、自分自身が巨人になるしかなく、HTML文字列をNSAttributedStringに変換するツールを自作しました。

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

特徴

  • HTMLレンダリング(NSAttributedStringへの変換)/ストリッパー(HTMLタグの除去)/セレクター機能をサポート

  • NSAttributedString.DocumentType.html より高いパフォーマンス

  • タグの正確性を自動解析・修正(endタグのないタグやずれたタグの修正)

  • style=”color:red…” からの動的なスタイル設定をサポート

  • カスタムスタイルの指定をサポートしています。例えば、太字を 太字 にするなど。

  • 柔軟に拡張可能なタグやカスタムタグおよび属性をサポート

詳細な紹介とインストール・使用方法は、こちらの記事をご参照ください:「ZMarkupParser HTML String から NSAttributedString 変換ツール

直接 git clone プロジェクト して、ZMarkupParser.xcworkspace を開き、ZMarkupParser-Demo ターゲットを選択してそのままビルド&実行して試せます。

[ZMarkupParser](https://github.com/ZhgChgLi/ZMarkupParser){:target="_blank"}

ZMarkupParser

技術的詳細情報

次に、本記事で共有したい、このツール開発に関する技術的な詳細です。

運作流程総覧

運用フロー概要

上図は大まかな動作フローであり、以降の記事で順を追って説明し、コードも添付します。

⚠️️️️️️ 本文ではデモコードをできるだけ簡略化し、抽象化やパフォーマンスの考慮を減らして、動作原理の説明に重点を置いています。最終的な結果についてはプロジェクトの Source Code をご参照ください。

コード化 — トークン化

別名パーサー、解析

HTMLレンダリングで最も重要なのは解析の段階です。従来はXMLParserを使ってHTMLをXMLとして解析していましたが、HTMLは日常的に使われる際に100%のXMLではないため、解析エラーが発生し、動的な修正ができませんでした。

XMLParser を使う方法を除外すると、Swift で残されるのは Regex 正規表現を使ってマッチング解析を行う方法だけになります。

最初はあまり考えずに、「ペア」になったHTMLタグを正規表現で直接抽出し、再帰的に内部のHTMLタグを一層ずつ探していけば良いと思っていました。しかし、この方法ではHTMLタグの入れ子や、不整合のあるタグの許容(エラー耐性)を解決できません。そのため、戦略を変更し、「単一」のHTMLタグを抽出し、それが開始タグ、終了タグ、または自己終了タグかを記録し、その他の文字列と組み合わせて解析結果の配列を作成する方法にしました。

Tokenization の構造は以下の通りです:

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
enum HTMLParsedResult {
    case start(StartItem) // <a>
    case close(CloseItem) // </a>
    case selfClosing(SelfClosingItem) // <br/>
    case rawString(NSAttributedString)
}

extension HTMLParsedResult {
    class SelfClosingItem {
        let tagName: String
        let tagAttributedString: NSAttributedString
        let attributes: [String: String]?
        
        init(tagName: String, tagAttributedString: NSAttributedString, attributes: [String : String]?) {
            self.tagName = tagName
            self.tagAttributedString = tagAttributedString
            self.attributes = attributes
        }
    }
    
    class StartItem {
        let tagName: String
        let tagAttributedString: NSAttributedString
        let attributes: [String: String]?

        // Start Tag は異常な HTML タグである可能性もあり、正常な文字列である可能性もあります。例: <Congratulation!>。後の正規化で孤立した Start Tag と判断された場合、true にマークされます。
        var isIsolated: Bool = false
        
        init(tagName: String, tagAttributedString: NSAttributedString, attributes: [String : String]?) {
            self.tagName = tagName
            self.tagAttributedString = tagAttributedString
            self.attributes = attributes
        }
        
        // 後の正規化で自動補完修正に使用
        func convertToCloseParsedItem() -> CloseItem {
            return CloseItem(tagName: self.tagName)
        }
        
        // 後の正規化で自動補完修正に使用
        func convertToSelfClosingParsedItem() -> SelfClosingItem {
            return SelfClosingItem(tagName: self.tagName, tagAttributedString: self.tagAttributedString, attributes: self.attributes)
        }
    }
    
    class CloseItem {
        let tagName: String
        init(tagName: String) {
            self.tagName = tagName
        }
    }
}

使用した正規表現は以下の通りです:

1
<(?:(?<closeTag>\/)?(?<tagName>[A-Za-z0-9]+)(?<tagAttributes>(?:\s*(\w+)\s*=\s*(["\\|']).*?\5)*)\s*(?<selfClosingTag>\/)?>)

-> Online Regex101 Playground

  • closeTag: < / a にマッチする

  • tagName: <a> または </a> にマッチする

  • tagAttributes: <a href=”https://zhgchg.li” style=”color:red” にマッチする

  • selfClosingTag: <br / > にマッチする

*この正規表現はまだ最適化可能で、後で改良します。

記事の後半部分には正規表現に関する追加資料が提供されています。興味のある方はぜひご覧ください。

組み合わせるとこうなります:

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
var tokenizationResult: [HTMLParsedResult] = []

let expression = try? NSRegularExpression(pattern: pattern, options: expressionOptions)
let attributedString = NSAttributedString(string: "<a>Li<b>nk</a>Bold</b>")
let totalLength = attributedString.string.utf16.count // utf-16 は絵文字をサポート
var lastMatch: NSTextCheckingResult?

// 開始タグのスタック、先入れ後出し(FILO First In Last Out)
// HTML文字列が後続のNormalizationで誤った位置やSelf-Closingタグの補正が必要か検査
var stackStartItems: [HTMLParsedResult.StartItem] = []
var needForamatter: Bool = false

expression.enumerateMatches(in: attributedString.string, range: NSMakeRange(0, totoalLength)) { match, _, _ in
  if let match = match {
    // タグ間や最初のタグまでの文字列をチェック
    // 例: Test<a>Link</a>zzz<b>bold</b>Test2 - > Test,zzz
    let lastMatchEnd = lastMatch?.range.upperBound ?? 0
    let currentMatchStart = match.range.lowerBound
    if currentMatchStart > lastMatchEnd {
      let rawStringBetweenTag = attributedString.attributedSubstring(from: NSMakeRange(lastMatchEnd, (currentMatchStart - lastMatchEnd)))
      tokenizationResult.append(.rawString(rawStringBetweenTag))
    }

    // <a href="https://zhgchg.li">, </a>
    let matchAttributedString = attributedString.attributedSubstring(from: match.range)
    // a, a
    let matchTag = attributedString.attributedSubstring(from: match.range(withName: "tagName"))?.string.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
    // false, true
    let matchIsEndTag = matchResult.attributedString(from: match.range(withName: "closeTag"))?.string.trimmingCharacters(in: .whitespacesAndNewlines) == "/"
    // href="https://zhgchg.li", nil
    // 正規表現でHTML属性を[String: String]に分解、ソースコードを参照
    let matchTagAttributes = parseAttributes(matchResult.attributedString(from: match.range(withName: "tagAttributes")))
    // false, false
    let matchIsSelfClosingTag = matchResult.attributedString(from: match.range(withName: "selfClosingTag"))?.string.trimmingCharacters(in: .whitespacesAndNewlines) == "/"

    if let matchAttributedString = matchAttributedString,
       let matchTag = matchTag {
        if matchIsSelfClosingTag {
          // 例: <br/>
          tokenizationResult.append(.selfClosing(.init(tagName: matchTag, tagAttributedString: matchAttributedString, attributes: matchTagAttributes)))
        } else {
          // 例: <a> または </a>
          if matchIsEndTag {
            // 例: </a>
            // スタックから同じTagNameの位置を最後から探す
            if let index = stackStartItems.lastIndex(where: { $0.tagName == matchTag }) {
              // 最後でなければ誤った位置や閉じ忘れタグあり
              if index != stackStartItems.count - 1 {
                  needForamatter = true
              }
              tokenizationResult.append(.close(.init(tagName: matchTag)))
              stackStartItems.remove(at: index)
            } else {
              // 余分な閉じタグ例: </a>
              // 後続に影響しないので無視
            }
          } else {
            // 例: <a>
            let startItem: HTMLParsedResult.StartItem = HTMLParsedResult.StartItem(tagName: matchTag, tagAttributedString: matchAttributedString, attributes: matchTagAttributes)
            tokenizationResult.append(.start(startItem))
            // スタックに追加
            stackStartItems.append(startItem)
          }
        }
     }

    lastMatch = match
  }
}

// 終端のRawStringをチェック
// 例: Test<a>Link</a>Test2 - > Test2
if let lastMatch = lastMatch {
  let currentIndex = lastMatch.range.upperBound
  if totoalLength > currentIndex {
    // まだ残りの文字列あり
    let resetString = attributedString.attributedSubstring(from: NSMakeRange(currentIndex, (totoalLength - currentIndex)))
    tokenizationResult.append(.rawString(resetString))
  }
} else {
  // lastMatch = nil、タグなしで全て純テキスト
  let resetString = attributedString.attributedSubstring(from: NSMakeRange(0, totoalLength))
  tokenizationResult.append(.rawString(resetString))
}

// スタックが空かチェック、残っていれば対応する終了タグなしの開始タグあり
// 孤立した開始タグとしてマーク
for stackStartItem in stackStartItems {
  stackStartItem.isIsolated = true
  needForamatter = true
}

print(tokenizationResult)
// [
//    .start("a",["href":"https://zhgchg.li"])
//    .rawString("Li")
//    .start("b",nil)
//    .rawString("nk")
//    .close("a")
//    .rawString("Bold")
//    .close("b")
// ]

運作フローは上図の通りです

運用の流れは上図の通りです。

最終的に Tokenization の結果配列が得られます。

対応するソースコード HTMLStringToParsedResultProcessor.swift の実装

標準化 — Normalization

別名 Formatter、正規化

前のステップで初期解析結果を取得した後、解析中にさらに正規化が必要と判断された場合、このステップで自動的にHTMLタグの問題を修正します。

HTMLタグの問題は以下の3種類があります:

  • HTMLタグで閉じタグがない場合:例えば <br>

  • 一般の文字がHTMLタグとして扱われる例:例えば <Congratulation!>

  • HTMLタグの不整合問題:例えば <a>Li<b>nk</a>Bold</b>

修正方法も非常に簡単で、Tokenization の結果の要素を順に処理し、欠落を補完する必要があります。

運作フローは上図の通り

運用フローは上図の通りです

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
var normalizationResult = tokenizationResult

// 開始タグのスタック、先入れ後出し(FILO First In Last Out)
var stackExpectedStartItems: [HTMLParsedResult.StartItem] = []
var itemIndex = 0
while itemIndex < newItems.count {
    switch newItems[itemIndex] {
    case .start(let item):
        if item.isIsolated {
            // 孤立した開始タグの場合
            if WC3HTMLTagName(rawValue: item.tagName) == nil && (item.attributes?.isEmpty ?? true) {
                // WC3で定義されていないHTMLタグかつ属性がない場合
                // WC3HTMLTagName Enumはソースコード参照
                // 一般文字がHTMLタグとして誤認されたと判断
                // raw stringタイプに変更
                normalizationResult[itemIndex] = .rawString(item.tagAttributedString)
            } else {
                // それ以外はセルフクロージングタグに変更、例: <br> -> <br/>
                normalizationResult[itemIndex] = .selfClosing(item.convertToSelfClosingParsedItem())
            }
            itemIndex += 1
        } else {
            // 通常の開始タグはスタックに追加
            stackExpectedStartItems.append(item)
            itemIndex += 1
        }
    case .close(let item):
        // 閉じタグに遭遇
        // 閉じタグまでの開始タグスタックの間のタグを取得
        // 例 <a><u><b>[CurrentIndex]</a></u></b> -> 間隔 0
        // 例 <a><u><b>[CurrentIndex]</a></u></b> -> 間隔 b,u

        let reversedStackExpectedStartItems = Array(stackExpectedStartItems.reversed())
        guard let reversedStackExpectedStartItemsOccurredIndex = reversedStackExpectedStartItems.firstIndex(where: { $0.tagName == item.tagName }) else {
            itemIndex += 1
            continue
        }
        
        let reversedStackExpectedStartItemsOccurred = Array(reversedStackExpectedStartItems.prefix(upTo: reversedStackExpectedStartItemsOccurredIndex))
        
        // 間隔が0ならタグはずれていない
        guard reversedStackExpectedStartItemsOccurred.count != 0 else {
            // ペアなのでポップ
            stackExpectedStartItems.removeLast()
            itemIndex += 1
            continue
        }
        
        // 他のタグが間にある場合、自動でずれているタグを補正
        // 例 <a><u><b>[CurrentIndex]</a></u></b> ->
        // 例 <a><u><b>[CurrentIndex]</b></u></a><b></u></u></b>
        let stackExpectedStartItemsOccurred = Array(reversedStackExpectedStartItemsOccurred.reversed())
        let afterItems = stackExpectedStartItemsOccurred.map({ HTMLParsedResult.start($0) })
        let beforeItems = reversedStackExpectedStartItemsOccurred.map({ HTMLParsedResult.close($0.convertToCloseParsedItem()) })
        normalizationResult.insert(contentsOf: afterItems, at: newItems.index(after: itemIndex))
        normalizationResult.insert(contentsOf: beforeItems, at: itemIndex)
        
        itemIndex = newItems.index(after: itemIndex) + stackExpectedStartItemsOccurred.count
        
        // 開始タグスタックを更新
        // 例 -> b,u
        stackExpectedStartItems.removeAll { startItem in
            return reversedStackExpectedStartItems.prefix(through: reversedStackExpectedStartItemsOccurredIndex).contains(where: { $0 === startItem })
        }
    case .selfClosing, .rawString:
        itemIndex += 1
    }
}

print(normalizationResult)
// [
//    .start("a",["href":"https://zhgchg.li"])
//    .rawString("Li")
//    .start("b",nil)
//    .rawString("nk")
//    .close("b")
//    .close("a")
//    .start("b",nil)
//    .rawString("Bold")
//    .close("b")
// ]

元のコードの HTMLParsedResultFormatterProcessor.swift の実装対応

抽象構文木 (Abstract Syntax Tree)

別名 AST、抽象構文木

Tokenization と Normalization によるデータ前処理が完了した後、結果を抽象構文木🌲に変換します。

如上図

上図のように

抽象構文木に変換することで、将来的な操作や拡張が容易になります。例えば、セレクター機能の実装や他の変換(HTMLからMarkdownへの変換など)が可能です。また、将来的にMarkdownからNSAttributedStringへの変換を追加したい場合も、Markdownのトークナイゼーションとノーマライゼーションを実装するだけで対応できます。

まず Markup Protocol を定義します。Child と Parent のプロパティを持ち、葉と枝の情報を記録します:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
protocol Markup: AnyObject {
    var parentMarkup: Markup? { get set }
    var childMarkups: [Markup] { get set }
    
    func appendChild(markup: Markup)
    func prependChild(markup: Markup)
    func accept<V: MarkupVisitor>(_ visitor: V) -> V.Result
}

extension Markup {
    func appendChild(markup: Markup) {
        markup.parentMarkup = self
        childMarkups.append(markup)
    }
    
    func prependChild(markup: Markup) {
        markup.parentMarkup = self
        childMarkups.insert(markup, at: 0)
    }
}

また Visitor Pattern を組み合わせて使用し、各スタイル属性を Element オブジェクトとして定義し、異なる Visit 戦略でそれぞれの適用結果を取得します。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
protocol MarkupVisitor {
    associatedtype Result
        
    func visit(markup: Markup) -> Result
    
    func visit(_ markup: RootMarkup) -> Result
    func visit(_ markup: RawStringMarkup) -> Result
    
    func visit(_ markup: BoldMarkup) -> Result
    func visit(_ markup: LinkMarkup) -> Result
    //...
}

extension MarkupVisitor {
    func visit(markup: Markup) -> Result {
        return markup.accept(self)
    }
}

基本的な Markup ノード:

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
// ルートノード
final class RootMarkup: Markup {
    weak var parentMarkup: Markup? = nil
    var childMarkups: [Markup] = []
    
    func accept<V>(_ visitor: V) -> V.Result where V : MarkupVisitor {
        return visitor.visit(self)
    }
}

// 葉ノード
final class RawStringMarkup: Markup {
    let attributedString: NSAttributedString
    
    init(attributedString: NSAttributedString) {
        self.attributedString = attributedString
    }
    
    weak var parentMarkup: Markup? = nil
    var childMarkups: [Markup] = []
    
    func accept<V>(_ visitor: V) -> V.Result where V : MarkupVisitor {
        return visitor.visit(self)
    }
}

Markupスタイルノードの定義:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 枝ノード:

// リンクスタイル
final class LinkMarkup: Markup {
    weak var parentMarkup: Markup? = nil
    var childMarkups: [Markup] = []
    
    func accept<V>(_ visitor: V) -> V.Result where V : MarkupVisitor {
        return visitor.visit(self)
    }
}

// 太字スタイル
final class BoldMarkup: Markup {
    weak var parentMarkup: Markup? = nil
    var childMarkups: [Markup] = []
    
    func accept<V>(_ visitor: V) -> V.Result where V : MarkupVisitor {
        return visitor.visit(self)
    }
}

元のコード内の Markup の実装対応

抽象構文木に変換する前に、私たちはまだ…

MarkupComponent

なぜなら、私たちのツリー構造はどのデータ構造にも依存していないからです(例えば a ノード/LinkMarkup は、レンダリングのために URL 情報が必要です)。
これに対して、ツリーノードとノードに関連するデータ情報を格納するためのコンテナを別途定義しています:

1
2
3
4
5
6
7
8
9
10
11
12
13
protocol MarkupComponent {
    associatedtype T
    var markup: Markup { get }
    var value: T { get }
    
    init(markup: Markup, value: T)
}

extension Sequence where Iterator.Element: MarkupComponent {
    func value(markup: Markup) -> Element.T? {
        return self.first(where:{ $0.markup === markup })?.value as? Element.T
    }
}

対応するソースコードの MarkupComponent 実装

Markup を Hashable と宣言して、直接 Dictionary に値 [Markup: Any] を格納することもできますが、この場合 Markup は通常の型として使用できず、any Markup とする必要があります。

HTMLTag & HTMLTagName & HTMLTagNameVisitor

HTML Tag Name 部分でも一層の抽象化を行い、ユーザーが処理する Tag を自由に決められるようにしました。また、将来的な拡張も容易です。例えば、<strong> Tag Name は BoldMarkup に対応させることができます。

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
public protocol HTMLTagName {
    var string: String { get }
    func accept<V: HTMLTagNameVisitor>(_ visitor: V) -> V.Result
}

public struct A_HTMLTagName: HTMLTagName {
    public let string: String = WC3HTMLTagName.a.rawValue
    
    public init() {
        
    }
    
    public func accept<V>(_ visitor: V) -> V.Result where V : HTMLTagNameVisitor {
        return visitor.visit(self)
    }
}

public struct B_HTMLTagName: HTMLTagName {
    public let string: String = WC3HTMLTagName.b.rawValue
    
    public init() {
        
    }
    
    public func accept<V>(_ visitor: V) -> V.Result where V : HTMLTagNameVisitor {
        return visitor.visit(self)
    }
}

対応するソースコードの HTMLTagNameVisitor 実装

また、W3C wiki を参照し、HTML タグ名の列挙型を示しています: WC3HTMLTagName.swift

HTMLTag は単純なコンテナオブジェクトです。外部から HTML タグに対応するスタイルを指定できるように、コンテナとしてまとめて宣言しています:

1
2
3
4
5
6
7
8
9
struct HTMLTag {
    let tagName: HTMLTagName
    let customStyle: MarkupStyle? // 後で紹介するRenderで説明します
    
    init(tagName: HTMLTagName, customStyle: MarkupStyle? = nil) {
        self.tagName = tagName
        self.customStyle = customStyle
    }
}

対応するソースコードの HTMLTag 実装

HTMLTagNameToHTMLMarkupVisitor

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct HTMLTagNameToMarkupVisitor: HTMLTagNameVisitor {
    typealias Result = Markup
    
    let attributes: [String: String]?
    
    func visit(_ tagName: A_HTMLTagName) -> Result {
        return LinkMarkup() // リンクマークアップを返す
    }
    
    func visit(_ tagName: B_HTMLTagName) -> Result {
        return BoldMarkup() // 太字マークアップを返す
    }
    //...
}

元のコード中の HTMLTagNameToHTMLMarkupVisitor の実装対応

HTMLデータから抽象構文木への変換

Normalization 後の HTML データを抽象構文木に変換するために、まず HTML データを格納できる MarkupComponent データ構造を宣言します:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct HTMLElementMarkupComponent: MarkupComponent {
    struct HTMLElement {
        let tag: HTMLTag
        let tagAttributedString: NSAttributedString
        let attributes: [String: String]?
    }
    
    typealias T = HTMLElement
    
    let markup: Markup
    let value: HTMLElement
    init(markup: Markup, value: HTMLElement) {
        self.markup = markup
        self.value = value
    }
}

Markup 抽象構文木への変換:

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
var htmlElementComponents: [HTMLElementMarkupComponent] = []
let rootMarkup = RootMarkup()
var currentMarkup: Markup = rootMarkup

let htmlTags: [String: HTMLTag]
init(htmlTags: [HTMLTag]) {
  self.htmlTags = Dictionary(uniqueKeysWithValues: htmlTags.map{ ($0.tagName.string, $0) })
}

// Start Tags スタック、正しくタグを pop するため
// 事前に Normalization を行っているため基本的にエラーは起きないが、念のため確認
var stackExpectedStartItems: [HTMLParsedResult.StartItem] = []
for thisItem in from {
    switch thisItem {
    case .start(let item):
        let visitor = HTMLTagNameToMarkupVisitor(attributes: item.attributes)
        let htmlTag = self.htmlTags[item.tagName] ?? HTMLTag(tagName: ExtendTagName(item.tagName))
        // Visitor を使って対応する Markup を取得
        let markup = visitor.visit(tagName: htmlTag.tagName)
        
        // 自身を現在の枝の葉ノードに追加
        // 自身が現在の枝ノードになる
        htmlElementComponents.append(.init(markup: markup, value: .init(tag: htmlTag, tagAttributedString: item.tagAttributedString, attributes: item.attributes)))
        currentMarkup.appendChild(markup: markup)
        currentMarkup = markup
        
        stackExpectedStartItems.append(item)
    case .selfClosing(let item):
        // 現在の枝の葉ノードに直接追加
        let visitor = HTMLTagNameToMarkupVisitor(attributes: item.attributes)
        let htmlTag = self.htmlTags[item.tagName] ?? HTMLTag(tagName: ExtendTagName(item.tagName))
        let markup = visitor.visit(tagName: htmlTag.tagName)
        htmlElementComponents.append(.init(markup: markup, value: .init(tag: htmlTag, tagAttributedString: item.tagAttributedString, attributes: item.attributes)))
        currentMarkup.appendChild(markup: markup)
    case .close(let item):
        if let lastTagName = stackExpectedStartItems.popLast()?.tagName,
           lastTagName == item.tagName {
            // Close Tag に遭遇したら一つ上の階層に戻る
            currentMarkup = currentMarkup.parentMarkup ?? currentMarkup
        }
    case .rawString(let attributedString):
        // 現在の枝の葉ノードに直接追加
        currentMarkup.appendChild(markup: RawStringMarkup(attributedString: attributedString))
    }
}

// print(htmlElementComponents)
// [(markup: LinkMarkup, (tag: a, attributes: ["href":"zhgchg.li"]...)]

運作結果如上図

動作結果は上の図の通りです

対応するソースコード HTMLParsedResultToHTMLElementWithRootMarkupProcessor.swift の実装

この時点で、実は Selector の機能が完成しています 🎉

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class HTMLSelector: CustomStringConvertible {
    
    let markup: Markup
    let componets: [HTMLElementMarkupComponent]
    init(markup: Markup, componets: [HTMLElementMarkupComponent]) {
        self.markup = markup
        self.componets = componets
    }
    
    public func filter(_ htmlTagName: String) -> [HTMLSelector] {
        let result = markup.childMarkups.filter({ componets.value(markup: $0)?.tag.tagName.isEqualTo(htmlTagName) ?? false })
        return result.map({ .init(markup: $0, componets: componets) })
    }

    //...
}

私たちは葉ノードオブジェクトを一層ずつフィルタリングできます。

対応するソースコードの HTMLSelector 実装

Parser — HTMLからMarkupStyleへ(NSAttributedString.Keyの抽象化)

次に、HTML を MarkupStyle (NSAttributedString.Key) に変換する処理を完成させます。

NSAttributedString は NSAttributedString.Key Attributes を使って文字のスタイルを設定します。私たちは NSAttributedString.Key のすべてのフィールドを抽象化し、MarkupStyle、MarkupStyleColor、MarkupStyleFont、MarkupStyleParagraphStyle に対応させました。

目的:

  • 元の Attributes のデータ構造は [NSAttributedString.Key: Any?] ですが、これを直接公開すると、ユーザーが渡す値を制御しにくく、例えば .font: 123 のように誤った値を渡すとクラッシュの原因になります。

  • スタイルは継承可能である必要があります。例えば <a><b>test</b></a> の場合、test の文字列スタイルはリンクの太字(bold+link)を継承します。もし直接 Dictionary を公開すると、継承ルールの管理が難しくなります。

  • iOS/macOS (UIKit/AppKit) のオブジェクトのラップ

MarkupStyle 構造体

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
public struct MarkupStyle {
    public var font:MarkupStyleFont
    public var paragraphStyle:MarkupStyleParagraphStyle
    public var foregroundColor:MarkupStyleColor? = nil
    public var backgroundColor:MarkupStyleColor? = nil
    public var ligature:NSNumber? = nil
    public var kern:NSNumber? = nil
    public var tracking:NSNumber? = nil
    public var strikethroughStyle:NSUnderlineStyle? = nil
    public var underlineStyle:NSUnderlineStyle? = nil
    public var strokeColor:MarkupStyleColor? = nil
    public var strokeWidth:NSNumber? = nil
    public var shadow:NSShadow? = nil
    public var textEffect:String? = nil
    public var attachment:NSTextAttachment? = nil
    public var link:URL? = nil
    public var baselineOffset:NSNumber? = nil
    public var underlineColor:MarkupStyleColor? = nil
    public var strikethroughColor:MarkupStyleColor? = nil
    public var obliqueness:NSNumber? = nil
    public var expansion:NSNumber? = nil
    public var writingDirection:NSNumber? = nil
    public var verticalGlyphForm:NSNumber? = nil
    //...

    // 継承元から...
    // デフォルト: フィールドが nil の場合、from から現在のデータオブジェクトに埋める
    mutating func fillIfNil(from: MarkupStyle?) {
        guard let from = from else { return }
        
        var currentFont = self.font
        currentFont.fillIfNil(from: from.font)
        self.font = currentFont
        
        var currentParagraphStyle = self.paragraphStyle
        currentParagraphStyle.fillIfNil(from: from.paragraphStyle)
        self.paragraphStyle = currentParagraphStyle
        //..
    }

    // MarkupStyle を NSAttributedString.Key: Any に変換
    func render() -> [NSAttributedString.Key: Any] {
        var data: [NSAttributedString.Key: Any] = [:]
        
        if let font = font.getFont() {
            data[.font] = font
        }

        if let ligature = self.ligature {
            data[.ligature] = ligature
        }
        //...
        return data
    }
}

public struct MarkupStyleFont: MarkupStyleItem {
    public enum FontWeight {
        case style(FontWeightStyle)
        case rawValue(CGFloat)
    }
    public enum FontWeightStyle: String {
        case ultraLight, light, thin, regular, medium, semibold, bold, heavy, black
        // ...
    }
    
    public var size: CGFloat?
    public var weight: FontWeight?
    public var italic: Bool?
    //...
}

public struct MarkupStyleParagraphStyle: MarkupStyleItem {
    public var lineSpacing:CGFloat? = nil
    public var paragraphSpacing:CGFloat? = nil
    public var alignment:NSTextAlignment? = nil
    public var headIndent:CGFloat? = nil
    public var tailIndent:CGFloat? = nil
    public var firstLineHeadIndent:CGFloat? = nil
    public var minimumLineHeight:CGFloat? = nil
    public var maximumLineHeight:CGFloat? = nil
    public var lineBreakMode:NSLineBreakMode? = nil
    public var baseWritingDirection:NSWritingDirection? = nil
    public var lineHeightMultiple:CGFloat? = nil
    public var paragraphSpacingBefore:CGFloat? = nil
    public var hyphenationFactor:Float? = nil
    public var usesDefaultHyphenation:Bool? = nil
    public var tabStops: [NSTextTab]? = nil
    public var defaultTabInterval:CGFloat? = nil
    public var textLists: [NSTextList]? = nil
    public var allowsDefaultTighteningForTruncation:Bool? = nil
    public var lineBreakStrategy: NSParagraphStyle.LineBreakStrategy? = nil
    //...
}

public struct MarkupStyleColor {
    let red: Int
    let green: Int
    let blue: Int
    let alpha: CGFloat
    //...
}

元のコード内の MarkupStyle 実装対応

また、W3c wiki やブラウザの事前定義カラー名も参照し、対応するカラー名テキストとカラーの R,G,B 列挙型を列挙しています: MarkupStyleColorName.swift

HTMLTagStyleAttribute & HTMLTagStyleAttributeVisitor

ここでこの二つのオブジェクトについて少し補足します。HTMLタグはCSSからスタイルを設定することが許可されているため、HTMLTagNameの抽象化と同様に、HTMLのStyle属性にも同じく抽象化を適用しています。

例えば、HTML は次のように与えられることがあります: <a style=”color:red;font-size:14px”>RedLink</a> 。これはこのリンクを赤色、サイズ14pxに設定することを意味します。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public protocol HTMLTagStyleAttribute {
    var styleName: String { get }
    
    func accept<V: HTMLTagStyleAttributeVisitor>(_ visitor: V) -> V.Result
}

public protocol HTMLTagStyleAttributeVisitor {
    associatedtype Result
    
    func visit(styleAttribute: HTMLTagStyleAttribute) -> Result
    func visit(_ styleAttribute: ColorHTMLTagStyleAttribute) -> Result
    func visit(_ styleAttribute: FontSizeHTMLTagStyleAttribute) -> Result
    //...
}

public extension HTMLTagStyleAttributeVisitor {
    func visit(styleAttribute: HTMLTagStyleAttribute) -> Result {
        return styleAttribute.accept(self)
    }
}

対応するソースコードの HTMLTagStyleAttribute 実装

HTMLTagStyleAttributeToMarkupStyleVisitor

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct HTMLTagStyleAttributeToMarkupStyleVisitor: HTMLTagStyleAttributeVisitor {
    typealias Result = MarkupStyle?
    
    let value: String
    
    func visit(_ styleAttribute: ColorHTMLTagStyleAttribute) -> Result {
        // 正規表現でカラーの16進数またはHTMLの事前定義カラー名を抽出、ソースコードを参照してください
        guard let color = MarkupStyleColor(string: value) else { return nil }
        return MarkupStyle(foregroundColor: color)
    }
    
    func visit(_ styleAttribute: FontSizeHTMLTagStyleAttribute) -> Result {
        // 正規表現で10px -> 10を抽出、ソースコードを参照してください
        guard let size = self.convert(fromPX: value) else { return nil }
        return MarkupStyle(font: MarkupStyleFont(size: CGFloat(size)))
    }
    // ...
}

対応するソースコードの HTMLTagAttributeToMarkupStyleVisitor.swift 実装

init の value は attribute の値で、visit の種類に応じて対応する MarkupStyle のフィールドに変換されます。

HTMLElementMarkupComponentMarkupStyleVisitor

MarkupStyle オブジェクトの紹介が終わったら、Normalization の HTMLElementComponents の結果を MarkupStyle に変換します。

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
// MarkupStyle ポリシー
public enum MarkupStylePolicy {
    case respectMarkupStyleFromCode // コード由来を優先し、HTMLのStyle属性は補完として使用
    case respectMarkupStyleFromHTMLStyleAttribute // HTMLのStyle属性を優先し、コード由来は補完として使用
}

struct HTMLElementMarkupComponentMarkupStyleVisitor: MarkupVisitor {

    typealias Result = MarkupStyle?
    
    let policy: MarkupStylePolicy
    let components: [HTMLElementMarkupComponent]
    let styleAttributes: [HTMLTagStyleAttribute]

    func visit(_ markup: BoldMarkup) -> Result {
        // .bold は MarkupStyle に定義されたデフォルトスタイルです。ソースコードを参照してください。
        return defaultVisit(components.value(markup: markup), defaultStyle: .bold)
    }
    
    func visit(_ markup: LinkMarkup) -> Result {
        // .link は MarkupStyle に定義されたデフォルトスタイルです。ソースコードを参照してください。
        var markupStyle = defaultVisit(components.value(markup: markup), defaultStyle: .link) ?? .link
        
        // HtmlElementComponents から LinkMarkup に対応する HtmlElement を取得
        // HtmlElement の attributes から href パラメータを取得 (HTMLでURL文字列として渡される方式)
        if let href = components.value(markup: markup)?.attributes?["href"] as? String,
           let url = URL(string: href) {
            markupStyle.link = url
        }
        return markupStyle
    }

    // ...
}

extension HTMLElementMarkupComponentMarkupStyleVisitor {
    // HTMLTag コンテナからカスタマイズしたい MarkupStyle を取得
    private func customStyle(_ htmlElement: HTMLElementMarkupComponent.HTMLElement?) -> MarkupStyle? {
        guard let customStyle = htmlElement?.tag.customStyle else {
            return nil
        }
        return customStyle
    }
    
    // デフォルト処理
    func defaultVisit(_ htmlElement: HTMLElementMarkupComponent.HTMLElement?, defaultStyle: MarkupStyle? = nil) -> Result {
        var markupStyle: MarkupStyle? = customStyle(htmlElement) ?? defaultStyle
        // HtmlElementComponents から LinkMarkup に対応する HtmlElement を取得
        // HtmlElement の attributes に `style` 属性があるか確認
        guard let styleString = htmlElement?.attributes?["style"],
              styleAttributes.count > 0 else {
            // ない場合
            return markupStyle
        }

        // Style属性がある場合
        // Style値の文字列を分割して配列に変換
        // 例: font-size:14px;color:red -> ["font-size":"14px","color":"red"]
        let styles = styleString.split(separator: ";").filter { $0.trimmingCharacters(in: .whitespacesAndNewlines) != "" }.map { $0.split(separator: ":") }
        
        for style in styles {
            guard style.count == 2 else {
                continue
            }
            // 例: font-size
            let key = style[0].trimmingCharacters(in: .whitespacesAndNewlines)
            // 例: 14px
            let value = style[1].trimmingCharacters(in: .whitespacesAndNewlines)
            
            if let styleAttribute = styleAttributes.first(where: { $0.isEqualTo(styleName: key) }) {
                // 先に説明した HTMLTagStyleAttributeToMarkupStyleVisitor を使って MarkupStyle に変換
                let visitor = HTMLTagStyleAttributeToMarkupStyleVisitor(value: value)
                if var thisMarkupStyle = visitor.visit(styleAttribute: styleAttribute) {
                    // Style属性で値が変換された場合
                    // 以前の MarkupStyle 結果とマージ
                    thisMarkupStyle.fillIfNil(from: markupStyle)
                    markupStyle = thisMarkupStyle
                }
            }
        }
        
        // デフォルトスタイルがある場合
        if var defaultStyle = defaultStyle {
            switch policy {
                case .respectMarkupStyleFromHTMLStyleAttribute:
                  // Style属性の MarkupStyle を優先し、
                  // デフォルトスタイルとマージ
                    markupStyle?.fillIfNil(from: defaultStyle)
                case .respectMarkupStyleFromCode:
                  // デフォルトスタイルを優先し、
                  // Style属性の MarkupStyle とマージ
                  defaultStyle.fillIfNil(from: markupStyle)
                  markupStyle = defaultStyle
            }
        }
        
        return markupStyle
    }
}

対応するソースコードの HTMLTagAttributeToMarkupStyleVisitor.swift 実装

私たちは一部のデフォルトスタイルを MarkupStyle に定義しており、外部コードからタグのスタイルが指定されていない場合、一部のマークアップはこのデフォルトスタイルを使用します。

スタイル継承の戦略は2種類あります:

  • respectMarkupStyleFromCode:
    デフォルトスタイルを基準にし、Style Attributesで補えるスタイルを追加します。既に値がある場合は無視します。

  • respectMarkupStyleFromHTMLStyleAttribute:
    Style Attributesを優先し、次にデフォルトスタイルで補える部分を適用します。既に値がある場合は無視します。

HTMLElementWithMarkupToMarkupStyleProcessor

Normalization の結果を AST と MarkupStyleComponent に変換する。

新たに MarkupComponent を宣言します。今回は対応する MarkupStyle を格納します:

1
2
3
4
5
6
7
8
9
10
struct MarkupStyleComponent: MarkupComponent {
    typealias T = MarkupStyle
    
    let markup: Markup
    let value: MarkupStyle
    init(markup: Markup, value: MarkupStyle) {
        self.markup = markup
        self.value = value
    }
}

Markup Tree と HTMLElementMarkupComponent 構造の簡単な遍歴:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
let styleAttributes: [HTMLTagStyleAttribute]
let policy: MarkupStylePolicy
    
func process(from: (Markup, [HTMLElementMarkupComponent])) -> [MarkupStyleComponent] {
  var components: [MarkupStyleComponent] = []
  let visitor = HTMLElementMarkupComponentMarkupStyleVisitor(policy: policy, components: from.1, styleAttributes: styleAttributes)
  walk(markup: from.0, visitor: visitor, components: &components)
  return components
}
    
func walk(markup: Markup, visitor: HTMLElementMarkupComponentMarkupStyleVisitor, components: inout [MarkupStyleComponent]) {
        
  if let markupStyle = visitor.visit(markup: markup) {
    components.append(.init(markup: markup, value: markupStyle))
  }
        
  for markup in markup.childMarkups {
    walk(markup: markup, visitor: visitor, components: &components)
  }
}

// print(components)
// [(markup: LinkMarkup, MarkupStyle(link: https://zhgchg.li, color: .blue)]
// [(markup: BoldMarkup, MarkupStyle(font: .init(weight: .bold))]

対応するソースコード HTMLElementWithMarkupToMarkupStyleProcessor.swift の実装

フローの結果は上図の通り

処理結果は上図の通りです

Render — NSAttributedString に変換する

現在、HTMLタグの抽象構文木とHTMLタグに対応するMarkupStyleが揃ったので、最後のステップとして最終的なNSAttributedStringのレンダリング結果を生成できます。

MarkupNSAttributedStringVisitor

markup を NSAttributedString に変換する

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
struct MarkupNSAttributedStringVisitor: MarkupVisitor {
    typealias Result = NSAttributedString
    
    let components: [MarkupStyleComponent]
    // root / base の MarkupStyle、外部から指定可能。例:全文字のサイズ指定など
    let rootStyle: MarkupStyle?
    
    func visit(_ markup: RootMarkup) -> Result {
        // 下位の RawString オブジェクトを見る
        return collectAttributedString(markup)
    }
    
    func visit(_ markup: RawStringMarkup) -> Result {
        // Raw String を返す
        // チェーン上のすべての MarkupStyle を収集
        // Style を NSAttributedString に適用
        return applyMarkupStyle(markup.attributedString, with: collectMarkupStyle(markup))
    }
    
    func visit(_ markup: BoldMarkup) -> Result {
        // 下位の RawString オブジェクトを見る
        return collectAttributedString(markup)
    }
    
    func visit(_ markup: LinkMarkup) -> Result {
        // 下位の RawString オブジェクトを見る
        return collectAttributedString(markup)
    }
    // ...
}

private extension MarkupNSAttributedStringVisitor {
    // Style を NSAttributedString に適用
    func applyMarkupStyle(_ attributedString: NSAttributedString, with markupStyle: MarkupStyle?) -> NSAttributedString {
        guard let markupStyle = markupStyle else { return attributedString }
        let mutableAttributedString = NSMutableAttributedString(attributedString: attributedString)
        mutableAttributedString.addAttributes(markupStyle.render(), range: NSMakeRange(0, mutableAttributedString.string.utf16.count))
        return mutableAttributedString
    }

    func collectAttributedString(_ markup: Markup) -> NSMutableAttributedString {
        // 下位から収集
        // Root -> Bold -> String("Bold")
        //      \
        //       > String("Test")
        // 結果: Bold Test
        // 一層ずつ下に RawString を探し、再帰的に visit して最終的な NSAttributedString を組み立てる
        return markup.childMarkups.compactMap({ visit(markup: $0) }).reduce(NSMutableAttributedString()) { partialResult, attributedString in
            partialResult.append(attributedString)
            return partialResult
        }
    }
    
    func collectMarkupStyle(_ markup: Markup) -> MarkupStyle? {
        // 上位から収集
        // String("Test") -> Bold -> Italic -> Root
        // 結果: style: Bold+Italic
        // 一層ずつ親タグの markupstyle を探し、
        // それを継承していく
        var currentMarkup: Markup? = markup.parentMarkup
        var currentStyle = components.value(markup: markup)
        while let thisMarkup = currentMarkup {
            guard let thisMarkupStyle = components.value(markup: thisMarkup) else {
                currentMarkup = thisMarkup.parentMarkup
                continue
            }

            if var thisCurrentStyle = currentStyle {
                thisCurrentStyle.fillIfNil(from: thisMarkupStyle)
                currentStyle = thisCurrentStyle
            } else {
                currentStyle = thisMarkupStyle
            }

            currentMarkup = thisMarkup.parentMarkup
        }
        
        if var currentStyle = currentStyle {
            currentStyle.fillIfNil(from: rootStyle)
            return currentStyle
        } else {
            return rootStyle
        }
    }
}

対応するソースコード MarkupNSAttributedStringVisitor.swift の実装

運作フローと結果は上図の通り

運用の流れと結果は上図の通りです。

最終的に得られるもの:

1
2
3
4
5
6
7
8
9
10
11
Li{
    NSColor = "Blue";
    NSFont = "<UICTFont: 0x145d17600> font-family: \".SFUI-Regular\"; font-weight: normal; font-style: normal; font-size: 13.00pt";
    NSLink = "https://zhgchg.li";
}nk{
    NSColor = "Blue";
    NSFont = "<UICTFont: 0x145d18710> font-family: \".SFUI-Semibold\"; font-weight: bold; font-style: normal; font-size: 13.00pt";
    NSLink = "https://zhgchg.li";
}Bold{
    NSFont = "<UICTFont: 0x145d18710> font-family: \".SFUI-Semibold\"; font-weight: bold; font-style: normal; font-size: 13.00pt";
}

🎉🎉🎉🎉終わった🎉🎉🎉🎉

ここまでで、HTML文字列からNSAttributedStringへの変換プロセスが完了しました。

Stripper — HTMLタグの除去

剥離 HTML タグの部分は比較的簡単で、以下が必要です:

1
2
3
4
5
6
7
8
9
10
func attributedString(_ markup: Markup) -> NSAttributedString {
  if let rawStringMarkup = markup as? RawStringMarkup {
    return rawStringMarkup.attributedString
  } else {
    return markup.childMarkups.compactMap({ attributedString($0) }).reduce(NSMutableAttributedString()) { partialResult, attributedString in
      partialResult.append(attributedString)
      return partialResult
    }
  }
}

対応するソースコード MarkupStripperProcessor.swift の実装

Render に似ていますが、純粋に RawStringMarkup を見つけて内容を返します。

Extend — 動的拡張

すべての HTMLTag/Style Attribute をカバーできるように、コードから動的にオブジェクトを拡張できる動的拡張の仕組みを用意しました。

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
public struct ExtendTagName: HTMLTagName {
    public let string: String
    
    public init(_ w3cHTMLTagName: WC3HTMLTagName) {
        self.string = w3cHTMLTagName.rawValue
    }
    
    public init(_ string: String) {
        self.string = string.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
    }
    
    public func accept<V>(_ visitor: V) -> V.Result where V : HTMLTagNameVisitor {
        return visitor.visit(self)
    }
}
// から
final class ExtendMarkup: Markup {
    weak var parentMarkup: Markup? = nil
    var childMarkups: [Markup] = []

    func accept<V>(_ visitor: V) -> V.Result where V : MarkupVisitor {
        return visitor.visit(self)
    }
}

//----

public struct ExtendHTMLTagStyleAttribute: HTMLTagStyleAttribute {
    public let styleName: String
    public let render: ((String) -> (MarkupStyle?)) // 動的にクロージャで MarkupStyle を変更
    
    public init(styleName: String, render: @escaping ((String) -> (MarkupStyle?))) {
        self.styleName = styleName
        self.render = render
    }
    
    public func accept<V>(_ visitor: V) -> V.Result where V : HTMLTagStyleAttributeVisitor {
        return visitor.visit(self)
    }
}

ZHTMLParserBuilder

最後に、Builderパターンを使用して外部モジュールがZMarkupParserに必要なオブジェクトを迅速に構築できるようにし、アクセスレベルの制御も適切に行いました。

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
public final class ZHTMLParserBuilder {
    
    private(set) var htmlTags: [HTMLTag] = []
    private(set) var styleAttributes: [HTMLTagStyleAttribute] = []
    private(set) var rootStyle: MarkupStyle?
    private(set) var policy: MarkupStylePolicy = .respectMarkupStyleFromCode
    
    public init() {
        
    }
    
    public static func initWithDefault() -> Self {
        var builder = Self.init()
        for htmlTagName in ZHTMLParserBuilder.htmlTagNames {
            builder = builder.add(htmlTagName)
        }
        for styleAttribute in ZHTMLParserBuilder.styleAttributes {
            builder = builder.add(styleAttribute)
        }
        return builder
    }
    
    public func set(_ htmlTagName: HTMLTagName, withCustomStyle markupStyle: MarkupStyle?) -> Self {
        return self.add(htmlTagName, withCustomStyle: markupStyle)
    }
    
    public func add(_ htmlTagName: HTMLTagName, withCustomStyle markupStyle: MarkupStyle? = nil) -> Self {
        // 同じ tagName は一つだけ存在可能
        htmlTags.removeAll { htmlTag in
            return htmlTag.tagName.string == htmlTagName.string
        }
        
        htmlTags.append(HTMLTag(tagName: htmlTagName, customStyle: markupStyle))
        
        return self
    }
    
    public func add(_ styleAttribute: HTMLTagStyleAttribute) -> Self {
        styleAttributes.removeAll { thisStyleAttribute in
            return thisStyleAttribute.styleName == styleAttribute.styleName
        }
        
        styleAttributes.append(styleAttribute)
        
        return self
    }
    
    public func set(rootStyle: MarkupStyle) -> Self {
        self.rootStyle = rootStyle
        return self
    }
    
    public func set(policy: MarkupStylePolicy) -> Self {
        self.policy = policy
        return self
    }
    
    public func build() -> ZHTMLParser {
        // ZHTMLParser の init は internal のみで、外部から直接初期化不可
        // ZHTMLParserBuilder を通じてのみ初期化可能
        return ZHTMLParser(htmlTags: htmlTags, styleAttributes: styleAttributes, policy: policy, rootStyle: rootStyle)
    }
}

対応するソースコード ZHTMLParserBuilder.swift の実装

initWithDefault は、すでに実装されているすべての HTMLTagName/Style Attribute をデフォルトで追加します

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
public extension ZHTMLParserBuilder {
    static var htmlTagNames: [HTMLTagName] {
        return [
            A_HTMLTagName(),
            B_HTMLTagName(),
            BR_HTMLTagName(),
            DIV_HTMLTagName(),
            HR_HTMLTagName(),
            I_HTMLTagName(),
            LI_HTMLTagName(),
            OL_HTMLTagName(),
            P_HTMLTagName(),
            SPAN_HTMLTagName(),
            STRONG_HTMLTagName(),
            U_HTMLTagName(),
            UL_HTMLTagName(),
            DEL_HTMLTagName(),
            TR_HTMLTagName(),
            TD_HTMLTagName(),
            TH_HTMLTagName(),
            TABLE_HTMLTagName(),
            IMG_HTMLTagName(handler: nil),
            // ...
        ]
    }
}

public extension ZHTMLParserBuilder {
    static var styleAttributes: [HTMLTagStyleAttribute] {
        return [
            ColorHTMLTagStyleAttribute(),
            BackgroundColorHTMLTagStyleAttribute(),
            FontSizeHTMLTagStyleAttribute(),
            FontWeightHTMLTagStyleAttribute(),
            LineHeightHTMLTagStyleAttribute(),
            WordSpacingHTMLTagStyleAttribute(),
            // ...
        ]
    }
}

ZHTMLParser の初期化は internal のみ公開しており、外部から直接初期化できません。必ず ZHTMLParserBuilder を通じて初期化してください。

ZHTMLParser は Render/Selector/Stripper 操作をラップしています:

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
public final class ZHTMLParser: ZMarkupParser {
    let htmlTags: [HTMLTag]
    let styleAttributes: [HTMLTagStyleAttribute]
    let rootStyle: MarkupStyle?

    internal init(...) {
    }
    
    // linkスタイル属性を取得する
    public var linkTextAttributes: [NSAttributedString.Key: Any] {
        // ...
    }
    
    public func selector(_ string: String) -> HTMLSelector {
        // ...
    }
    
    public func selector(_ attributedString: NSAttributedString) -> HTMLSelector {
        // ...
    }
    
    public func render(_ string: String) -> NSAttributedString {
        // ...
    }
    
    // HTMLSelectorの結果を使ってノード内のNSAttributedStringをレンダリング可能にする
    public func render(_ selector: HTMLSelector) -> NSAttributedString {
        // ...
    }
    
    public func render(_ attributedString: NSAttributedString) -> NSAttributedString {
        // ...
    }
    
    public func stripper(_ string: String) -> String {
        // ...
    }
    
    public func stripper(_ attributedString: NSAttributedString) -> NSAttributedString {
        // ...
    }
    
  // ...
}

対応するソースコードの ZHTMLParser.swift 実装

UIKit の問題

NSAttributedString の結果は最もよく UITextView に表示されますが、注意が必要です:

  • UITextView 内のリンクスタイルは linkTextAttributes の設定に統一されており、NSAttributedString.Key の設定は参照されず、個別にスタイルを設定することはできません。そのため ZMarkupParser.linkTextAttributes というインターフェースが用意されています。

  • UILabel は一時的にリンクスタイルを変更する方法がなく、また UILabel に TextStorage がないため、NSTextAttachment の画像を読み込むには別途 UILabel を取得する必要があります。

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
public extension UITextView {
    func setHtmlString(_ string: String, with parser: ZHTMLParser) {
        self.setHtmlString(NSAttributedString(string: string), with: parser)
    }
    
    func setHtmlString(_ string: NSAttributedString, with parser: ZHTMLParser) {
        self.attributedText = parser.render(string)
        self.linkTextAttributes = parser.linkTextAttributes
    }
}
public extension UILabel {
    func setHtmlString(_ string: String, with parser: ZHTMLParser) {
        self.setHtmlString(NSAttributedString(string: string), with: parser)
    }
    
    func setHtmlString(_ string: NSAttributedString, with parser: ZHTMLParser) {
        let attributedString = parser.render(string)
        attributedString.enumerateAttribute(NSAttributedString.Key.attachment, in: NSMakeRange(0, attributedString.string.utf16.count), options: []) { (value, effectiveRange, nil) in
            guard let attachment = value as? ZNSTextAttachment else {
                return
            }
            
            attachment.register(self)
        }
        
        self.attributedText = attributedString
    }
}

そのため、UIKit に多数の Extension を追加し、外部からは単に setHTMLString() を呼ぶだけでバインディングが完了します。

複雑なレンダリング項目 — リスト項目

項目リストの実装記録について。

HTMLで <ol> / <ul> を使って <li> を包むことでリスト項目を表します:

1
2
3
4
5
6
<ul>
    <li>ItemA</li>
    <li>ItemB</li>
    <li>ItemC</li>
    //...
</ul>

前述の解析方法を用いることで、visit(_ markup: ListItemMarkup) 内で他のリストアイテムを取得し、現在のリストインデックスを知ることができます(ASTに変換されているため可能です)。

1
2
3
4
func visit(_ markup: ListItemMarkup) -> Result {
  let siblingListItems = markup.parentMarkup?.childMarkups.filter({ $0 is ListItemMarkup }) ?? []
  let position = (siblingListItems.firstIndex(where: { $0 === markup }) ?? 0)
}

NSParagraphStyle にはリストアイテムを表示するための NSTextList オブジェクトがありますが、実装上、空白の幅をカスタマイズできません(個人的には空白が大きすぎると感じます)。また、項目の記号と文字列の間に空白があると、改行がそこで発生し、表示が少しおかしくなります。以下の図のように:

Beter 部分は 設定 headIndent, firstLineHeadIndent, NSTextTab を使って実現できる可能性がありますが、テストしたところ文字列が長すぎたりサイズが変わったりすると、完璧に表示できませんでした。

現在は Acceptable レベルまで実装しており、自分で項目リストの文字列を組み合わせて文字列の先頭に挿入しています。

私たちは NSTextList.MarkerFormat のみを使用して、箇条書きの記号を生成しており、直接 NSTextList は使用していません。

リスト記号のサポート一覧はこちらをご参照ください: MarkupStyleList.swift

最終表示結果:( <ol><li> )

複雑なレンダリング項目 — Table

リスト項目に似ていますが、表の実装です。

HTMLでの<table>の使用 -> <tr>で行を包み -> <td>/<th>でセルを表す:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<table>
  <tr>
    <th>会社名</th>
    <th>担当者</th>
    <th></th>
  </tr>
  <tr>
    <td>Alfreds Futterkiste</td>
    <td>Maria Anders</td>
    <td>ドイツ</td>
  </tr>
  <tr>
    <td>Centro comercial Moctezuma</td>
    <td>Francisco Chang</td>
    <td>メキシコ</td>
  </tr>
</table>

実際にネイティブの NSAttributedString.DocumentType.html は、プライベートな macOS API の NSTextBlock を使って表示を実現しているため、HTMLの表のスタイルや内容を完全に表示できます。

ちょっとズルい!Private APIは使えません🥲

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
    func visit(_ markup: TableColumnMarkup) -> Result {
        let attributedString = collectAttributedString(markup)
        let siblingColumns = markup.parentMarkup?.childMarkups.filter({ $0 is TableColumnMarkup }) ?? []
        let position = (siblingColumns.firstIndex(where: { $0 === markup }) ?? 0)
        
        // 外部から指定された幅があるかどうか、.maxに設定すると文字列は切り詰められません
        var maxLength: Int? = markup.fixedMaxLength
        if maxLength == nil {
            // 指定がなければ、最初の行の同じ列の文字列長をmax lengthとする
            if let tableRowMarkup = markup.parentMarkup as? TableRowMarkup,
               let firstTableRow = tableRowMarkup.parentMarkup?.childMarkups.first(where: { $0 is TableRowMarkup }) as? TableRowMarkup {
                let firstTableRowColumns = firstTableRow.childMarkups.filter({ $0 is TableColumnMarkup })
                if firstTableRowColumns.indices.contains(position) {
                    let firstTableRowColumnAttributedString = collectAttributedString(firstTableRowColumns[position])
                    let length = firstTableRowColumnAttributedString.string.utf16.count
                    maxLength = length
                }
            }
        }
        
        if let maxLength = maxLength {
            // カラムがmaxLengthを超える場合は文字列を切り詰める
            if attributedString.string.utf16.count > maxLength {
                attributedString.mutableString.setString(String(attributedString.string.prefix(maxLength))+"...")
            } else {
                attributedString.mutableString.setString(attributedString.string.padding(toLength: maxLength, withPad: " ", startingAt: 0))
            }
        }
        
        if position < siblingColumns.count - 1 {
            // スペースを追加して間隔を作る。外部からスペースの幅を指定可能
            attributedString.append(makeString(in: markup, string: String(repeating: " ", count: markup.spacing)))
        }
        
        return attributedString
    }
    
    func visit(_ markup: TableRowMarkup) -> Result {
        let attributedString = collectAttributedString(markup)
        attributedString.append(makeBreakLine(in: markup)) // 改行を追加、詳細はソースコード参照
        return attributedString
    }
    
    func visit(_ markup: TableMarkup) -> Result {
        let attributedString = collectAttributedString(markup)
        attributedString.append(makeBreakLine(in: markup)) // 改行を追加、詳細はソースコード参照
        attributedString.insert(makeBreakLine(in: markup), at: 0) // 改行を先頭に追加、詳細はソースコード参照
        return attributedString
    }

最終的な表示は以下の図の通りです:

完璧ではありませんが、許容範囲です。

複雑なレンダリング項目 — Image

最後に最大の難関である、リモート画像を NSAttributedString に読み込むことについて話します。

HTMLで <img> を使って画像を表示する:

1
<img src="https://user-images.githubusercontent.com/33706588/219608966-20e0c017-d05c-433a-9a52-091bc0cfd403.jpg" width="300" height="125"/>

width / height の HTML 属性を使って、表示したいサイズを指定できます。

NSAttributedString で画像を表示するのは想像以上に複雑で、うまく実装できる方法がありません。以前 UITextView 文繞圖 を作ったときに少し苦労しましたが、今回改めて調べてみても完璧な解決策は見つかりませんでした。

現在は NSTextAttachment のネイティブな再利用やメモリ解放の問題を無視して、まずはリモートから画像をダウンロードして NSTextAttachment に入れ、それを NSAttributedString に配置し、自動的に内容を更新する機能のみを実装します。

このシリーズの操作は別の小さなプロジェクトとして分割して実装しました。将来的に最適化や他のプロジェクトへの再利用がしやすくなると思ったためです:

主に Asynchronous NSTextAttachments のシリーズ記事を参考に実装しましたが、最後の更新内容部分(ダウンロード完了後にUIをリフレッシュして表示する)を置き換え、さらに外部拡張用にDelegate/DataSourceを追加しています。

運用の流れと関係は上図の通り

運用のフローと関係は上図の通りです。

  • ZNSTextAttachmentable オブジェクトを宣言し、NSTextStorage オブジェクト(UITextView に内蔵)と UILabel 自身(UILabel は NSTextStorage を持たない)をラップします。
    操作方法は NSRange からの attributedString の置換を実現するだけです。(func replace(attachment: ZNSTextAttachment, to: ZResizableNSTextAttachment)

  • 実装の原理は、まず ZNSTextAttachment を使って imageURL、PlaceholderImage、目立つ表示サイズ情報をラップし、先にプレースホルダーで画像を直接表示することです。

  • システムが画面にこの画像を表示する必要があるときに image(forBounds… メソッドが呼ばれ、そのタイミングで画像データのダウンロードを開始します

  • DataSource は外部で画像のダウンロード方法や Image Cache Policy を決定できるようにし、デフォルトでは URLSession を使って画像データをリクエストします。

  • ダウンロード完了後、新しく ZResizableNSTextAttachment を生成し、attachmentBounds(for… 内で画像サイズのカスタムロジックを実装します。

  • replace(attachment: ZNSTextAttachment, to: ZResizableNSTextAttachment) メソッドを呼び出し、ZNSTextAttachment の位置を ZResizableNSTextAttachment に置き換えます。

  • didLoad デリゲート通知を送信し、外部が必要な場合に連携できるようにします

  • 完了

詳細なコードは Source Code をご参照ください。

NSLayoutManager.invalidateLayout(forCharacterRange: range, actualCharacterRange: nil)NSLayoutManager.invalidateDisplay(forCharacterRange: range) を使って UI を更新しない理由は、UI が正しく表示更新されないことが判明したためです。範囲が分かっているので、直接 NSAttributedString を置き換えることで UI の正しい更新を保証できます。

最終表示結果は以下の通りです:

1
2
<span style="color:red">こんにちは</span>こんにちはこんにちは <br />
<img src="https://user-images.githubusercontent.com/33706588/219608966-20e0c017-d05c-433a-9a52-091bc0cfd403.jpg"/>

テストと継続的インテグレーション

今回のプロジェクトでは、Unit Test(単体テスト)に加えて、Snapshot Testを導入し、最終的なNSAttributedStringの総合的なテスト比較を容易にしました。

主な機能ロジックにはすべて UnitTests があり、統合テストも加えています。最終的な Test Coverage85% 前後です。

[ZMarkupParser — codecov](https://app.codecov.io/gh/ZhgChgLi/ZMarkupParser){:target="_blank"}

ZMarkupParser — codecov

スナップショットテスト

直接フレームワークを導入して使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
import SnapshotTesting
// ...
func testShouldKeppNSAttributedString() {
  let parser = ZHTMLParserBuilder.initWithDefault().build()
  let textView = UITextView()
  textView.frame.size.width = 390
  textView.isScrollEnabled = false
  textView.backgroundColor = .white
  textView.setHtmlString("html string...", with: parser)
  textView.layoutIfNeeded()
  assertSnapshot(matching: textView, as: .image, record: false)
}
// ...

最終結果が期待通りであるかを直接比較し、調整や統合に問題がないことを確認します。

Codecov テストカバレッジ

Codecov.io(パブリックリポジトリは無料)と連携してテストカバレッジを評価するには、CodecovのGitHubアプリをインストールして設定するだけです。

Codecov <-> Github リポジトリの設定が完了したら、プロジェクトのルートディレクトリに codecov.yml を追加することもできます。

1
2
3
4
5
6
comment:                  # これはトップレベルのキーです
  layout: "reach, diff, flags, files"
  behavior: default
  require_changes: false  # trueの場合:カバレッジが変化した時のみコメントを投稿します
  require_base: no        # [yes :: コメント投稿にベースレポートが必須]
  require_head: yes       # [yes :: コメント投稿にヘッドレポートが必須]

設定ファイル、これで各PRが出された後に、自動でCIの結果をコメントとして投稿できます。

継続的インテグレーション

Github Action, CI 統合: ci.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
name: CI

on:
  workflow_dispatch:
  pull_request:
    types: [opened, reopened]
  push:
    branches:
    - main

jobs:
  build:
    runs-on: self-hosted
    steps:
      - uses: actions/checkout@v3
      - name: spm build and test
        run: \\|
          set -o pipefail
          xcodebuild test -workspace ZMarkupParser.xcworkspace -testPlan ZMarkupParser -scheme ZMarkupParser -enableCodeCoverage YES -resultBundlePath './scripts/TestResult.xcresult' -destination 'platform=iOS Simulator,name=iPhone 14,OS=16.1' build test \\| xcpretty
      - name: Codecov
        uses: codecov/[email protected]
        with:
          xcode: true
          xcode_archive_path: './scripts/TestResult.xcresult'

この設定は、PRがオープン/再オープンされたとき、またはmainブランチにプッシュされたときにビルドとテストを実行し、最後にテストカバレッジのレポートをcodecovにアップロードします。

Regex

正規表現については、使うたびに改良を重ねています。今回はあまり多用しませんでしたが、元々ペアになったHTMLタグを正規表現で抽出しようと考えていたため、書き方を詳しく調べました。

今回新しく学んだチートシートのメモ…

  • ?: は ( ) 内のマッチ結果をグループ化しますが、キャプチャはしません。
    例:(?:https?:\/\/)?(?:www\.)?example\.comhttps://www.example.com に対して、https://www ではなく、全体のURLを返します。

  • .+? は非貪欲マッチ(最も近いものを見つけたら返す)です。
    例: <.+?><a>test</a> の中で <a></a> を返し、文字列全体ではありません。

  • (?=XYZ)XYZ という文字列が現れるまでの任意の文字列を意味します。注意すべきは、似ている [^XYZ]X または Y または Z のいずれかの文字が現れるまでの任意の文字を表すことです。
    例:(?:__)(.+?(?=__))(?:__)__ までの任意の文字列)は test にマッチします。

  • ?R は再帰的に同じルールの値を内部で検索します。
    例:\((?:[^()]\\|((?R)))+\)(simple) (and(nested)) に対して (simple)(and(nested))(nested) をマッチします。

  • ?<GroupName> は前のグループ名にマッチします。
    例: (?<tagName><a>).*(\k<GroupName>)

  • (?(X)yes\\|no) は第 X 番目のマッチ結果に値がある(グループ名も使用可)場合は後続の条件 yes にマッチし、なければ no にマッチします。
    Swift は現時点で未対応です。

その他の優れた Regex 記事:

Swift Package Manager & Cocoapods

これも私にとって初めての SPM と Cocoapods の開発でした…なかなか面白いです。SPM は本当に便利ですが、同じパッケージを二つのプロジェクトが同時に依存している場合、二つのプロジェクトを同時に開くと、そのうちの一つがパッケージを見つけられずビルドできなくなることがあります。。。

Cocoapods は ZMarkupParser をアップロードしていますが、正常に動作するかはテストしていません。私は SPM を使っているので 😝。

ChatGPT

実際の開発経験では、Readme の校正支援にしか役立たないと感じました。開発面では特に効果を実感できませんでした。mid-senior 以上のレベルに質問しても、正確な答えが得られず、時には間違った答えが返ってくることもありました(正規表現のルールについて質問した際、回答があまり正確ではありませんでした)。結局は Google で手動で正しい解答を探すことになりました。

ましてやコードを書かせることは避けるべきです。簡単なコード生成オブジェクトでない限り、ツール全体の構成を直接完成させることは期待しないでください。
(少なくとも現時点ではそうであり、コードを書く面ではCopilotの方が役立つと感じています)

しかし、知識の盲点となる大まかな方向性を示してくれるため、どの部分がどうすべきかを素早く大まかに把握できます。時には理解度が低すぎて、Googleでは正しい方向を素早く見つけるのが難しいことがありますが、そんな時にChatGPTは非常に役立ちます。

声明

三ヶ月以上の研究と開発を経て、非常に疲れましたが、この方法は私の調査によって得られた実行可能な結果であり、必ずしも最良の解決策ではなく、まだ改善の余地があることを明言しておきます。このプロジェクトは一種のきっかけとして捉えており、Markup Language から NSAttributedString への完璧な解決策を目指しています。多くの方の貢献を大歓迎します;多くの点でコミュニティの力が必要です。

貢献について

[ZMarkupParser](https://github.com/ZhgChgLi/ZMarkupParser){:target="_blank"} [⭐](https://github.com/ZhgChgLi/ZMarkupParser){:target="_blank"}

ZMarkupParser

ここでは、現時点(2023/03/12)で思いついた改善点をいくつか挙げます。今後、リポジトリに記録していきます:

  1. パフォーマンスやアルゴリズムの最適化は、ネイティブの NSAttributedString.DocumentType.html よりも高速で安定していますが、まだ多くの改善余地があります。パフォーマンスは XMLParser には及ばないと考えており、いつか同等の性能を持ちながらカスタマイズ性と自動修正の耐障害性も維持できることを期待しています。

  2. より多くのHTMLタグとスタイル属性の変換解析をサポート

  3. ZNSTextAttachment をさらに最適化し、再利用可能にしてメモリを解放する;CoreTextの研究が必要かもしれません

  4. Markdown 解析をサポートしています。基盤の抽象化により HTML に限定されないため、前段階で Markdown を Markup オブジェクトに変換できれば Markdown 解析が可能です。そのため、名前を ZHTMLParser ではなく ZMarkupParser としました。将来的に Markdown から NSAttributedString への対応も目指しています。

  5. Any to Any をサポート、例:HTML から Markdown、Markdown から HTML。元の AST ツリー(Markup オブジェクト)があるため、任意の Markup 間の変換が可能です。

  6. css の !important 機能を実装し、抽象 MarkupStyle の継承戦略を強化する

  7. HTMLセレクタ機能を強化しました。現在は最も基本的なフィルター機能のみです。

  8. たくさんのご意見お待ちしております。issueを開いてください。

もしご支援いただけるなら、⭐を押していただくことでリポジトリがより多くの人に見られ、GitHubの達人たちが協力してくれる可能性が高まります!

まとめ

[ZMarkupParser](https://github.com/ZhgChgLi/ZMarkupParser){:target="_blank"}

ZMarkupParser

以上が私が開発した ZMarkupParser のすべての技術的詳細と経緯です。約3ヶ月の仕事終わりや休日の時間を費やし、数え切れないほどの研究と実践を重ね、テスト作成やテストカバレッジの向上、CIの構築まで行いました。ようやくそれなりの成果が得られました。このツールが同じ悩みを持つ方々の助けになれば幸いですし、皆さんと一緒にこのツールをより良くしていきたいと思います。

[pinkoi.com](https://www.pinkoi.com){:target="_blank"}

pinkoi.com

現在、弊社の pinkoi.com の iOS アプリで使用しており、問題は発生していません。😄

関連記事

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

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 に基づき公開されています。