NSAttributedStringとRTF・HTMLの相互変換

はじめに

iOSアプリ開発において、書式付き文字列NSAttributedString(またはその派生クラスのNSMutableAttributedString)と、 RTF(リッチテキスト形式)またはHTML文字列を相互変換する方法について説明する。 書式付き文字列の保存や読み込みの際にこのような変換を行うことになるかもしれない。 ただし、HTML文字列をNSAttributedStringに変換する際には注意が必要。

環境

  • macOS Monterey 12.4
  • Xcode 13.4.1
  • iOS 15.5

NSAttributedString → RTF or HTML

以下のようなコードで変換できる。

※本記事のSwiftコードは、そのままXcode付属のPlaygroundで実行できるように記述している。 iOS上で実行する(iOSアプリの一部として実行する)場合は、普通はimport文をファイルの冒頭に記述し、それ以外を適当な場所に挿入する。

import UIKit

let attrStr = NSMutableAttributedString(string: "abcdefg")  // 変換対象
attrStr.addAttribute(.foregroundColor, value: UIColor.red,
                     range: NSRange(location: 0, length: 3))  // 最初の3文字を赤色にする

if let data = try? attrStr.data(
    from: NSRange(location: 0, length: attrStr.length),
    documentAttributes: [.documentType: NSMutableAttributedString.DocumentType.html, // 変換形式指定
                         .characterEncoding: String.Encoding.utf8.rawValue]) {
    if let str = String(data: data, encoding: .utf8) {
        print(str)
    }
}

上記コードをiOS上で実行した場合、以下のように変換される。

