手作りで作る HTML パーサーのあれこれ
ZMarkupParser HTML to NSAttributedString レンダリングエンジンの開発記録
HTML文字列のトークン化変換、正規化処理、抽象構文木の生成、Visitorパターン / Builderパターンの応用、そしていくつかの雑談…
続き
昨年、「[ TL;DR] 自作 iOS NSAttributedString HTML レンダラー」という記事を公開しました。簡単に XMLParser を使って HTML を解析し、NSAttributedString.Key に変換する方法を紹介しましたが、記事内のコード構成や考え方はかなり散漫でした。当時は問題点の記録程度で、このテーマにあまり時間をかけていませんでした。
HTML文字列をNSAttributedStringに変換する
再度この問題を検討すると、APIから取得したHTML文字列をNSAttributedStringに変換し、対応するスタイルを適用してUITextViewやUILabelに表示できる必要があります。
例:<b>Test<a>Link</a></b> は Test Link のように表示できること。
-
註1
Appとデータ間の通信やレンダリングにHTMLを使用することは推奨されません。HTML仕様が非常に柔軟なため、AppがすべてのHTMLスタイルをサポートできず、公式のHTML変換レンダリングエンジンも存在しません。 -
註2
iOS 14以降、公式のネイティブAttributedStringでMarkdownを解析したり、apple/swift-markdown Swift Packageを導入してMarkdownを解析したりできます。 -
註3
弊社のプロジェクトは大規模で、長年HTMLを媒介として使用しているため、現時点では全面的にMarkdownや他のマークアップに切り替えることができません。 -
注4
ここでのHTMLは、ウェブページ全体を表示するためのものではなく、HTMLをスタイルとしてMarkdownの文字列スタイルをレンダリングするためのものです。
(ページ全体や画像・表を含む複雑なHTMLをレンダリングする場合は、引き続きWebViewのloadHTMLを使用してください)
Markdown を文字列レンダリングのマークアップ言語として強く推奨します。もしあなたのプロジェクトが私と同じように HTML を使わざるを得ず、優れた NSAttributedString 変換ツールが見つからない場合は、ぜひご利用ください。
前回の記事を読んだ方は、直接「ZhgChgLi / ZMarkupParser」の章に進んでも問題ありません。
NSAttributedString.DocumentType.html
ネット上で見つかる HTML to NSAttributedString の方法は、ほとんどが NSAttributedString に内蔵された options を使って直接 HTML をレンダリングするものです。以下はその例です:
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 Core を使ってスタイルをレンダリングし、メインスレッドに戻して UI に表示します。300文字以上のレンダリングで約0.03秒かかります。
-
文字が欠ける問題:例えばマーケティングのコピーで
<Congratulation!>を使うと、HTMLタグと認識されて削除されてしまいます。 -
カスタマイズ不可:例えば、HTML の太字を NSAttributedString でどの程度の太さに対応させるか指定できません。
-
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の問題を完璧かつ優雅に解決できなかったため、既存の解決策がないか探し始めました。
-
johnxnguyen / Down
Markdown を入力として Any (XML/NSAttributedString など) に変換することのみ対応しており、HTML の入力変換には対応していません。 -
malcommac / SwiftRichString
内部で XMLParser を使用しており、前述のケースでも同様に容錯率 0 の問題が発生します。 -
scinfu / SwiftSoup
HTML パーサー(Selector)のみ対応し、NSAttributedString への変換はサポートしていません。
あちこち探しましたが、上記のようなプロジェクトばかりで、巨人の肩に乗ることができませんでした Orz。
ZhgChgLi/ZMarkupParser
巨人の肩に乗れなかったので、自分で巨人になるしかなく、HTML文字列をNSAttributedStringに変換するツールを自作しました。
純粋な Swift で開発し、Regex を使って HTML タグを解析し Tokenization を行い、タグの正確性を分析・修正(終了タグがないタグや位置のずれたタグを修正)し、抽象構文木に変換します。最終的に Visitor Pattern を用いて HTML タグと抽象スタイルを対応させ、最終的な NSAttributedString を得ます。その際、いかなる Parser ライブラリにも依存しません。
特徴
-
HTMLレンダー(to NSAttributedString)/ストリッパー(HTMLタグの剥離)/セレクター機能のサポート
-
NSAttributedString.DocumentType.htmlより高いパフォーマンス -
タグの正確性を自動解析・修正(終了タグのないタグや位置のずれたタグを修正)
-
style=”color:red…”からの動的なスタイル設定をサポート -
カスタムスタイルの指定をサポートしています。例えば、太字を 粗 に変更できます。
-
柔軟に拡張可能なタグやカスタムタグおよび属性のサポート
詳細な紹介、インストールや使用方法はこの記事をご参照ください:「ZMarkupParser HTML文字列からNSAttributedStringへの変換ツール」
直接 git clone プロジェクト して、ZMarkupParser.xcworkspace を開き、ZMarkupParser-Demo ターゲットを選択してそのまま Build & Run して試せます。

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

