手工打造 HTML 解析器的那些事

ZhgChgLi
ZRealm Dev.
Published in
96 min readMar 11, 2023

--

ZMarkupParser HTML to NSAttributedString 渲染引擎的開發實錄

HTML String 的 Tokenization 轉換、Normalization 處理、Abstract Syntax Tree 的產生、Visitor Pattern / Builder Pattern 的應用, 還有一些雜談…

接續

去年發表了篇「[TL;DR]自行實現 iOS NSAttributedString HTML Render」的文章,粗淺的介紹可以使用 XMLParser 去剖析 HTML 再將其轉換成 NSAttributedString.Key,文中的程式架構及思路都很零亂,因是過水紀錄一下之前遇到的問題及當初並沒有花太多時間研究此議題。

Convert HTML String to NSAttributedString

再次重新探討此議題,我們需要能將 API 給的 HTML 字串轉換成 NSAttributedString ,並套用對應樣式放到 UITextView/UILabel 中顯示。

e.g. <b>Test<a>Link</a></b> 要能顯示成 TestLink

  • 註1
    不建議使用 HTML 做為 App 與資料間的溝通渲染媒介,因 HTML 規格過於彈性,App 無法支援所有 HTML 樣式,也沒有官方的 HTML 轉換渲染引擎。
  • 註2
    iOS 14 開始可使用官方原生的 AttributedString 解析 Markdown或引入 apple/swift-markdown Swift Package 解析 Markdown。
  • 註3
    因敝司專案龐大且已應用 HTML 做為媒介多年,所以暫時無法全面更換為 Markdown 或其他 Markup。
  • 註4
    這邊的 HTML 並不是要用來顯示整個 HTML 網頁,只是把 HTML 做為樣式 Markdown 渲染字串樣式。
    (要渲染整頁、複雜包含圖片表格的 HTML,依然要使用 WevView loadHTML)

強烈建議使用 Markdown 做為字串渲染媒介語言,如果您的專案跟我有一樣困擾不得不使用 HTML 並苦無優雅的 to 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 去渲染出樣式,再切回 Main Thread 給 UI 顯示;渲染 300 多個字元就需 0.03 Sec。
  • 會吃字:例如行銷文案可能會使用 <Congratulation!> 會被當成 HTML Tag 被去除掉。
  • 無法客製化:例如無法指定 HTML 的粗體在 NSAttributedString 中對應的粗體程度。
  • iOS ≥ 12 開始會零星閃退的問題且官方無解
  • 在 iOS 15 出現大量閃退,測試發現低電量情況下會 100% 閃退 (iOS ≥ 15.2 已修正)
  • 字串太長會閃退,實測輸入超過 54,600+ 長度字串就會 100% 閃退 (EXC_BAD_ACCESS)

對與我們最痛的還是閃退問題,iOS 15 發佈到 15.2 修正之前,App 始終被此問題霸榜,從數據來看,2022/03/11~2022/06/08 就造成了 2.4K+ 次閃退、影響 1.4K+ 位使用者。

此閃退問題自 iOS 12 開始就有,iOS 15 只是踩到更大的坑,但我猜 iOS 15.2 的修正也只是補洞,官方無法根除。

其次問題是效能,因為做為字串樣式 Markup Language,會大量應用在 App 上的 UILabel/UITextView,如同前述一個 Label 就需要 0.03 Sec,列表*UILabel/UITextView 乘下來就會對使用者操作手感上產生卡頓。

XMLParser

第二個方案是上篇文章介紹的,使用 XMLParser 解析成對應的 NSAttributedString Key 並套用樣式。

可參考 SwiftRichString 的實現及上一篇文章內容

上一篇也只是探究出可以使用 XMLParser 解析 HTML 並做對應轉換,然後完成實驗性的實作,但並沒有把它設計成一個有架構好擴充的「工具」。

