swiftでHTMLからNSAttributedStringを作る方法

この記事は Retty Advent Calendar 8日目です。
昨日は @koji-t さんの[物体検出とボケ検出で一眼レフ風の料理写真候補抽出](https://qiita.com/koji-t/private/93fed9acf14dab59de65)でした。

# はじめに
RettyのiOSエンジニア兼なんでも屋の櫻井です。
今回のネタはswiftでHTMLからNSAttributedStringを作るというお話になります。

この記事を書くキッカケとなったのは、直近でリリースしました弊社iOSアプリのRettyのリニューアルにおいて **NSAttributedStringを使うような表現にさらに多言語対応を含めたい** という要件が発生したためでした。
さらに言うと **formattedString を使って変数も埋め込みたい** といった要件も重なり ／(^o^)＼ﾅﾝﾃｺｯﾀｲ といったことになってしまいました。

まずは、この場合において何が課題となるのかを説明します。

## 課題となること
完成イメージはこのような感じです。
<img width="760" alt="29470b35-8eed-7ef0-6c37-2169db34bbce.png" src="https://qiita-image-store.s3.amazonaws.com/0/34824/a3a70242-e0fc-c346-c090-e7404b2cacc5.png">


 * 要件の整理
   * 図のようにユーザ名と、いいねをもらった日付が変数として埋め込まれる
   * bold、文字サイズや色の違いが入る
   * さらに多言語対応も必要

NSMutableAttributedString を使って複数の NSAttributedString を連結することでももちろん作れますが、３段目にあるタイ語のように言語によっては文章の分割仕方の違いがあり、それだけで xx.string に作成するキーの数が増えてしまい、管理が面倒なのは勿論、バグを生み出す元にもなり、またNSMutableAttributedStringで連結するためのコードも面倒なものになっていってしまいます。

# swiftでHTMLからNSAttributedStringを作る方法
NSAttributedStringを複雑に組みたい場合の背景を説明したところで本題です。
今回コードを調べて行く中で NSAttributedString のイニシャライザに options として
 documentType というパラメータを指定でき、さらに `NSAttributedString.DocumentType.html` なるものがあることを知りました。

つまりHTMLの文字列からなんとNSAttributedStringを生成することが可能なのです。
ですが、この生成についてもいくつかの課題があり一筋縄ではいきませんでした。

## 結論となるコード
今回の話のサンプルコードをGithubにあげておきました。
Starとかもらったことあまりないので、参考になったならもらえると筆者が泣いて喜びますw
https://github.com/saku/HtmlLabel

サンプルプロジェクト内にある String.swift が今回のテーマとなる String の extension になります。

```swift
import UIKit

extension String {
    func convertHtml(withFont: UIFont? = nil, align: NSTextAlignment = .left) -> NSAttributedString {
        if let data = self.data(using: .utf8, allowLossyConversion: true),
            let attributedText = try? NSAttributedString(
                data: data,
                options: [.documentType: NSAttributedString.DocumentType.html,
                          .characterEncoding: String.Encoding.utf8.rawValue],
                documentAttributes: nil
            ) {
            let style = NSMutableParagraphStyle()
            style.alignment = align

            let fullRange = NSRange(location: 0, length: attributedText.length)
            let mutableAttributeText = NSMutableAttributedString(attributedString: attributedText)

            if let font = withFont {
                mutableAttributeText.addAttribute(.paragraphStyle, value: style, range: fullRange)
                mutableAttributeText.enumerateAttribute(.font, in: fullRange, options: .longestEffectiveRangeNotRequired, using: { attribute, range, _ in
                    if let attributeFont = attribute as? UIFont {
                        let traits: UIFontDescriptorSymbolicTraits = attributeFont.fontDescriptor.symbolicTraits
                        var newDescriptor = attributeFont.fontDescriptor.withFamily(font.familyName)
                        if (traits.rawValue & UIFontDescriptorSymbolicTraits.traitBold.rawValue) != 0 {
                            newDescriptor = newDescriptor.withSymbolicTraits(.traitBold)!
                        }
                        if (traits.rawValue & UIFontDescriptorSymbolicTraits.traitItalic.rawValue) != 0 {
                            newDescriptor = newDescriptor.withSymbolicTraits(.traitItalic)!
                        }
                        let scaledFont = UIFont(descriptor: newDescriptor, size: attributeFont.pointSize)
                        mutableAttributeText.addAttribute(.font, value: scaledFont, range: range)
                    }
                })
            }

            return mutableAttributeText
        }

        return NSAttributedString(string: self)
    }
}
```

## 課題その１　なぜかフォントがlabelの指定のものと変わる
なぜかNSAttributedString単純にテキストを生成するとフォントの指定が勝手に変わります＼(^o^)／
生成されるタイミングで色々な情報が失われるようで、storyboardのfontを指定してもここでは意味がありません。
フォントを正しいものにするためには自らstoryboardで設定したフォントを生成時のメソッドに渡して、その中で設定してあげる必要があります。

具体的にはサンプルコードのこの部分となります。

```swift
            if let font = withFont {
                mutableAttributeText.addAttribute(.paragraphStyle, value: style, range: fullRange)
                mutableAttributeText.enumerateAttribute(.font, in: fullRange, options: .longestEffectiveRangeNotRequired, using: { attribute, range, _ in
                    if let attributeFont = attribute as? UIFont {
                        // ・・・中略
                        var newDescriptor = attributeFont.fontDescriptor.withFamily(font.familyName)
                        // ・・・中略
                        let scaledFont = UIFont(descriptor: newDescriptor, size: attributeFont.pointSize)
                        mutableAttributeText.addAttribute(.font, value: scaledFont, range: range)
                    }
                })
            }
```

## 課題その２　bold指定やitalic指定が消える
課題１で指定した元ラベルのフォントを設定することでフォントファミリーやフォントサイズが指定のものと変わってしまう問題が解決できましたが、storyboard 上の設定をそのまま使っているためこのままではbold指定やitalicの指定が反映されません。

そこで、HTMLから生成したAttributedStringをattributeの配列に分割し、その分割結果からフォントを取得し、さらにそのフォントからfont descriptorというものを取得します。
font descriptor は symbolicTraits というプロパティがあり、これにboldやitalicといった文字スタイルに関する情報が含まれています。
このtraitsから情報を取得して反映することになるのですが、情報の取得には昔ながらのビット演算を使う必要があります。

具体的にはサンプルコードのこの部分となります。

```swift
                mutableAttributeText.enumerateAttribute(.font, in: fullRange, options: .longestEffectiveRangeNotRequired, using: { attribute, range, _ in
                    if let attributeFont = attribute as? UIFont {
                        // ここで traits を取得
                        let traits: UIFontDescriptorSymbolicTraits = attributeFont.fontDescriptor.symbolicTraits
                        var newDescriptor = attributeFont.fontDescriptor.withFamily(font.familyName)
                        // ビット演算をしてboldのフラグがたっているようならば新しく設定するfont descriptorにも反映
                        if (traits.rawValue & UIFontDescriptorSymbolicTraits.traitBold.rawValue) != 0 {
                            newDescriptor = newDescriptor.withSymbolicTraits(.traitBold)!
                        }
                        // 同様にitalicも設定
                        if (traits.rawValue & UIFontDescriptorSymbolicTraits.traitItalic.rawValue) != 0 {
                            newDescriptor = newDescriptor.withSymbolicTraits(.traitItalic)!
                        }
                        let scaledFont = UIFont(descriptor: newDescriptor, size: attributeFont.pointSize)
                        mutableAttributeText.addAttribute(.font, value: scaledFont, range: range)
                    }
                })
```

## 課題その３　text align（中央寄せ等）の情報が消える
課題１の話と関連して、HTMLから変換した文字は全てデフォルト左寄せとなります。
対策としてはAttributedStringには paragraphStyle という形で text align の情報を付加することで解決します。
（今回の方法では左寄せと中央寄せと右寄せとが色々複合したラベルを作る方法には対応していません）

具体的にはサンプルコードのこの部分となります。

```swift
            let style = NSMutableParagraphStyle()
            style.alignment = align

            // ・・・中略

            if let font = withFont {
                // ここでalignを設定
                mutableAttributeText.addAttribute(.paragraphStyle, value: style, range: fullRange)
```

# おわりに
最初にHTMLをNSAttributedStringに変換できると知ったときには、「勝ったな・・・！」と思っていたのですが、そこからの道のりが意外と遠く険しくでビックリしました(´・ω・｀)
同じ苦しみを皆様が味わうことがなければ幸いです m(_ _)m

明日は @noripi さんの [Kotlin/Nativeを使ってiOSアプリを作ってみる](https://qiita.com/noripi/items/4ee969c48b3da5ca6fbd) です。
みなさん、お楽しみに！