運用フローの概要
上図はおおまかな動作フローであり、以降の記事でステップごとに紹介し、コードも添付します。
⚠️️️️️️ 本文ではデモコードをできるだけ簡略化し、抽象化やパフォーマンスの考慮を減らして、動作原理の説明に重点を置いています。最終的な結果についてはプロジェクトの Source Code をご参照ください。
コード化 — トークン化(Tokenization)
いわゆるパーサー、解析
HTMLレンダリングで最も重要なのは解析の部分です。従来は XMLParser を使って HTML を XML として解析していましたが、HTML は日常的に使われる際に必ずしも 100% XML ではないため、解析エラーが発生し、動的な修正ができませんでした。
XMLParserを使う方法を除外すると、Swiftで残されるのはRegex(正規表現)を使ってマッチング解析を行う方法だけになります。
最初はあまり深く考えず、「ペアになった」HTMLタグを正規表現で直接抽出し、再帰的に内部のタグを一層ずつ探していけばいいと思っていました。しかし、この方法ではHTMLタグの入れ子や誤ったタグの許容(フォールトトレランス)に対応できません。そこで、戦略を変えて「単一の」HTMLタグを抽出し、それが開始タグ、終了タグ、または自己閉じタグかを記録し、その他の文字列と組み合わせて解析結果の配列を作成する方法にしました。
Tokenization の構造は以下の通りです:
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 タグの可能性もあり、正常なテキストの場合もある e.g. <Congratulation!>、後の Normalization で孤立した Start Tag と判明した場合は True に設定。
var isIsolated: Bool = false
init(tagName: String, tagAttributedString: NSAttributedString, attributes: [String : String]?) {
self.tagName = tagName
self.tagAttributedString = tagAttributedString
self.attributes = attributes
}
// 後の Normalization による自動補完修正用
func convertToCloseParsedItem() -> CloseItem {
return CloseItem(tagName: self.tagName)
}
// 後の Normalization による自動補完修正用
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
}
}
}
使用している正規表現は以下の通りです:
<(?:(?<closeTag>\/)?(?<tagName>[A-Za-z0-9]+)(?<tagAttributes>(?:\s*(\w+)\s*=\s*(["\\|']).*?\5)*)\s*(?<selfClosingTag>\/)?>)
-
closeTag: <
/a> にマッチする -
tagName: <
a> または </a> にマッチする -
tagAttributes: <a
href=”https://zhgchg.li” style=”color:red”にマッチする -
selfClosingTag: <br
/> にマッチする
*この正規表現はさらに最適化可能で、後で対応します
記事の後半には正規表現に関する追加資料が掲載されています。興味のある方はぜひご覧ください。
組み合わせるとこうなります:
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 Tag補完が必要か検査
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、正規化
前のステップで初期解析結果を取得した後、解析中にさらに正規化(Normalization)が必要と判断された場合、このステップでHTMLタグの問題を自動修正します。
HTMLタグの問題は以下の3種類があります:
-
HTMLタグで閉じタグが省略されている場合:例えば
<br> -
一般の文字がHTMLタグとして扱われる例:
<Congratulation!> -
HTMLタグのずれ問題:例えば
<a>Li<b>nk</a>Bold</b>
修正方法も非常に簡単で、Tokenization の結果の要素を順に処理し、欠落を補完します。

動作の流れは上の図の通りです
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 実装
抽象構文木(AST)
別名 AST、抽象構文木
Tokenization & Normalization のデータ前処理が完了した後、次に結果を抽象構文木(AST)に変換します🌲。

上記の図のように
抽象構文木に変換することで、将来的な操作や拡張が容易になります。例えば、Selector機能の実装や他の変換(HTMLからMarkdownへの変換など)が可能です。また、将来的にMarkdownからNSAttributedStringへの変換を追加したい場合も、Markdownのトークナイズと正規化を実装するだけで対応できます。
まずは Markup Protocol を定義します。Child と Parent のプロパティを持ち、葉と枝の情報を記録します:
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 戦略で個別の適用結果を取得します。
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 ノード:
// ルートノード
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 スタイルノードの定義:
// 枝ノード:
// リンクスタイル
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 情報を持つべきですが)。
そのため、ツリーのノードとノードに関連するデータ情報を格納する別のコンテナを定義しています:
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 の部分にも一層の抽象化を行い、ユーザーが処理するタグを自由に決められるようにしました。また、将来的な拡張も容易になります。例えば、<strong> タグ名は BoldMarkup に対応させることも可能です。
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)
}
}
public protocol HTMLTagNameVisitor {
associatedtype Result
func visit(tagName: HTMLTagName) -> Result
func visit(_ tagName: A_HTMLTagName) -> Result
func visit(_ tagName: B_HTMLTagName) -> Result
//...
}
public extension HTMLTagNameVisitor {
func visit(tagName: HTMLTagName) -> Result {
return tagName.accept(self)
}
}
対応するソースコード内の HTMLTagNameVisitor の実装
また、W3Cのwikiを参考にしてHTMLタグ名の列挙型を作成しました:WC3HTMLTagName.swift
HTMLTag は単なるコンテナオブジェクトです。外部から HTML タグに対応するスタイルを指定できるように、コンテナとしてまとめて宣言しています:
struct HTMLTag {
let tagName: HTMLTagName
let customStyle: MarkupStyle? // 後で紹介する Render で説明します
init(tagName: HTMLTagName, customStyle: MarkupStyle? = nil) {
self.tagName = tagName
self.customStyle = customStyle
}
}
対応するソースコードの HTMLTag 実装
HTMLTagNameToHTMLMarkupVisitor
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 データ構造を宣言します:
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 抽象構文木への変換:
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 タグに遭遇したら、一つ上の階層に戻る
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 の機能が完成しています 🎉
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 実装対応
パーサー — 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 構造体
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 の browser predefined color name を参考にして、対応する color name テキストと color の R,G,B enum を列挙しています:MarkupStyleColorName.swift
HTMLTagStyleAttribute & HTMLTagStyleAttributeVisitor
ここでこの2つのオブジェクトについて少し触れておきます。HTMLタグはCSSからスタイルを設定することが許可されているため、HTMLTagNameの抽象化と同様に、HTMLのStyle属性にも同じく抽象化を適用しています。
例えば、HTML は <a style=”color:red;font-size:14px”>RedLink</a> のように渡されることがあり、このリンクを赤色でフォントサイズ14pxに設定することを意味します。
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)
}
}
public struct ColorHTMLTagStyleAttribute: HTMLTagStyleAttribute {
public let styleName: String = "color"
public init() {
// 初期化
}
public func accept<V>(_ visitor: V) -> V.Result where V : HTMLTagStyleAttributeVisitor {
return visitor.visit(self)
}
}
public struct FontSizeHTMLTagStyleAttribute: HTMLTagStyleAttribute {
public let styleName: String = "font-size"
public init() {
// 初期化
}
public func accept<V>(_ visitor: V) -> V.Result where V : HTMLTagStyleAttributeVisitor {
return visitor.visit(self)
}
}
// ...
対応するソースコードの HTMLTagStyleAttribute 実装
HTMLTagStyleAttributeToMarkupStyleVisitor
struct HTMLTagStyleAttributeToMarkupStyleVisitor: HTMLTagStyleAttributeVisitor {
typealias Result = MarkupStyle?
let value: String
func visit(_ styleAttribute: ColorHTMLTagStyleAttribute) -> Result {
// 正規表現で Color Hex または 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 に変換します。
// 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 を格納します:
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 構造の簡単な遍歴:
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 に変換する方法
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
// 一層ずつ下に Raw String を探し、再帰的に 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 実装

運用の流れと結果は上図の通りです。
最終的に得られるものは:

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 タグの部分は比較的簡単で、以下のことを行うだけです:
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 をカバーできるように、コードから直接動的に拡張できる仕組みを作りました。
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に必要なオブジェクトを迅速に構築できるようにし、アクセスレベルの制御も行いました。
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 のみ公開、外部から直接 init 不可
// ZHTMLParserBuilder を通してのみ init 可能
return ZHTMLParser(htmlTags: htmlTags, styleAttributes: styleAttributes, policy: policy, rootStyle: rootStyle)
}
}
対応するソースコードの ZHTMLParserBuilder.swift 実装
initWithDefault は既に実装されているすべての HTMLTagName/Style Attribute をデフォルトで追加します
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 の init は internal のみ公開されており、外部から直接 init することはできません。必ず ZHTMLParserBuilder を通じて init してください。
ZHTMLParser は Render/Selector/Stripper の操作をラップしています:
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 を取得する必要があります。
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に登録する
}
self.attributedText = attributedString
}
}
そのため UIKit に多くの Extension を追加し、外部からはただ setHTMLString() を呼ぶだけでバインディングが完了します。
複雑なレンダリング項目 — 項目リスト
項目リストの実装記録について。
HTMLで <ol> / <ul> を使って <li> を包むと項目リストを表します:
<ul>
<li>ItemA</li>
<li>ItemB</li>
<li>ItemC</li>
//...
</ul>
前述の解析方法を使用すると、visit(_ markup: ListItemMarkup) 内で他のリストアイテムから現在のリストのインデックスを取得できます(ASTに変換されているため可能です)。
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>でセルを表す:
<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 は、Private macOS API の NSTextBlock を使って表示を実現しているため、HTMLの表のスタイルや内容を完全に表示できます。
ちょっとズルい!Private APIは使えません 🥲
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> を使って画像を表示する:
<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 Delegate 通知を発行し、外部が必要な場合に連携できるようにする
-
完了
詳細なコードは Source Code をご参照ください。 。
NSLayoutManager.invalidateLayout(forCharacterRange: range, actualCharacterRange: nil) や NSLayoutManager.invalidateDisplay(forCharacterRange: range) を使って UI を更新しない理由は、UI が正しく表示されないことがあったためです。範囲が分かっているので、直接 NSAttributedString を置き換えることで、UI の正しい更新を確実にしています。
最終表示結果は以下の通りです:
<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 Coverageは約85%です。
![]()
スナップショットテスト
フレームワークを直接導入して使用:
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(Public Repoは無料)と連携してテストカバレッジを評価するには、CodecovのGithubアプリをインストールして設定するだけです。
Codecov <-> Github リポジトリの設定が完了したら、プロジェクトのルートディレクトリに codecov.yml を追加することもできます。
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
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\.comはhttps://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>… 直前の Group Name にマッチ
例:(?<tagName><a>).*(\k<GroupName>) -
(?(X)yes\\|no)は、第X番目のマッチ結果に値がある場合(グループ名も使用可能)にyesをマッチし、そうでなければnoをマッチします。
Swift は現時点でサポートしていません
他の優れた Regex 記事:
-
正規表現はどのように動作するのか? -> 今後このプロジェクトの正規表現パフォーマンス改善の参考にできます
Swift Package Manager & Cocoapods
これも私にとって初めての SPM と Cocoapods の開発でした…けっこう面白かったです。SPM は本当に便利ですが、同じパッケージに依存する2つのプロジェクトを同時に開くと、どちらか一方がそのパッケージを見つけられずビルドできなくなることがあります。。。
Cocoapods は ZMarkupParser をアップロードしていますが、正常に動作するかはテストしていません。私は SPM を使っているので 😝。
ChatGPT
実際の開発経験を通じて、Readme の校正支援にのみ役立つと感じました。開発面では特に効果を実感していません。mid-senior 以上のレベルに質問しても、正確な答えが得られなかったり、間違った回答が返ってくることもありました(正規表現のルールについて質問した際、答えがあまり正確でなかった)。結局、最終的には Google で自分で正しい解答を探すことになりました。
ましてやコードを書かせるのはやめましょう。簡単なコード生成オブジェクトでなければ、彼がツール全体の構造を直接完成させることは期待しないでください。
(少なくとも現時点ではそうで、コードを書く部分はCopilotの方が役立つと感じます)
しかし、知識の盲点となる大まかな方向性を示してくれるため、どのように進めるべきかを素早く把握できます。理解度が低い場合、Googleでは正しい方向性を素早く見つけるのが難しいことがありますが、そのような時にChatGPTは非常に役立ちます。
免責事項
3ヶ月以上の研究と開発を経て、非常に疲れましたが、この方法は私の研究によって得られた実現可能な結果であり、必ずしも最良の解決策ではなく、まだ改善の余地があることを明言しておきます。このプロジェクトは一種の試みであり、Markup Language から NSAttributedString への完璧な解答を目指しています。皆様からのご協力を大歓迎します;多くの点でコミュニティの力が必要です。
貢献方法