此做法的問題:

  • 容錯率 0:<br>/<Congratulation!>/<b>Bold<i>Bold+Italic</b>Italic</i>
    以上三種 HTML 有可能出現的情境,在 XMLParser 解析都會出錯直接 Throw Error 顯示空白。
  • 使用 XMLParser,HTML 字串必須完全符合 XML 規則,無法像瀏覽器或 NSAttributedString.DocumentType.html 容錯正常顯示。

站在巨人的肩膀上

以上兩個方案都不能完美優雅的解決 HTML 問題,於是開始搜尋有無現成的解決方案。

找了一大圈結果都類似上方的專案 Orz,沒有巨人的肩膀可以站。

ZhgChgLi/ZMarkupParser

沒有巨人的肩膀,只好自己當巨人了,於是自行開發了 HTML String to NSAttributedString 工具。

使用純 Swift 開發,透過 Regex 剖析出 HTML Tag 並經過 Tokenization,分析修正 Tag 正確性(修正沒有 end 的 tag & 錯位 tag),再轉換成 abstract syntax tree,最終使用 Visitor Pattern 將 HTML Tag 與抽象樣式對應,得到最終 NSAttributedString 結果;其中不依賴任何 Parser Lib。

特色

  • 支援 HTML Render (to NSAttributedString) / Stripper (剝離 HTML Tag) / Selector 功能
  • NSAttributedString.DocumentType.html 更高的效能
  • 自動分析修正 Tag 正確性(修正沒有 end 的 tag & 錯位 tag)
  • 支援從 style=”color:red…” 動態設定樣式
  • 支援客製化樣式指定,例如粗體要多
  • 支援彈性可擴充標籤或自訂標籤及屬性

詳細介紹、安裝使用可參考此篇文章:「ZMarkupParser HTML String 轉換 NSAttributedString 工具

可直接 git clone 專案後,打開 ZMarkupParser.xcworkspace Project 選擇 ZMarkupParser-Demo Target 直接 Build & Run 起來玩玩。

ZMarkupParser

技術細節

再來才是本篇文章想分享的,關於開發這個工具上的技術細節。

運作流程總覽

上圖為大概的運作流程,後面文章會一步一步介紹及附上程式碼。

⚠️️️️️️ 本文會盡量簡化 Demo Code、減少抽象跟效能考量,盡量把重心放在解釋運作原理上;如需了解最終結果請參考專案 Source Code

程式碼化 — Tokenization

a.k.a parser, 解析

談到 HTML 渲染最重要的就是解析的環節,以往是透過 XMLParser 將 HTML 做為 XML 解析;但是無法克服 HTML 日常用法並不是 100% 的 XML 會造成解析器錯誤,且無法動態修正。

排除掉使用 XMLParser 這條路之後,在 Swift 上留給我們的就只剩使用 Regex 正則來做匹配解析了。

最一開始沒想太多,想說可以直接用正則挖出「成對」的 HTML Tag,再遞迴往裡面一層一層找 HTML Tag,直到結束;但是這樣沒有辦法解決 HTML Tag 可以嵌套,或想支援錯位容錯的問題,因此我們把策略改成挖成出「單個」 HTML Tag,並記錄是 Start Tag, Close Tag or Self-Closing Tag,及其他字串組合成解析結果陣列。

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 Tag 也有可能是正常文字 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>\/)?>)

-> Online Regex101 Playground

  • closeTag: 匹配 </a>
  • tagName: 匹配 <a> or , </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 support emoji
var lastMatch: NSTextCheckingResult?

// Start Tags Stack, 先進後出(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 {
// 檢查 Tag 之間或是到第一個 Tag 之間的字串
// e.g. 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 Attribute, to [String: String], 請參考 Source Code
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 {
// e.g. <br/>
tokenizationResult.append(.selfClosing(.init(tagName: matchTag, tagAttributedString: matchAttributedString, attributes: matchTagAttributes)))
} else {
// e.g. <a> or </a>
if matchIsEndTag {
// e.g. </a>
// 從 Stack 取出出現相同 TagName 的位置,從最後開始
if let index = stackStartItems.lastIndex(where: { $0.tagName == matchTag }) {
// 如果不是最後一個,代表有錯位或遺漏關閉的 Tag
if index != stackStartItems.count - 1 {
needForamatter = true
}
tokenizationResult.append(.close(.init(tagName: matchTag)))
stackStartItems.remove(at: index)
} else {
// 多餘的 close tag e.g </a>
// 不影響後續,直接忽略
}
} else {
// e.g. <a>
let startItem: HTMLParsedResult.StartItem = HTMLParsedResult.StartItem(tagName: matchTag, tagAttributedString: matchAttributedString, attributes: matchTagAttributes)
tokenizationResult.append(.start(startItem))
// 塞到 Stack
stackStartItems.append(startItem)
}
}
}