<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<meta http-equiv="Content-Style-Type" content="text/css">
<title></title>
<meta name="Generator" content="Cocoa HTML Writer">
<style type="text/css">
p.p1 {margin: 0.0px 0.0px 0.0px 0.0px}
span.s1 {font-family: 'Helvetica'; font-weight: normal; font-style: normal; font-size: 12.00px; color: #ff0000}
span.s2 {font-family: 'Helvetica'; font-weight: normal; font-style: normal; font-size: 12.00px}
</style>
</head>
<body>
<p class="p1"><span class="s1">abc</span><span class="s2">defg</span></p>
</body>
</html>

変換には、NSAttributedString(またはNSMutableAttributedString)のインスタンスメソッドdata(from:documentAttributes:)を使用する(公式ドキュメントはこちら)。 from:に変換範囲(上記コードでは全体)、documentAttributes:に変換先の形式(.documentType)とエンコーディング(.characterEncoding)を指定する。なお、検証した環境(iOS 15.5)ではエンコーディングを指定しなくても動作したが、実行時に警告が表示された(一方、XcodeのPlaygroundで実行した場合は警告は表示されなかった)。

.documentTypeとして指定できるものは、iOSでは現在のところ以下の通り (macOSでは他にもWord形式などを指定できる)。 実際には、NSAttributedString.DocumentType.htmlなどと指定する。

  • plain: プレーンテキスト
  • html: HTML
  • rtf: リッチテキスト形式
  • rtfd: 添付ドキュメント付きリッチテキスト形式

リッチテキスト形式(RTF)について

リッチテキスト形式(Rich Text Format; RTF)は、Microsoftが開発したテキストベースの書式付き文字列の表現形式。 テキストベースであるが可読性はかなり悪い。特に日本語などはすべてエスケープされて文字コードで表現される。 仕様としては画像も含めることができ、Microsoft製品ではRTFファイルに画像を埋め込むことができる。 一方で、macOSやiOSでは、画像を含むリッチテキストはRTFD(Rich Text Format Directory)と呼ばれるRTFファイルと画像ファイルをまとめたディレクトリとして扱われる(Mac上では拡張子.rtfdのファイルとして見える)。

例えば、上記コードで.documentTypertfを指定した場合は以下のように変換される。

{\rtf1\ansi\ansicpg1252\cocoartf2638
\cocoatextscaling1\cocoaplatform1{\fonttbl\f0\fswiss\fcharset0 Helvetica;}
{\colortbl;\red255\green255\blue255;\red255\green0\blue0;}
{\*\expandedcolortbl;;\cssrgb\c100000\c0\c0;}
\pard\tx560\tx1120\tx1680\tx2240\tx2800\tx3360\tx3920\tx4480\tx5040\tx5600\tx6160\tx6720\pardirnatural\partightenfactor0

\f0\fs24 \cf2 abc\cf0 defg}

RTF or HTML → NSAttributedString

基本的にはNSAttributedString(またはNSMutableAttributedString)のイニシャライザを使用すれば良い(公式ドキュメントはこちら)。 今回は、options:.documentTypeを含む辞書を指定する。

import UIKit

let str = "<html><body><span style='color: red;'>abc</span>defg</body></html>"  // 変換対象

if let data = str.data(using: .utf8),
   let attrStr = try? NSAttributedString(
    data: data,
    options: [.documentType: NSAttributedString.DocumentType.html],
    documentAttributes: nil) {
    print(attrStr)
}

iOS上でうまく実行できた場合(後述)、以下のような出力が得られる。

abc{
    NSColor = "kCGColorSpaceModelRGB 1 0 0 1 ";
    NSFont = "<UICTFont: 0x13902f150> font-family: \"Times New Roman\"; font-weight: normal; font-style: normal; font-size: 12.00pt";
    NSKern = 0;
    NSParagraphStyle = "Alignment 4, LineSpacing 0, ParagraphSpacing 0, ParagraphSpacingBefore 0, HeadIndent 0, TailIndent 0, FirstLineHeadIndent 0, LineHeight 0/0, LineHeightMultiple 0, LineBreakMode 0, Tabs (\n), DefaultTabInterval 36, Blocks (\n), Lists (\n), BaseWritingDirection 0, HyphenationFactor 0, TighteningForTruncation NO, HeaderLevel 0 LineBreakStrategy 0 PresentationIntents (\n) ListIntentOrdinal 0 CodeBlockIntentLanguageHint ''";
    NSStrokeColor = "kCGColorSpaceModelRGB 1 0 0 1 ";
    NSStrokeWidth = 0;
}defg{
    NSColor = "kCGColorSpaceModelRGB 0 0 0 1 ";
    NSFont = "<UICTFont: 0x13902f150> font-family: \"Times New Roman\"; font-weight: normal; font-style: normal; font-size: 12.00pt";
    NSKern = 0;
    NSParagraphStyle = "Alignment 4, LineSpacing 0, ParagraphSpacing 0, ParagraphSpacingBefore 0, HeadIndent 0, TailIndent 0, FirstLineHeadIndent 0, LineHeight 0/0, LineHeightMultiple 0, LineBreakMode 0, Tabs (\n), DefaultTabInterval 36, Blocks (\n), Lists (\n), BaseWritingDirection 0, HyphenationFactor 0, TighteningForTruncation NO, HeaderLevel 0 LineBreakStrategy 0 PresentationIntents (\n) ListIntentOrdinal 0 CodeBlockIntentLanguageHint ''";
    NSStrokeColor = "kCGColorSpaceModelRGB 0 0 0 1 ";
    NSStrokeWidth = 0;
}

注意点として、iOS上で以上のようなHTMLをインポートするコードを実行するとクラッシュすることがある(一方、RTFの読み込みでは発生しない)。 筆者の環境では、特にアプリの起動時に以上のコードを実行するとクラッシュするため、 確証はないが、HTMLインポーター自体の初期化タイミングと関係しているのではないかと思う。

ちなみに、公式ドキュメントにHTMLインポーターをバックグラウンドスレッドで実行してはいけない、という記述があるからかもしれないが、 読み込みコードをDispatchQueue.main.async {}で囲むとよいという投稿(下記)もあった。

確かに何度か試した限りではクラッシュしなかったが、クラッシュしていた時もメインスレッドで実行されていたので、 やはり実行タイミングの問題のような気がする。さらに、完全にこれで解決されるのかもよくわからない。

以下のAppleのフォーラムでは、この現象について、Appleの人がiOSのバグであると言及している (かなり前のスレッドだが、他の人の投稿からもまだ解決していないことがわかる)。

同じ人によって、HTMLを表示するときはWKWebViewを使う、自分でHTMLをパースする (か自分で同様のマークアップシステムを作る)、Markdownを使う、というような代替案が示されている。

筆者は結局、アプリ内部での書式付き文字列の保存形式として、HTMLの代わりにRTFを使うことにしたのだが、 WebKitを使ってHTMLを読み込む方法もあったので、以下に記載する。

HTML → NSAttributedString の別の方法(WebKit使用)

WebKitをインポートすると、NSAttributedString(またはNSMutableAttributedString)に loadFromHTML(string:options:completionHandler:)などのタイプメソッドが追加される(公式ドキュメントはこちら)。

import WebKit

let str = "<html><body><span style='color: red;'>abc</span>defg</body></html>"  // 変換対象

NSAttributedString.loadFromHTML(string: str) { a, _, _ in
    if let attrStr = a {
        print(attrStr)
    }
}

iOS上で実行した場合、以下の出力が得られた(特にクラッシュなどは起こらなかった)。 今回はフォントサイズ等が少し異なっている。

abc{
    NSColor = "UIExtendedSRGBColorSpace 1 0 0 1";
    NSFont = "<UICTFont: 0x132f0af80> font-family: \"Times New Roman\"; font-weight: normal; font-style: normal; font-size: 19.00pt";
    NSKern = 0;
    NSParagraphStyle = "Alignment 4, LineSpacing 0, ParagraphSpacing 0, ParagraphSpacingBefore 0, HeadIndent 0, TailIndent 0, FirstLineHeadIndent 0, LineHeight 0/0, LineHeightMultiple 0, LineBreakMode 0, Tabs (\n), DefaultTabInterval 36, Blocks (\n), Lists (\n), BaseWritingDirection 0, HyphenationFactor 0, TighteningForTruncation NO, HeaderLevel 0 LineBreakStrategy 0 PresentationIntents (\n) ListIntentOrdinal 0 CodeBlockIntentLanguageHint ''";
    NSStrokeColor = "UIExtendedSRGBColorSpace 1 0 0 1";
    NSStrokeWidth = 0;
}defg{
    NSFont = "<UICTFont: 0x132f0af80> font-family: \"Times New Roman\"; font-weight: normal; font-style: normal; font-size: 19.00pt";
    NSKern = 0;
    NSParagraphStyle = "Alignment 4, LineSpacing 0, ParagraphSpacing 0, ParagraphSpacingBefore 0, HeadIndent 0, TailIndent 0, FirstLineHeadIndent 0, LineHeight 0/0, LineHeightMultiple 0, LineBreakMode 0, Tabs (\n), DefaultTabInterval 36, Blocks (\n), Lists (\n), BaseWritingDirection 0, HyphenationFactor 0, TighteningForTruncation NO, HeaderLevel 0 LineBreakStrategy 0 PresentationIntents (\n) ListIntentOrdinal 0 CodeBlockIntentLanguageHint ''";
    NSStrokeColor = "UIExtendedSRGBColorSpace 0 0 0 1";
    NSStrokeWidth = 0;
}