ここでは、現時点(2023/03/12)で思いつく改善点をいくつか挙げます。今後、Repoに記録していきます:
-
パフォーマンスやアルゴリズムの最適化については、ネイティブの
NSAttributedString.DocumentType.htmlよりも高速かつ安定していますが、まだ改善の余地があります。パフォーマンスは XMLParser には及ばないと考えています。将来的には、同等のパフォーマンスを持ちながら、カスタマイズ性と自動修正による耐障害性を両立できることを願っています。 -
より多くの HTML タグやスタイル属性の変換解析をサポート
-
ZNSTextAttachment をさらに最適化し、再利用可能にしてメモリを解放する。CoreText の研究が必要かもしれません。
-
Markdown 解析をサポートしています。基盤の抽象化により実際には HTML に限定されないため、前段階で Markdown を Markup オブジェクトに変換すれば Markdown 解析が可能です。だからこそ、名前を ZHTMLParser ではなく ZMarkupParser とし、いつか Markdown から NSAttributedString への対応も目指しています。
-
Any to Any をサポート、例:HTML から Markdown、Markdown から HTML。元の AST ツリー(Markup オブジェクト)があるため、任意の Markup 間の変換が可能です。
-
css の
!important機能を実装し、抽象的な MarkupStyle の継承戦略を強化する -
HTML Selector 機能の強化、現在は最も基本的なフィルター機能のみです
-
たくさんありますので、ぜひ issue を開いてください。
もし応援したいけど力が足りない場合は、⭐ を押していただけるとリポジトリがより多くの人に見られ、Githubの達人たちが協力してくれる可能性が高まります!
まとめ

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

現在、弊社の pinkoi.com iOS版アプリで利用しており、問題は発生していません。😄
関連記事
Post は ZMediumToMarkdown によって Medium から変換されました。



コメント