lastMatch = match
}
}

// 檢查結尾的 RawString
// e.g. 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))
}

// 檢查 Stack 是否已經清空,如果還有代表有 Start Tag 沒有對應的 End
// 標記成孤立 Start Tag
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

a.k.a Formatter, 正規化

繼上一步取得初步解析結果後,解析中如果發現還需要 Normalization,則需要此步驟,自動修正 HTML Tag 問題。

HTML Tag 問題有以下三種:

  • HTML Tag 但遺漏 Close Tag: 例如 <br>
  • 一般文字被當成 HTML Tag: 例如 <Congratulation!>
  • HTML Tag 存在錯位問題: 例如 <a>Li<b>nk</a>Bold</b>

修正方式也很簡單,我們需要遍歷 Tokenization 結果的元素,嘗試補齊缺漏。

運作流程如上圖
var normalizationResult = tokenizationResult

// Start Tags Stack, 先進後出(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 {
// 如果為孤立 Start Tag
if WC3HTMLTagName(rawValue: item.tagName) == nil && (item.attributes?.isEmpty ?? true) {
// 如果不是 WCS 定義的 HTML Tag & 沒有任何 HTML Attribute
// WC3HTMLTagName Enum 可參考 Source Code
// 判定為 一般文字被當成 HTML Tag
// 改成 raw string type
normalizationResult[itemIndex] = .rawString(item.tagAttributedString)
} else {
// 否則,改成 self-closing tag, e.g. <br> -> <br/>
normalizationResult[itemIndex] = .selfClosing(item.convertToSelfClosingParsedItem())
}
itemIndex += 1
} else {
// 正常 Start Tag, 加入 Stack
stackExpectedStartItems.append(item)
itemIndex += 1
}
case .close(let item):
// 遇到 Close Tag
// 取得 Start Stack Tag 到此 Close Tag 中間隔的 Tags
// e.g <a><u><b>[CurrentIndex]</a></u></b> -> 間隔 0
// e.g <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, 代表 tag 沒錯位
guard reversedStackExpectedStartItemsOccurred.count != 0 else {
// is pair, pop
stackExpectedStartItems.removeLast()
itemIndex += 1
continue
}

// 有其他間隔,自動在前候補期間格 Tag
// e.g <a><u><b>[CurrentIndex]</a></u></b> ->
// e.g <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

// 更新 Start Stack Tags
// e.g. -> 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

a.k.a AST, 抽象樹

經過 Tokenization & Normalization 資料預處理完成後,再來要將結果轉換成抽象樹🌲。

如上圖

轉換成抽象樹可以方便我們日後的操作及擴充,例如實現 Selector 功能或是做其他轉換,例如 HTML To Markdown;亦或是日後想增加 Markdown to NSAttributedString,只需實現 Markdown 的 Tokenization & Normalization 就能完成。

首先我們定義一個 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 資訊才能做後續 Render)。
對此我們另外定義一個容器存放樹節點與節點相關的資料資訊:

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 就不能被當一般 type 使用,要加上 any Markup

HTMLTag & HTMLTagName & HTMLTagNameVisitor

HTML Tag Name 部分我們也做了一層的抽象,讓使用者能自行決定有哪些 Tag 需要被處理,也能方便日後的擴充,例如: <strong> Tag Name 同樣可對應到 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 tag name enum: WC3HTMLTagName.swift

HTMLTag 則是單純一個容器物件,因為我們希望能讓外部指定 HTML Tag 對應到的樣式,所以宣告一個容器放在一起:

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 實作

轉換成抽象樹 with 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 Stack, 確保有正確 pop tag
// 前面已經做過 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 的功能了 🎉

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) })
}

//...
}

我們可以一層一層 Filter 葉節點物件。

對應原始碼中的 HTMLSelector 實作

Parser — HTML to MarkupSyle (Abstract of 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 字串的樣式就是繼承自 link 的 bold (bold+linke);如果直接暴露 Dictionary 出去很難控制好繼承規
  • 封裝 iOS/macOS (UIKit/Appkit) 所屬物件

MarkupStyle Struct

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 to 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 text & color R,G,B enum: MarkupStyleColorName.swift

HTMLTagStyleAttribute & HTMLTagStyleAttributeVisitor

這邊多提一下這兩個物件,因為 HTML Tag 是允許搭配從 CSS 設定樣式的;對此我們同 HTMLTagName 的抽象,再套用一次在 HTML Style Attribute 上。

例如 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 or Mapping from HTML Pre-defined Color Name, 請參考 Source Code
guard let color = MarkupStyleColor(string: value) else { return nil }
return MarkupStyle(foregroundColor: color)
}

func visit(_ styleAttribute: FontSizeHTMLTagStyleAttribute) -> Result {
// 正則挖取 10px -> 10, 請參考 Source Code
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 // 從 Code 來的為主, 用 HTML Style Attribute 來的填空
case respectMarkupStyleFromHTMLStyleAttribute // 從 HTML Style Attribute 來的為主, 用 Code 來的填空
}

struct HTMLElementMarkupComponentMarkupStyleVisitor: MarkupVisitor {

typealias Result = MarkupStyle?

let policy: MarkupStylePolicy
let components: [HTMLElementMarkupComponent]
let styleAttributes: [HTMLTagStyleAttribute]

func visit(_ markup: BoldMarkup) -> Result {
// .bold 只是定義在 MarkupStyle 中的預設樣式, 請參考 Source Code
return defaultVisit(components.value(markup: markup), defaultStyle: .bold)
}

func visit(_ markup: LinkMarkup) -> Result {
// .link 只是定義在 MarkupStyle 中的預設樣式, 請參考 Source Code
var markupStyle = defaultVisit(components.value(markup: markup), defaultStyle: .link) ?? .link

// 從 HtmlElementComponents 取得 LinkMarkup 對應的 HtmlElement
// 從 HtmlElement 中的 attributes 找 href 參數 (HTML 帶 URL String 的方式)
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` Attribute
guard let styleString = htmlElement?.attributes?["style"],
styleAttributes.count > 0 else {
// 沒有
return markupStyle
}

// 有 Style Attributes
// 切割 Style Value 字串成陣列
// 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
}
// e.g font-szie
let key = style[0].trimmingCharacters(in: .whitespacesAndNewlines)
// e.g. 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 Attribute 有換回值時..
// 合併上一個 MarkupStyle 結果
thisMarkupStyle.fillIfNil(from: markupStyle)
markupStyle = thisMarkupStyle
}
}
}

// 如果有預設 Style
if var defaultStyle = defaultStyle {
switch policy {
case .respectMarkupStyleFromHTMLStyleAttribute:
// Style Attribute MarkupStyle 為主,然後
// 合併 defaultStyle 結果
markupStyle?.fillIfNil(from: defaultStyle)
case .respectMarkupStyleFromCode:
// defaultStyle 為主,然後
// 合併 Style Attribute MarkupStyle 結果
defaultStyle.fillIfNil(from: markupStyle)
markupStyle = defaultStyle
}
}

return markupStyle
}
}

對應原始碼中的 HTMLTagAttributeToMarkupStyleVisitor.swift 實作

我們會定義部分預設樣式在 MarkupStyle 中,部分 Markup 如果沒有從 Code 外部指定 Tag 想要的樣式時會使用預設樣式。

樣式繼承策略有兩種:

  • 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 — Convert To NSAttributedString

現在我們有了 HTML Tag 抽象樹結構、HTML Tag 對應的 MarkupStyle 後;最後一步我們就能來產出最後的 NSAttributedString 渲染結果。

MarkupNSAttributedStringVisitor

visit markup to 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 {
// collect from downstream
// Root -> Bold -> String("Bold")
// \
// > String("Test")
// Result: 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? {
// collect from upstream
// String("Test") -> Bold -> Italic -> Root
// Result: style: Bold+Italic
// 一層一層網上找 parent tag 的 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 String to NSAttributedString 的整個轉換過程。

Stripper — 剝離 HTML Tag

剝離 HTML Tag 的部分相對簡單,只需要:

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 所以開了一個動態擴充的口,方便直接從 Code 動態擴充物件。

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)
}
}
// to
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?)) // 動態用 clourse 變更 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 Pattern 讓外部 Module 可以快速構建 ZMarkupParser 所需的物件,並做好 Access Level Control。

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 style attributes
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 沒有 TextStroage,若要拿來載入 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.attributedText = attributedString
}
}

因此多 Extension 了 UIKit,外部只需無腦 setHTMLString() 即可完成綁定。

複雜的渲染項目— 項目清單

關於項目清單的實現紀錄。

在 HTML 中使用 <ol>/<ul> 包裝 <li> 表示項目清單:

<ul>
<li>ItemA</li>
<li>ItemB</li>
<li>ItemC</li>
//...
</ul>

使用同前文解析方式,我們可以在 visit(_ markup: ListItemMarkup) 取得其他 list item 知道當前 list index (得利於有轉換成 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 物件可以用來顯示 list item,但是在實作上無法客製化空白的寬度 (個人覺得空白太大),如果項目符號與字串中間有空白會讓換行觸發在此,顯示會有點奇怪,如下圖:

Beter 部分有機會透過設定 headIndent, firstLineHeadIndent, NSTextTab 實現,但是測試發現字串太長、大小有變還是無法完美呈現結果。

目前只做到 Acceptable,自己組合項目清單字串 insert 到字串前。

我們只使用到 NSTextList.MarkerFormat 用來產項目清單符號,而不是直接使用 NSTextList。

清單符號支援列表可參考:MarkupStyleList.swift

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

複雜的渲染項目 — Table

類似 清單項目的實現,但是是表格。

在 HTML 中使用 <table>表格->包裝 <tr> 表格列->包裝 <td>/<th> 表示表格欄位:

<table>
<tr>
<th>Company</th>
<th>Contact</th>
<th>Country</th>
</tr>
<tr>
<td>Alfreds Futterkiste</td>
<td>Maria Anders</td>
<td>Germany</td>
</tr>
<tr>
<td>Centro comercial Moctezuma</td>
<td>Francisco Chang</td>
<td>Mexico</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 不 truncated string
var maxLength: Int? = markup.fixedMaxLength
if maxLength == nil {
// 沒指定則找到第一行同一欄的 String length 做為 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 則 truncated string
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 {
// 新增空白做為 spacing, 外部可指定 spacing 寬度幾個空白字
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)) // 新增換行, 詳細請參考 Source Code
return attributedString
}

func visit(_ markup: TableMarkup) -> Result {
let attributedString = collectAttributedString(markup)
attributedString.append(makeBreakLine(in: markup)) // 新增換行, 詳細請參考 Source Code
attributedString.insert(makeBreakLine(in: markup), at: 0) // 新增換行, 詳細請參考 Source Code
return attributedString
}

最終呈現效果如下圖:

not perfect, but acceptable.

複雜的渲染項目 — 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 Attribute 指定想要的顯示大小。

在 NSAttributedString 中顯示圖片,比想像中複雜很多;且沒有很好的實現,之前做 UITextView 文繞圖時有稍微踩過坑,但這次在研究一輪發現還是沒有一個完美的解決方案。

目前先忽略 NSTextAttachment 原生不能 reuse 釋放記憶體的問題,先只實現從遠端下載圖片放到 NSTextAttachment 在放到 NSAttributedString 中,並實現自動更新內容。

此系列操作又再拆到另一個小的 Project 實現,想說日後比較好優化跟復用到其他 Project:

主要是參考 Asynchronous NSTextAttachments 這系列文章實現,但是替換了最後的更新內容部分(下載完後要刷新 UI 才會呈現)還有增加 Delegate/DataSource 給外部擴充使用。

運做流程與關係如上圖
  • 宣告 ZNSTextAttachmentable 物件,封裝 NSTextStorage 物件(UITextView自帶)及 UILabel 本身 (UILabel 無 NSTextStorage)
    操作方法僅為實現 replace attributedString from NSRange. (func replace(attachment: ZNSTextAttachment, to: ZResizableNSTextAttachment))
  • 實現原理是先使用 ZNSTextAttachment 包裝 imageURL、PlaceholderImage、顯要顯示的大小資訊,然後先用 placeHolder 直接顯示圖片
  • 當 系統需要此圖片在畫面時會呼叫 image(forBounds… 方法,此時我們開始下載 Image Data
  • DataSource 出去讓外部可決定怎麼下載或實現 Image Cache Policy,預設直接使用 URLSession 請求圖片 Data
  • 下載完成後 new 一個新的 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 沒有正確的顯示更新;既然都知道所在 Range 了,直接觸發取代 NSAttributedString,能確保 UI 正確更新。

最終顯示結果如下:

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

Testing & Continuous Integration

這次專案除了撰寫 Unit Test 單元測試之外還建立了 Snapshot Test 做整合測試方便對最終的 NSAttributedString 做綜觀的測試比較。

主要功能邏輯都有 UnitTests 並加上整合測試,最終 Test Coverage85% 左右。

ZMarkupParser — codecov

Snapshot Test

直接引入框架使用:

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 Test Coverage

串接 Codecov.io (free for Public Repo) 評估 Test Coverage,只需安裝 Codecov Github App & 設計即可。

Codecov <-> Github Repo 設定好後,也可以在專案根目錄加上 codecov.yml

comment:                  # this is a top-level key
layout: "reach, diff, flags, files"
behavior: default
require_changes: false # if true: only post the comment if coverage changes
require_base: no # [yes :: must have a base report to post]
require_head: yes # [yes :: must have a head report to post]

設定檔,這樣可以啟用每個 PR 發出後,自動把 CI 跑的結果 Comment 到內容。

Continuous Integration

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/codecov-action@v3.1.1
with:
xcode: true
xcode_archive_path: './scripts/TestResult.xcresult'

此設定是在 PR opened/reopend or push main branch 時跑 build and test 最後把 test coverage 報告上傳到 codecov.

Regex

關於正規表示法,每用到一次就又再精進一次;這次實際沒用到太多,但是因為本來想用一個正則挖出成對的 HTML Tag 所以也多研究過要怎麼撰寫。

一些這次新學習的 cheat sheet 筆記…

  • ?: 可以讓 () 匹配 group 結果,但不會捕獲返回
    e.g. (?:https?:\/\/)?(?:www\.)?example\.comhttps://www,example.com 會返回整個網址而不是 https://,www
  • .+? 非貪婪的匹配 (找到最近的就返回)
    e.g. <.+?><a>test</a> 會返回 <a>, </a> 而非整個字串
  • (?=XYZ) 任何字串直到 XYZ 字串出現;要注意,另一個與之相似的 [^XYZ] 是代表任何字串直到 X or Y or Z字元出現
    e.g. (?:__)(.+?(?=__))(?:__) (任何字串直到 __) 會匹配出 test
  • ?R 遞迴往內找一樣規則的值
    e.g. \((?:[^()]|((?R)))+\)(simple) (and(nested)) 會匹配出 (simple), (and(nested)), (nested)
  • ?<GroupName>\k<GroupName> 匹配前面的 Group Name
    e.g. (?<tagName><a>).*(\k<GroupName>)
  • (?(X)yes|no)X個匹配結果有值(也可以用 Group Name)時則匹配後面條件 yes 否則匹配 no
    Swift 暫時不支援

其他 Regex 好文:

Swift Package Manager & Cocoapods

這也是我第一次開發 SPM & Cocoapods…蠻有趣的,SPM 真的方便;但是踩到同時兩個專案依賴同個套件的話,同時開兩個專案會有其中一個找不到該套件然後 Build 不起來。。。

Cocoapods 有上傳 ZMarkupParser 但沒測試正不正常,因為我是用 SPM 😝。

ChatGPT

實際搭配開發體驗下來,覺得只有在協助潤稿 Readme 時最有用;在開發上目前沒體會到有感的地方;因為詢問 mid-senior 以上的問題,他也給不出個確切答案甚是是錯誤的答案 (有遇到問他一些正則規則,答案不太正確),所以最後還是回到 Google 人工找正確解答。

更不要說請他寫 Code 了,除非是簡單的 Code Gen Object;不然不要幻想他能直接完成整個工具架構。(至少目前是這樣,感覺寫 Code 這塊 Copilot 可能更有幫助)

但他可以給一些知識盲區的大方向,讓我們能快速大略知道某些地方應該會怎麼做;有的時候掌握度太低,在 Google 反而很難快速定位到正確的方向,這時候 ChatGPT 就蠻有幫助的。

聲明

歷經三個多月的研究及開發,已疲憊不堪,但還是要聲明一下此做法僅為我研究後得到的可行結果,不一定是最佳解,或還有可優化的地方,這專案更像是一個拋磚引玉,希望能得到一個 Markup Language to NSAttributedString 的完美解答,非常歡迎大家貢獻;有許多事項還需要群眾的力量才能完善

Contributing

ZMarkupParser

這邊先列一些此時此刻(2023/03/12)想到能更好的地方,之後會在 Repo 上紀錄:

  1. 效能/算法的優化,雖然比原生 NSAttributedString.DocumentType.html 快速且穩定;但還有需多優化空間,我相信效能絕對不如 XMLParser;希望有朝一日能有同樣的效能但又能保持客製化及自動修正容錯
  2. 支援更多 HTML Tag、Style Attribute 轉換解析
  3. ZNSTextAttachment 再優化,實現 reuse 能,釋放記憶體;可能要研究 CoreText
  4. 支援 Markdown 解析,因底層抽象其實不局限於 HTML;所以只要建好前面的 Markdown 轉 Markup 物件就能完成 Markdown 解析;因此我取名叫 ZMarkupParser,而不是 ZHTMLParser,就是希望有朝一日也能支援 Markdown to NSAttributedString
  5. 支援 Any to Any, e.g. HTML To Markdown, Markdown To HTML,因我們有原始的 AST 樹(Markup 物件),所以實現任意 Markup 間的轉換是有機會的
  6. 實現 css !important 功能,加強抽象 MarkupStyle 的繼承策略
  7. 加強 HTML Selector 功能,目前只是最粗淺的 filter 功能
  8. 好多好多, 歡迎開 issue

如果您心有餘而力不足,也可以透過給我一顆 ⭐ 讓 Repo 可以被更多人看見,進而讓 Github 大神有機會協助貢獻!

總結

ZMarkupParser

以上就是我開發 ZMarkupParser 的所有技術細節及心路歷程,花費了我快三個月的下班及假日時間,無數的研究及實踐過程,到撰寫測試、提升 Test Coverage、建立 CI;最後才有一個看起來有點樣子的成果;希望這個工具有解決掉有相同困擾的朋友,也希望大家能一起讓這個工具變得更好。

pinkoi.com

目前有應用在敝司 pinkoi.com 的 iOS 版 App 上,沒有發現問題。😄

延伸閱讀

有任何問題及指教歡迎與我聯絡

--

--

ZhgChgLi
ZRealm Dev.

An iOS, web, and automation developer from Taiwan 🇹🇼 who also loves sharing, traveling, and writing. https://link.zhgchg.li/