Chatwork Creator's Note

ビジネスチャット「Chatwork」のエンジニアとデザイナーのブログです。

ビジネスチャット「Chatwork」のエンジニアとデザイナーのブログです。

読者になる

iOS アプリのタイムラインで info タグを表示する実装について

こんにちは。モバイルアプリケーション開発部の iOS アプリエンジニア、安宅 (@at_aka) です。

Chatwork の iOS アプリは現在、絶賛 Swift 化の真っ最中です。私もメインのプロジェクトの傍ら、タイムラインのメッセージ表示回りの Swift 化をやっています。これが少し手の込んだことをしているので、本日は Chatwork の iOS アプリがどのようにして info タグを表示するのか説明してみようと思います。

エッジケースのケアまで含めると話が大きく広がります。それに Swift のコードを読んでも面白くないでしょう。ですので、デザイナーの皆さんにも分かるように Swift のソースコードは極力出さずにエッセンスを書いてみます。

なお、Swift 化の前と後とでは、実装方法に少なからず違いが出ています。Swift 化したメッセージ表示はまだリリースされていませんが、ここは Sneak Preview ということで Swift 版での実装をベースに話を進めます。

AST とトークン

まずは大まかな流れを見てみましょう。

Chatwork アプリではメッセージの本文 —— Chatwork 独自の記法である メッセージ記法 で書かれています —— を一度 AST に直し、AST に対して描画の処理を行なっています。

AST とは Abstract Syntax Tree の頭文字を取ったもので、日本語では抽象構文木と訳されます。

例えば次のメッセージ本文は、どこからどこまでが日本語のテキストなのかが分かりません。

(*) 日本語のテキスト
[info]時報は tel:117 です[/info]

これを XML 版なんちゃって AST に変換すると、次のように表現できます 1

<ast>
    <emoji value="star"/>
    <text value=" 日本語のテキスト"/>
    <info>
        <text value="時報は "/>
        <telephoneNumber value="tel:117"/>
        <text value=" です"/>
    </info>
</ast>

ここで 1 つ 1 つの XML 要素をトークン (字句) と呼びます。 AST はトークンの集まりだと思って構いません。

AST を上から詳しく見て行きましょう。

最初のトークンは絵文字トークンです。 Chatwork には 40 個を越える絵文字が用意されていますが、その書き方は様々です。 (*) 2 のように丸括弧で囲む書き方もあれば、:) 3 のように顔文字 の書式が使えるものもあります。

描画処理を行なう場合、基本的にテキストの先頭から処理を進めていきます。 もし AST がなかったら、テキストの先頭から 1 文字 1 文字、これは絵文字かな? 違うかな? と判定しながら進めます。 1 文字目が ( だったとしても、それがテキストとしての丸括弧なのか、絵文字を構成する丸括弧なのか分かりません。 2 文字目、3 文字目と読み進めてようやく絵文字だった、実は違ったということが分かります。 たくさんある絵文字トークンの書式をサポートするのは大変です。

ですが、XML の形のようになっていると、テキストを 1 文字 1 文字判定する手間がなくなります。 絵文字の書式は必ず絵文字トークンになっているのですから、テキストトークンの中に絵文字があるかもしれない! なんて心配する必要がないわけです。 絵文字トークンは後でアニメーション画像で描画する処理を行ないます。

次のトークンはテキストトークンです。 値に " 日本語のテキスト" が入っています。 テキストトークンはそのまま表示して良さそうです。

その次は Info トークンです。 Info トークンは [info]〜本文〜[/info] のような書式で書きます。 AST がなければ 1 文字ずつチェックして、閉じタグを見つけるまで Info 書式があると断定できません。 しかし、AST があれば閉じタグのチェックをしなくても、Info があると分かります。 閉じタグのチェック etc. は AST を作る時に終えているからです。 とても便利ですね。

Info トークンは入れ子構造を取れます。 中にはテキストトークンと電話番号トークンがあります。 電話番号トークンはタップしたら電話がかけられるように後でリンク化しておきましょう。 Chatwork では 9 〜 14 文字の数字の羅列を電話番号として判定しますが、tel: プレフィックスを付けると 3 文字から電話番号として認識するようになります。 このような電話番号のルールは細かいのですが、AST になっていれば難しいことを考えなくて良いので楽です。

AST の中身をざっと眺めてみました。 要はテキストの解析と描画処理を一挙にやるのではなく、別々に行なう。 テキストの解析結果を AST という形に直してしまおう。 そうすると描画処理がシンプルにできる、というお話です。

さて、AST はメッセージ記法を別の形で表したものと考えることができます。 ですので、本項ではタグを使って書く従来のメッセージ記法を 生タグ文字列、トークンの形で表されたメッセージ記法を AST、生タグ文字列から AST を作る作業をトークナイズ (トークンを作る作業) と呼ぶことにします。

レイアウトして描画する

AST の描画処理について見て行きましょう。 iOS では一般に次のステップを踏んで描画を行ないます。

  1. 属性付き文字列を作成する
  2. 属性付き文字列をテキストビューにレイアウトする
  3. テキストビューに描画処理をかける

属性付き文字列 4 は、フォント属性・前景色属性・背景色属性・段落属性 etc. を持った文字列のことです。 本項のお話はどうやって属性付き文字列を作成するか、という話に他なりません。 Info トークンの属性付き文字列の作成については、次章以降で詳しく見ていきます。

属性付き文字列をテキストビュー 5 にレイアウト 6 するのは、ほぼ iOS 任せです。 カスタマイズも可能ですが、ここで説明するような内容はありません。 OS が自動でレイアウトしてくれると思って頂ければ結構です。

テキストビューに描画処理を行なうのも、ほぼ OS が自動で行なってくれます。 レイアウトしたテキストや色がテキストビューに描画されます。 ただし、Info トークンの枠線や Code トークンの背景色などは自前で描画する 7 必要があります。 次節では Info トークンの枠線の描画について見ていきます。

以上が描画までの流れです。 ここで重要なのは、レイアウトされた後に描画が行なわれる点です。

例えば次のような入れ子になった info タグの描画を考えてみましょう。

[info]最初のテキスト
[info]次のテキスト[/info]
[/info]

この時の XML 版なんちゃって AST は次のようになります:

<ast>
    <info>
        <text value="最初のテキスト"/>
        <info>
            <text value="次のテキスト"/>
        </info>
    </info>
</ast>

Info トークンの中のテキストは 10 pt インデントして表示するとしましょう。

もしも、枠線を描いた後にテキストをレイアウトするのであればとても楽です。

まず枠線だけを描きます。

f:id:at-aka:20201120154954j:plain

そして外側の枠線から 10 pt インデントした位置に「最初のテキスト」を配置します。

f:id:at-aka:20201120154953j:plain

次に内側の枠線から 10 pt インデントした位置に「次のテキスト」を配置します。

f:id:at-aka:20201120161648j:plain

Info の中のテキストは常に枠線から 10 pt だけインデントすれば良いので、難しいことを考えるが必要がありません。 とても楽です。

ウェブページで CSS を使ってスタイルを指定する人達の多くは、上のような描画イメージを持っているのではないでしょうか?

残念ながら、iOS ではこの順番が逆で、レイアウトした後に描画が行なわれます。 ですので、上のような描画プロセスは踏みません。 ではどうなるのかと言うと、次のようになります。

まず、「最初のテキスト」を 10 pt インデントするようにレイアウトします。

f:id:at-aka:20201120154956j:plain

次に「次のテキスト」を 20 pt インデントするようにレイアウトします。

f:id:at-aka:20201120154957j:plain

そして、テキストビューの端から左 0 pt の位置に (外側の Info の) 枠線を描きます。

f:id:at-aka:20201120154958j:plain

最後にテキストビューの端から左 10 pt の位置に (内側の Info の) 枠線を描きます。

f:id:at-aka:20201120154959j:plain

これで完成です。

外側の Info トークンのテキスト「最初のテキスト」をレイアウトするためには、10 pt のインデントが、内側の Info トークンのテキスト「次のテキスト」には 20 pt のインデントが必要です。また、外側の Info トークンの枠線は 0 pt、内側の Info トークンの枠線は 10 pt 内側に描く必要があります。これらの数字は自分達で計算する必要があります。 面倒ですね。 描画した後にレイアウトするのであれば上に例を書いた通りこのような計算は不要です。 ですが、OS はレイアウトした後に描画を行なうので、どうしても自分達で計算しなければいけません。

レイアウトは OS によって自動的に行なわれると書きました。 ですので、先述の「10 pt インデントするようレイアウトする」は、「10 pt インデントする属性付き文字列を作る」というのが正確です。

また枠線を描くための「テキストビューの端から左 0 pt」といった情報。 これも予め計算して、属性付き文字列を作る際に情報を埋め込んでおきます。 実は枠線を描く処理では AST の情報は失われていて属性付き文字列しか渡すことができないのです。

というわけで、レイアウトを正しく行なうように属性付き文字列を作りつつ、描画のために必要な情報を予め計算しておいてその情報属性も付与しておく (これをカスタム描画属性と呼びましょう)。 属性付き文字列の作成が一番の肝になります。

次節では、比較的に軽めな枠線の描画を先に見て行きましょう。

枠線を描く

Info トークンのレイアウトが終わった前提で、枠線の描画をどのように行なうか見てみます。

まずテキストビューの中に含まれるカスタム描画属性を取り出します 8。 今回のケースですと 2 つの属性が取り出せます。 外側の Info トークンのカスタム描画属性と内側の Info トークンのカスタム描画属性です。 やることは同じなので、ここからは外側の Info トークンの描画に絞って話を進めましょう。

レイアウトマネージャー 6 にカスタム描画属性を渡して Info トークンのレイアウト領域を問い合わせます 9。 すると Info トークンのレイアウト領域が矩形領域 (赤色の領域) として得られます。

f:id:at-aka:20201120155000j:plain

最も外側の Info トークンはテキストビューの幅一杯に表示されます。 ですので、この領域を Info 用に少し加工します 10

f:id:at-aka:20201120155001j:plain

外側の Info トークンの枠線はこの領域から 0 pt 内側に枠線を引けば良いのでした。 この情報はカスタム情報属性の中に埋め込まれているので、描画時に計算したりする必要はありません。 カスタム情報属性の中から得られた値をそのまま使うだけです。

今回は 0 pt 内側にズラすだけなので、何もしないのと同じですね。 領域に変化はありません。

f:id:at-aka:20201120155002j:plain

Swift には短形領域を与えるとその境界に対して Bezier 曲線でパスを作成する機能があります。 それで Bezier パスを作成して、線の太さや色を指定して線を描いてあげれば枠線が描けます 11。 実際に描いてみましょう。

f:id:at-aka:20201120155003j:plain

外側の Info の枠線が描けました。

同じように内側の Info のカスタム情報属性からも枠線を描きます。 Info 用に加工した矩形領域を取得するところまでは同じです。

f:id:at-aka:20201120155004j:plain

違いは、この領域から 10 pt 内側に枠線を引くことです。 この 10pt の計算も事前に済んでいるので、ここでは計算する必要はありません。 属性付き文字列を作る時に、どうやって計算するのか説明します。

10 pt 内側にズラした領域は次の通りです。

f:id:at-aka:20201120155005j:plain

この矩形領域に対して Bezier パスを作成して線を描いてあげましょう。

f:id:at-aka:20201120155006j:plain

外側の Info と内側の Info、両方の枠線を描くことができました。 用意されている値の通りに領域を加工して線を引くだけなので、難しくないですね。

インデントを意識して属性付き文字列を作る

Info トークンの属性付き文字列を作成して行きましょう。 ここで意識すべきは次の 2 点です。

  1. レイアウトを正しく行なうように属性付き文字列を作る
  2. 描画に必要な情報を計算してカスタム描画属性として付与する

サンプルとして使う生タグ文字列を再掲します。

[info]最初のテキスト
[info]次のテキスト[/info]
[/info]

XML 版なんちゃって AST は次の通りです。

<ast>
    <info>
        <text value="最初のテキスト"/>
        <info>
            <text value="次のテキスト"/>
        </info>
    </info>
</ast>

さて、属性付き文字列の作成は各トークンを前から順番に処理していきます。 今回の例だと次のような流れです。

  1. 外側の Info トークンの前処理
  2. テキストトークン「最初のテキスト」から属性付き文字列を作る
  3. 内側の Info トークンの前処理
  4. テキストトークン「次のテキスト」から属性付き文字列を作る
  5. 内側の Info トークンの後処理で内側の Info トークン全体の属性付き文字列を作る
  6. 外側の Info トークンの後処理で外側の Info トークン全体の属性付き文字列を作る

随分ステップ数が多いですね。

もしかしたら、テキストトークンで属性付き文字列を作った後に、Info トークンの後処理で属性付き文字列を作り直しているのが冗長に思われるかもしれません。 しかし、トークンの種類はテキストトークンだけではありません。 絵文字トークンもありますし、電話番号トークンもあります。 トークンの数は 10 種類を軽く超えます。 そんな数のトークン全てに Info トークンの処理だけで対応しようとすると、考えることが多くなりすぎて誰もコードを管理できなくなります。

ですので、テキストトークンなどの各種トークンは自分自身を表示する属性付き文字列の作成に注力します。 そして各種トークンを内包する Info トークンは作成された属性付き文字列に少しだけ手を入れる形で属性付き文字列を連結する。 これが属性付き文字列作成の戦略になります。

さて、そうなるとテキストトークン「最初のテキスト」は Info の中にあることをどうやって知れば良いのでしょう?

テキストトークンから属性付き文字列を作るためには、「Info の入れ子の中にある」という文脈を知る必要があります。 そこでトークンにまつわる文脈情報を入れるためにトークンコンテキストを導入します。

トークンコンテキストの導入

トークンコンテキストはトークン処理にまつわる文脈情報を納めた小さなクラスです 12。 中には現在のインデント量を pt 値で保存しています。

トークンコンテキストは各トークンの処理を行なう時に、常に情報が引き渡されるようにしています。 また入れ子の中に入るごとに新しいトークンコンテキストを作って、インデント量を増やすようにしています 13

AST に対して処理を行なう場合、トークンコンテキストのインデント値はどうなるでしょう?

まず AST の処理前に 1 つ目のトークンコンテキストを作ります。 この時、インデント値は 0 pt です。

次に外側の Info トークンの処理に入ります。 ここで入れ子の中に入ったので、2 つ目のトークンコンテキストを作って、インデント値に 10 pt をセットします。

テキストトークン「最初のテキスト」の処理では 2 つ目のトークンコンテキストが渡ってきます。 なのでインデント値 10pt を使います。

そして内側の Info トークン。 再び入れ子の中に入ったので、3 つ目のトークンコンテキストを作ります。 インデント値は 10 pt + 10 pt で 20 pt をセットします。

ここでトークンコンテキストに保存するのはインデント値ではなく入れ子の深さだけで良いのではないか? と思われる方もいらっしゃるかもしれません。 入れ子の深さに 10 pt をかければそのコンテキストのインデント量が求まるじゃないか、という考えです。 しかし、これは入れ子ごとのインデント量が一定の場合にしか使えない考え方です。

一部のトークンでは入れ子のインデント量が 10 pt ではなく 12 pt だったりします。 また引用トークンでは右側のインデント量が 0 pt に設定されています (今は左側のインデントしか話をしていないので、少し唐突になりますが)。 そういうわけで、入れ子ごとのインデント量が一定ではないので、合計のインデント量を保存するようにしています。

閑話休題。

テキストトークン「次のテキスト」の処理に移ります。 ここでは 3 つ目のトークンコンテキストを使います。 なのでインデント値 20 pt を使います。

このようにして、トークンコンテキストを渡してゆくことにより、各トークンの処理が他のトークンに依存しないようにします。

段落属性の付与

具体的にテキストトークンのレイアウトについて話をしましょう。 テキストトークン「最初のテキスト」は左に 10 pt の位置にインデントしてレイアウトされれ良いはずです。

どのような属性付き文字列を作ればインデントを設定できるのでしょうか?

それには段落属性を使います 14。 段落属性には段落に関わる情報 (インデント量・行間・位置揃えなど) をセットできます。 ここでは段落属性のインデント量だけセットするようにします。

どれだけのインデント量をセットすれば良いでしょうか?

その値はトークンコンテキストに入っているので、その値を使えば良いですね。

テキストトークン「最初のテキスト」には 2 つ目のトークンコンテキストが渡ってきていて、インデント値は 10 pt でした。 この値を段落属性にセットし、文字列「最初のテキスト」全体に段落属性を付与すれば、属性付き文字列の完成です。

同様にテキストトークン「次のテキスト」には 3 つ目のトークンコンテキストからインデント値 20 pt を取得して、段落属性にセットします。 そして文字列「次のテキスト」全体に段落属性を付与すれば OK です。

段落属性の注意点

属性付き文字列には、基本的に 1 文字ずつ属性を付与することが可能です。 次の例はテキストの前半に HelveticaNeue フォント、後半に Courier フォントをフォント属性として付与しました 15

f:id:at-aka:20201120173636j:plain

しかし段落属性だけは例外で、1 文字ずつ異なる属性を付与しても、反映される属性はその段落の 1 文字目に付与された段落属性になります。

f:id:at-aka:20201120174628j:plain

上の例ではテキストの前半にインデント 10 pt を、後半にインデント 20 pt となるように段落属性を付与しています 16。 しかし、結果はインデント 10 pt になっています。

考えてみれば分かりますが、同じ行で違うインデントを持つことはできません。 インデント 10 pt でかつインデント 20 pt というのは実現不可能です。

そういったわけで段落属性は、段落の最初の 1 文字目に付与された属性値を使うようになっているのです。

この注意点は属性付き文字列を作る上で、とても重要なので覚えておいてください。

ゼロ幅スペースの挿入

段落属性を付与することで、レイアウトを正しく行なう属性付き文字列が作れました。 次は描画に必要な情報を計算してカスタム描画属性として付与するステップです。

さて、これからの説明をシンプルにするためにまず内側の Info トークンがないケースから見て行きましょう。 つまり、このような生タグ文字列を考えます。

[info]最初のテキスト[/info]

XML 版なんちゃって AST は次の通りです。

<ast>
    <info>
        <text value="最初のテキスト"/>
    </info>
</ast>

処理の流れを書くとこうなります。

  1. Info トークンの前処理
  2. テキストトークン「最初のテキスト」から属性付き文字列を作る
  3. Info トークンの後処理で Info トークン全体の属性付き文字列を作る

処理 1. で入れ子の中で使うトークンコンテキストを作り、処理 2. でテキストトークン「最初のテキスト」から属性付き文字列を作ります。 この作業は既に前節までで終了しています。

これから処理 3. Info の後処理を行なっていきます。

Info の枠線を描くために必要な情報を計算する必要があります。

この Info は入れ子の深さが 1 です。 ですから枠線はテキストビューの境界と同じ位置、つまりテキストビューから枠線までの距離は 0 pt となります。 0 pt をどうやって計算しましょう?

実は Info の枠線の位置は入れ子の外側のインデントと同じです。

f:id:at-aka:20201120182130j:plain

入れ子の外側のインデントの量は、Info の入れ子の前に作ったトークンコンテキストのインデント値と同じです。 今回の場合 1 つ目のトークンコンテキストのインデント値のことですね。 この値が 0 pt でした。

計算をする必要もなく枠線のインデント量を知ることができました。 では、この値をカスタム描画属性として属性付き文字列に付与しましょう。 どのように付与すれば良いでしょうか?

結論から書きますと、ゼロ幅スペースにカスタム描画属性を付与して、Info の中の属性付き文字列の先頭に置きます。 ゼロ幅スペース (Zero Width Space) はユニコードで U+200B に割り当てられている文字で、その名の通り幅がゼロのスペースです。 ユーザーの目には見えないので、トリッキーですがこの文字を挿入するようにします (理由は後で書きます)。 図にすると次のような感じです。

f:id:at-aka:20201120183059j:plain

本当に幅がゼロなスペースを表示すると見えないので、便宜上背景黄色のスペースでゼロ幅スペースを表現しました。

ゼロ幅スペースに段落情報を付与する

前節の画像を見ておかしな事に気がつきませんか?

そうです、ゼロ幅スペースが段落の先頭に来ています。 ですからこの段落はゼロ幅スペースに付与された段落属性が適用されます。 しかし、私達はゼロ幅スペースに何の段落属性も付与していません。 せっかく用意したレイアウトが崩れてしまいます。

ゼロ幅スペースに段落属性を付与する必要があります。 どんな属性値を付与すれば良いでしょうか?

これはゼロ幅スペースの右隣の文字の段落属性をコピーして段落属性を付与してあげれば OK です 17

f:id:at-aka:20201120182542j:plain

入れ子のカスタム描画属性

入れ子がある Info の描画に話を戻しましょう。 生タグ文字列は次の通りです。

[info]最初のテキスト
[info]次のテキスト[/info]
[/info]

XML 版なんちゃって AST は次の通りです。

<ast>
    <info>
        <text value="最初のテキスト"/>
        <info>
            <text value="次のテキスト"/>
        </info>
    </info>
</ast>

処理の流れで言うと処理 4. まで見てきました。

  1. 外側の Info トークンの前処理
  2. テキストトークン「最初のテキスト」から属性付き文字列を作る
  3. 内側の Info トークンの前処理
  4. テキストトークン「次のテキスト」から属性付き文字列を作る
  5. 内側の Info トークンの後処理で内側の Info トークン全体の属性付き文字列を作る
  6. 外側の Info トークンの後処理で外側の Info トークン全体の属性付き文字列を作る

前節を踏襲して内側の Info トークンの後処理をやっていきましょう。

内側の Info トークンの枠のインデント量を得たいのでした。 この値は内側の Info トークンの外側のトークンコンテキスト (1 つ目のトークンコンテキスト) のインデント値を参照すれば良いのでした。 1 つ目のトークンコンテキストのインデント値は 10 pt です。

ゼロ幅スペースを属性付き文字列に直して、インデント値 10 pt のカスタム描画属性を付与します。 できあがった属性付き文字列を、テキストトークン「次のテキスト」の属性付き文字列の前に置きます。 ゼロ幅スペースの右隣の文字から段落属性をコピーして、ゼロ幅スペースの属性付き文字列に付与するのも忘れないようにしましょう。

f:id:at-aka:20201120185356j:plain

内側 Info トークンの属性付き文字列を便宜上背景青色のスペースで表現しました。

最後に処理 6. として外側 Info トークンの属性付き文字列を作ります。

といっても、外側 Info の中身がテキストトークンの属性付き文字列から、「テキストトークンと内側 Info トークン」の属性付き文字列に変わっただけです。 やることは先ほどと変わりません。

外側 Info のゼロ幅スペースには、インデント値 0 pt のカスタム描画属性と右隣の文字からコピーした段落属性を付与して、すべての属性付き文字列の前に置けば良いだけです。

f:id:at-aka:20201120185435j:plain

なぜゼロ幅スペースを使うのか その 1

ゼロ幅スペースをなぜ使うのでしょうか?

属性付き文字列には、指定した属性がどこからどこまで付与されているか調べる関数があります。 この関数が使えると、描画の時にカスタム描画属性が付与されている範囲を調べられるので、とても便利です。 しかし、属性付き文字列では属性 1 種類につき 1 つしか属性値を持たせることができません。 同じ属性に別の属性値を付与した場合は後勝ちになります。

処理の流れを思い出してみましょう。

  1. 外側の Info トークンの前処理
  2. テキストトークン「最初のテキスト」から属性付き文字列を作る
  3. 内側の Info トークンの前処理
  4. テキストトークン「次のテキスト」から属性付き文字列を作る
  5. 内側の Info トークンの後処理で内側の Info トークン全体の属性付き文字列を作る
  6. 外側の Info トークンの後処理で外側の Info トークン全体の属性付き文字列を作る

仮に Info 全体にカスタム描画属性を付与するなら、処理 5. では青色背景の領域に内側 Info のカスタム描画属性が付与されます。

f:id:at-aka:20201120183829j:plain

そして処理 6. で外側 Info のカスタム描画属性が黄色背景の領域に付与されます。

f:id:at-aka:20201120183830j:plain

外側 Info のカスタム描画属性が内側 Info のカスタム描画属性を全部上書きしてしまいました。 これでは内側 Info の枠線を描くことができません。

処理の流れは変えることができないので、ゼロ幅スペースを挿入してカスタム描画属性を付与するようにしました。

すると 2 つ疑問が出てくるのではないでしょうか?

1 つ目は、ゼロ幅スペースを挿入しなくても、Info で作ったテキストトークンの先頭の文字にカスタム描画属性を付与すれば良いのでは? という疑問です。 そうすればレイアウトが崩れることもなくなり、ゼロ幅スペースに段落属性を付与し直す手間もなくなります。

この疑問についてはその通りなのですが、まだ説明していない別の理由でゼロ幅スペースを置かざるを得ませんでした。 すぐに説明しますので、少々お待ちください。

2 つ目は、カスタム描画属性が Info 全体を覆っていないのだから、属性を指定して属性が付与されている範囲を調べる関数が使えなくなるのでは? という疑問です。

当におっしゃる通りです。 Info の先頭にゼロ幅スペースを置いていますから、先頭位置は分かります。 しかし、どこまで Info の範囲が及んでいるのか分かりません。 範囲が分からなければ枠線も描けませんから困ります。 そこで、Info 全体の属性付き文字列の長さもカスタム描画属性に保存します。

ここまでの内容をまとめると、Info の先頭に置かれるゼロ幅スペースには、次の情報が属性として付与されています。

  1. 段落属性
    • レイアウトのためのインデント値
  2. カスタム描画属性
    • 枠線のためのインデント値
    • Info の長さ

トップパディングを意識して属性付き文字列を作る

今まで横方向のインデントの話ばかりしていました。 しかし、2 次元には縦方向もあります。 縦方向のスペースはどうなっているのでしょう。 具体的には Info の中身から枠線までのパディングはどうやって作っているのでしょう?

本章では縦方向のスペースについて説明をします。

前のテキスト
[info]最初のテキスト[/info]

XML 版なんちゃって AST は次の通りです。

<ast>
    <text value="前のテキスト"/>
    <info>
        <text value="最初のテキスト"/>
    </info>
</ast>

テキストトークン「最初のテキスト」までの属性付き文字列を作って表示してみましょう。

f:id:at-aka:20201120192556j:plain

テキストトークンのレイアウトは OK です。 手を入れるところはありません。 しかし、テキストトークン「前のテキスト」とテキストトークン「最初のテキスト」の間には通常の行間しか空いていません。 これでは枠線を描くスペースがありません。

属性付き文字列にはベースライン・オフセット属性があって、これを付与すると文字のベースラインを上下に変更することができます。

テキストークン「最初のテキスト」の前にスペースを置いてベースライン・オフセットを 5 pt 上げてみましょう。

f:id:at-aka:20201120192801j:plain

良い感じにパディングを用意することができました。

なぜゼロ幅スペースを使うのか その 2

処理の流れを振り返ってみましょう。

  1. Info トークンの前処理
  2. テキストトークン「最初のテキスト」から属性付き文字列を作る
  3. Info トークンの後処理で Info トークン全体の属性付き文字列を作る

処理 2. までは今まで通りで OK です。 Info トークンの後処理で、何かしらの文字のベースライン・オフセットを上に上げてやれば適切にパディングを空けられそうです。

通常の文字のベースライン・オフセットを上げてはいけません。 レイアウトが崩れます。

f:id:at-aka:20201120194307j:plain

スペースを使うのは良さそうですが、スペースは横幅を取るのでやはりレイアウトがわずかに崩れます (黄色背景はスペースです)。

f:id:at-aka:20201120193245j:plain

ゼロ幅スペースはどうでしょうか? 横幅がないので、レイアウト崩れが起きません。

f:id:at-aka:20201120195805j:plain

とても良さそうです。

処理 3. で思い出して欲しいのですが、私達はすでに Info の先頭にはゼロ幅スペースを置くようにしていました。 このゼロ幅スペースにベースライン・オフセット属性を付与すれば全て丸くおさまります。

実は Info の属性付き文字列の先頭にゼロ幅スペースを置くようにしたのは、この縦のパディングを空けるためだったのでした。

カスタム描画属性

レイアウトの問題は解決したので、枠線を引くためのカスタム描画属性を付与しましょう。

「枠線を描く」で説明した通り、レイアウトマネージャーに (カスタム描画属性を渡して) Info トークンのレイアウト領域を問い合わせることができます。 現在の Info のレイアウト領域を赤色で表示してみましょう。 ゼロ幅スペースを背景色黄色のスペースで表現します。

f:id:at-aka:20201120193246j:plain

ゼロ幅スペースで少し高くなった位置から赤色が始まっています。 このまま枠線を引いてしまったらどうでしょう。

f:id:at-aka:20201120193245j:plain

とても良い感じに枠線が引けました。 今回はカスタム描画属性をわざわざ用意しなくても良いのでしょうか。

Info の内側の先頭に Info が連続するケース

次の生タグ文字列を見てください。

[info]
[info]最初のテキスト[/info]
第二のテキスト
[/info]

[info]
第三のテキスト
[info]最後のテキスト[/info]
[/info]

XML 版なんちゃって AST は次の通りです。

<ast>
    <info>
        <info>
            <text value="最初のテキスト"/>
        </info>
        <text value="第二のテキスト"/>
    </info>

    <info>
        <text value="第三のテキスト"/>
        <info>
            <text value="最後のテキスト"/>
        </info>
    </info>
</ast>

これを表示してみるとレイアウトが崩れているのが分かります。

f:id:at-aka:20201120201614j:plain

内側 Info トークンがテキストトークンに続く場合 (下) は良いですね。 しかし、内側 Info トークンがテキストトークンの前にあるケース (上) で表示が崩れています。

内側 Info トークンが、外側 Info トークンの先頭にあるケースでケアが抜けています。 先頭にないケースでは問題ないので、外側 Info トークンから見て先頭にあるケースだけ例外扱いできると良さそうです。

まずはレイアウトについて見た後、トークンコンテキストを使ってカスタム描画属性を付与する方法を見ていきます。

トップパディングをレイアウトする

トップパディングをレイアウトするのは、ゼロ幅スペースにベースライン・オフセットを設定することです。 実際にどうやって設定するのか見ていきましょう。

生タグ文字列と AST を再掲します。

[info]
[info]最初のテキスト[/info]
第二のテキスト
[/info]

[info]
第三のテキスト
[info]最後のテキスト[/info]
[/info]

XML 版なんちゃって AST は次の通りです。

<ast>
    <info>
        <info>
            <text value="最初のテキスト"/>
        </info>
        <text value="第二のテキスト"/>
    </info>

    <info>
        <text value="第三のテキスト"/>
        <info>
            <text value="最後のテキスト"/>
        </info>
    </info>
</ast>

1 つ目の外側の Info トークンを見ていきましょう。

テキストトークンのレイアウトに関しては特に手を入れる必要がないので、割合します。 なので、テキストトークン「最初のテキスト」の属性付き文字列を作成した後の処理から始めましょう。

内側の Info トークンを抜けたら後処理で、ゼロ幅スペースを Info の属性付き文字列の前に置くのでした。 インデント処理の時に、ゼロ幅スペースの右隣の文字から段落属性をコピーして写していました。 今回も同じようにベースライン・オフセット属性を、ゼロ幅スペースの右隣の文字から取得します。

ゼロ幅スペースの右隣の文字はテキストトークンの属性付き文字列です。 この属性付き文字列にベースライン・オフセット属性はセットしていません。 なのでここは 0 pt のベースライン・オフセット値を取得します。

この 0 pt に Info のトップパディングの量である 5 pt を足して 5 pt をゼロ幅スペースのベースライン・オフセット属性の値として付与します。

テキストトークン「第二のテキスト」の処理は割合します。 外側の Info トークンを抜けたら後処理で、再びゼロ幅スペースを Info の属性付き文字列の前に置くのでした。

さきほどと同じようにゼロ幅スペースの右隣の文字からベースライン・オフセット属性をコピーします。 外側 Info のゼロ幅スペースの右隣には、内側 Info のゼロ幅スペースがあります。 ベースライン・オフセット値は 5 pt でした。

この 5 pt に Info のトップパディングの量である 5 pt を足して 10 pt をゼロ幅スペースのベースライン・オフセット属性の値として付与します。

以上で 1 つ目の外側の Info トークンのレイアウトはおしまいです。

f:id:at-aka:20201120202342j:plain

2 つ目の外側の Info トークンも見ておきましょう。

内側の Info トークンを抜けたら後処理でゼロ幅スペースを Info の属性付き文字列の前に置きます。

ゼロ幅スペースの右隣の文字はテキストトークンの属性付き文字列です。 0 pt のベースライン・オフセット値を取得して、Info のトップパディングの量である 5 pt を足します。 0 + 5 = 5 pt をゼロ幅スペースのベースライン・オフセット属性の値として付与します。

外側の Info トークンを抜けたら後処理でゼロ幅スペースを Info の属性付き文字列の前に置きます。

外側 Info のゼロ幅スペースの右隣の文字はなんでしょう。 1 つ目の外側 Info のゼロ幅スペースの右隣は内側 Info トークンの属性付き文字列がありました。 しかし今回はテキストトークンの属性付き文字列です。

0 pt のベースライン・オフセット値を取得して、Info のトップパディングの量である 5 pt を足します。 0 + 5 = 5 pt をゼロ幅スペースのベースライン・オフセット属性の値として付与します。

2 つ目の外側の Info トークンのレイアウトはおしまいです。

f:id:at-aka:20201120204319j:plain

トークンコンテキスト再び

枠線を描く話をする前に、1 つ目の外側 Info トークンの内側 Info トークンの表示領域を赤色で描いてみます。 黄色背景は外側 Info のゼロ幅スペース、青色背景は内側 Info のゼロ幅スペースです。

f:id:at-aka:20201120202343j:plain

内側 Info の表示領域を描いたはずなのに、内側 Info のゼロ幅スペースより上に領域が広がっています。 実は、この表示領域は「同じ行」にある一番上にある文字を含むようになっているのです。 この行にある一番上にある文字は外側 Info のゼロ幅スペースですから、それにひっぱられてしまったわけです。

そこでカスタム画像属性には、一番外の領域から枠線までのトップマージンを計算してセットしておく必要があります。

トップマージンの計算には外側の Info トークンと内側のトークンの関係性が重要です。 外側の Info トークンの最初の要素が Info トークンならトップマージンの値が必要ですが、2 番目の要素が Info トークンならトップマージンは不要、という風にです。

このようにトークンのまたがる情報を引き渡すにはトークンコンテキストを使うのでした。

トークンコンテキストには、現在のトークンの位置とトップマージン値を保存します 18。 トークン位置には先頭・中間・末尾の三種を用意して、新しいトークンに入るたびに「先頭」をセットします。 そして次のトークンに移る時に、トークン位置を中間 (もしくは末尾) に、トップマージンを 0 に変更します。

では全体の流れを前述の AST に対して見て行きましょう。

まず AST の処理前に 1 つ目のトークンコンテキストを作ります。 1 つ目のトークンコンテキストのトークン位置は先頭、トップマージンは 0 です。

次に 1 つ目の外側 Info トークンの処理に入ります。 (Info という) 入れ子に入ったので、2 つ目のトークンコンテキストを作り、トークン位置・先頭、トップマージン 5 pt をセットします。

外側 Info トークンの最初の要素は、また Info トークンです。 内側 Info トークンの処理に入ります。 入れ子に入ったので、3 つ目のトークンコンテキストを作り、トークン位置・先頭、トップマージン 5 + 5 = 10 pt をセットします。

テキストトークン「最初のテキスト」の処理では 3 つ目のトークンコンテキストが渡ってきます。 今回トークンの位置とトップマージンの情報は使いません。

内側 Info トークンの処理を抜けて、テキストトークン「第二のテキスト」の処理に入る前に、2 つ目のトークンコンテキストのトークン位置を末尾に変更し、トップマージンを 0 pt にリセットします。

テキストトークン「第二のテキスト」の処理では 2 つ目のトークンコンテキストを使います。 今回トークンの位置とトップマージンの情報は使いません。

これで 1 つ目の外側 Info トークンの処理はおしまいです。

蛇足になりますが、2 つ目の外側 Info トークンの処理も見ておきましょう。

2 つ目の外側 Info トークンの処理に移る前に、1 つ目のトークンコンテキストのトークン位置を末尾に変更し、トップマージンを 0 pt にセットします。

準備が整ったところで、2 つ目の外側 Info トークンの処理に入ります。 入れ子に入ったので、4 つ目のトークンコンテキストを作り、トークン位置・先頭、トップマージン 5 pt をセットします。

テキストトークン「第三のテキスト」の処理では 4 つ目のトークンコンテキストを使います。 今回トークンの位置とトップマージンの情報は使いません。

内側の Info トークンの処理に移る前に、4 つ目のトークンコンテキストのトークン位置を末尾に変更し、トップマージンを 0 pt にリセットします。

内側 Info トークンの処理に入ります。 入れ子に入ったので、5 つ目のトークンコンテキストを作り、トークン位置・先頭、トップマージン 0 + 5 = 5 pt をセットします。

テキストトークン「最後のテキスト」の処理では 5 つ目のトークンコンテキストを使います。 今回トークンの位置とトップマージンの情報は使いません。

内側 Info トークンの処理を抜けます。 続いて外側 Info トークンの処理を抜けます。 これで 2 つ目の外側 Info トークンの処理もおしまいです。

カスタム描画属性の付与

それではカスタム描画属性を付与していきましょう。

1 つ目の外側 Info トークンは、1 つ目のトークンコンテキストを参照します。 1 つ目のトークンコンテキストのトップマージン値は 0 pt でした。 ですので、1 つ目の外側 Info のゼロ幅スペースに付与するカスタム描画属性にトップマージン値 0 pt をセットします。

(1 つ目外側 Info トークンの) 内側 Info トークンは 2 つ目のトークンコンテキストを参照します。 2 つ目のトークンコンテキストのトップマージン値は 5 pt です。 内側 Info のゼロ幅スペースに付与するカスタム描画属性にトップマージン値 5 pt をセットします。

2 つ目の外側 Info トークンも 1 つ目のトークンコンテキストを参照します。 ですので、2 つ目の外側 Info のゼロ幅スペースに付与するカスタム描画属性にトップマージン値 0 pt をセットします。

(2 つ目外側 Info トークンの) 内側 Info トークンは 4 つ目のトークンコンテキストを参照します。 4 つ目のトークンコンテキストは (トークン位置が末尾に変更されたタイミングで) トップマージンが 0 にリセットされています。 内側 Info のゼロ幅スペースに付与するカスタム描画属性にトップマージン値 0 pt をセットします。

f:id:at-aka:20201120205712j:plain

これで Info が先頭で連続するケースでも表示崩れがなくなりました。

旅は続く

これで Info の表示は万全でしょうか?

いいえ。

みなさんご存じですね。 Info にはタイトルがあるということを。

[info][title]タイトルを忘れちゃいけないよ![/title]
そんなものもありましたね。
[/info]

とても残念なのですが、タイトルの話をするには原稿の執筆時間が足りないようです。 タイトルと本文の間に引かれる横線を描く方法、タイトルがあるケースとないケースでの条件分けなど、チャレンジングな話題が満載なのですが、本エントリーでは説明を省きます。

まとめ

Info の表示に関して、左インデントとトップパディングの話を中心にしました。 エッセンスに絞って話をしたので、デザイナーの人でも概略を理解できたのではないでしょうか?

もちろん、みなさんお気づきでしょうが、右インデントとボトムパディングの対処も必要です。 右インデントについては左インデントと同様に、ボトムパディングに関してはトップパディングと同様に処理を行なえば OK です。

本エントリーでは、説明をシンプルにするために左インデントとトップパディングの話を分けましたが、プログラムコードの上では左インデントとトップパディング (右インデントとボトムパディングも) を同時に処理することになります。 その分コードも複雑になりますが、ここに書いた基本を押さえていけば理解も難しくないと信じています。

メッセージ記法で入れ子を許すのは info タグ、引用タグ、タスクタグの 3 つです。 引用タグは info タグにない別の難しさがあります。 タスクタグは info タグとセットで使いますが、特殊なケアが必要なため、タスクタグと一緒に使う info タグに関しては通常の info タグとは別のコードを用意して対応しています。

また、行間に関する取り扱い、改行に関する取り扱い、URL やコードタグの省略表示など「表示」だけを取ってもまだまだ話は尽きません。

「表示」以外に目を向ければ、サーバーにデータを送る時は属性付き文字列を通常の「文字列」に戻さないといけませんし、属性付き文字列のまま「編集」を行なうには相応のケアが必要で、コピペに至ってはこれらの集体成の感があります。

もし機会があれば、これらのトピックについても触れられればと思います。


1. ここでは理解のために皆さんの見慣れた XML で説明を行ないましたが、実際には Google が開発した Protocol Buffers を使っています。

2. 星印の絵文字になります。

3. 笑った顔の絵文字になります。

4. Swift では属性付き文字列として NSAttributedString を使います。

5. Swift では UITextView を使います。Chatwork アプリでは UITextView をサブクラス化して利用しています。

6. Swift では NSLayoutManager にレイアウトを任せます。

7. 追加の描画処理は UITextVeiew のサブクラスで draw(_ rect:) メソッドをオーバーライドして行ないます。Swift をやっている人なら、draw(_ rect:) は CPU レンダリングなので、GPU レンダリングになる draw(_ layer:in:) の方が良いのではないか? (参考: High performance drawing on iOS — Part 1 | by Besher Al Maleh | Medium, Part 2) と気になる方もあるでしょう。ご説もっともで、そこまで手が回っていないだけです。余裕が出来たら、ぜひ。

8. NSAttributedStringenumerateAttribute(_:in:options:using:) メソッドを使うと指定した NSAttributeString.Key (属性のキー名) に対して、その NSAttributedString.Key が付与されている領域と属性値が得られます。次のようにして使います。

textView.textStorage.enumerateAttribute(.syntaxRoundedBox, in: NSRange(location: 0, length: textView.attributedText.length)) { value, range, _ in
    guard let roundedBox = value as? SyntaxAttribute.RoundedBox else { return }
    // 続きの処理
}

textView.textStorageUITextView に描画する NSAttributedString を格納するプロパティーです。 属性の当たっていない NSRange に対しては value = nil が渡されるので、guard let 文で弾くようにしています。

SyntaxAttribute.RoundedBox が Info 用のカスタム描画属性です。 本項で必要な情報だけ抜粋します。

extension NSAttributedString.Key {
    public static let syntaxRoundedBox = NSAttributedString.Key(rawValue: "syntaxRoundedBox")
}

public enum SyntaxAttribute {
    public struct RoundedBox {
        public let border: Line?
        public let margin: Margin
        public let length: Int?
    }
}

/// ラインを表す struct
public struct Line {
    public let width: CGFloat
    public let color: UIColor
}

/// TextView から枠線までのマージンを表す struct
public struct Margin {
    let top: CGFloat
    let left: CGFloat
    fileprivate let _bottom: CGFloat
    fileprivate let _right: CGFloat
}

枠線を描くためにズラす量は (左インデントに関しては) SyntaxAttribute.RoundedBox.marginMargin.left という形で収められています。

SyntaxAttribute.RoundedBox.length に Info トークンの文字数が入っています。

9. NSLayoutManagerglyphRange(forCharacterRange:actualCharacterRange:)boundingRect(forGlyphRange:in:) メソッドを使ってレイアウト領域を取得します。

let charRange = NSRange(location: range.location, length: roundedBox.length)
let layoutManager = textView.layoutManager
let textContainer = layoutManager.textContainers[0]

let glyphRange = layoutManager.glyphRange(forCharacterRange: charRange, actualCharacterRange: nil)
let boundingRect = layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer)

charRangeNSAttributedString 内における文字ベースの NSRange です。 開始位置は enumerateAttribute(_:in:options:using:) で得られる range.location で、その長さは SyntaxAttribute.Roundedbox.length に絡収されていた値を使います。

boundingRect(forGlyphRange:in:) メソッドはグリフが表示される領域を CGRect で返します。

グリフとは何でしょう? 大雑把に言えばコンピューターにおける見た目の 1 文字です。 例えば fi が連続する時、書体によってはリガチャ (合字; Ligature) されて 1 つのグリフとして表示されます。

f:id:at-aka:20201120212954p:plain

2 文字が 1 グリフとして表示される場合があるというわけです。

全ての文字はグリフに変換されてから表示されるので、表示領域を取得する boundingRect(forGlyphRange:in:) メソッドがグリフ・ベースになるのは自然に見えます。

しかし、属性付き文字列では文字数しか分かりません。 そこで、glyphRange(forCharacterRange:actualCharacterRange:) メソッドを使って文字ベースの NSRange をグリフベースの NSRange に変換します。

10. テキストビューの幅一杯に横幅を取りたいので、前述の boundingRect を使って次の CGRect を返します。

func baseRect(textView: SyntaxTextView, range: NSRange) -> CGRect? {
    let textContainer = layoutManager.textContainers[0]
    let boundingRect = ...  // boundingRect 取得のコード
    return CGRect(x: 0,
                  y: boundingRect.minY,
                  width: textContainer.size.width,
                  height: boundingRect.height)
}

UITextView がテキストを表示する領域は UITextView.textContainer で指定されます。 ですので、UITextView.frame.width ではなく textContainer.size.width を使って横幅を取得します。

textContainerUITextView.textContainerInset: UIEdgeInsets = {8, 0, 8, 0}UIEdgeInsets がデフォルトでセットされていて、横方向に 8pt 内側へインデントされています。 ただ私達は手抜きをして、 UITextViewUITextView.textContainer が同じ大きさになるようにしています。

class SyntaxTextView: UITextView, UIScrollViewDelegate, NSLayoutManagerDelegate {
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        commonInit()
    }

    override init(frame: CGRect, textContainer: NSTextContainer?) {
        super.init(frame: frame, textContainer: textContainer)
        commonInit()
    }

    private func commonInit() {
        textContainerInset = .zero
        // ...
    }
}

11. 矩形領域 CGRect を与えて Bezier 曲線のパスを得るには UIBezierPath.init(roundedRect:cornerRadius:) メソッドを使います。 ` 得られた Bezier パスに対して線を描くコードは次のようなものになります。

let path = UIBezierPath(roundedRect: roundedRect, cornerRadius: 8)
if let border = roundedBox.border {
    UIColor.red.setStroke()  // 赤色の枠線
    path.lineWidth = 1.0     // 1 pt の太さ
    path.stroke()
}

一部コードを省きますが、今までのまとめとして Info の枠線を描画するコードをのせておきます。

private func drawRoundedBox(in textView: SyntaxTextView) {
    textView.textStorage.enumerateAttribute(.syntaxRoundedBox, in: NSRange(location: 0, length: textView.attributedText.length)) { value, range, _ in
        guard let roundedBox = value as? SyntaxAttribute.RoundedBox else { return }
        let baseRect = baseRect(textView: textView, range: NSRange(location: range.location, length: roundedBox.length))

        let roundedRect = baseRect
            .inset(by: roundedBox.margin)
            .inset(by: roundedBox.border)

        // パスを生成して描画する
        let path = UIBezierPath(roundedRect: roundedRect, cornerRadius: roundedBox.cornerRadius)
        if let border = roundedBox.border {
            border.color.setStroke()
            path.lineWidth = border.width
            path.stroke()
        }
    }
}

public extension CGRect {
    func inset(by margin: Margin) -> CGRect {
        CGRect(x: minX + margin.left,
               y: minY + margin.top,
               width: width - (margin.left + margin._right),
               height: height - (margin.top + margin._bottom))
    }

    func inset(by line: Line?) -> CGRect {
        guard let line = line else { return self }
        return insetBy(dx: 0.5 * line.width, dy: 0.5 * line.width)
    }
}

12. トークンコンテキストの実体は次のような class です。 インデントに関わるコードだけ抜粋します。

final class TokenContext {
    let depth: Int  // デバッグ用
    let paragraphStyle: NSMutableParagraphStyle
}

13. 新しいトークンコンテキストを作らずに、入れ子処理から抜けた時にインデント量をリセットするという方法もあります。 ただ、リセット処理を書き忘れると、とんでもなく表示が崩れることになります。 不具合の影響範囲を小さくする目的で、毎回新しいトークンコンテキストを作るようにしています。

14. Swift では段落属性に NSParagraphStyle を使います。 といっても NSParagraphStyle はプロパティー値を変更できないクラスなので、実際は Mutable なクラスである NSMutableParagraphStyle を使います。

15. 次のようなコードで範囲を選択してフォント属性を付与します。

let mAttributedString = NSMutableAttributedString()
mAttributedString.append(NSAttributedString(string: "HelveticaNeue ", attributes: [
    .font: UIFont(name: "HelveticaNeue", size: 16),
]))
mAttributedString.append(NSAttributedString(string: "Courier", attributes: [
    .font: UIFont(name: "Courier", size: 16),
]))

16. 次のようなコードで範囲を選択して段落属性を付与します。 本文で書いている通り、テキストの後半に付与した段落属性は無視されます。

func makeParagraphStyle(withIndent indent: CGFloat) -> NSParagraphStyle {
    let mParagraphStyle = NSParagraphStyle.default.mutableCopy() as! NSMutableParagraphStyle
    mParagraphStyle.headIndent = indent
    mParagraphStyle.firstLineHeadIndent = indent
    return mParagraphStyle
}

let mAttributedString = NSMutableAttributedString()
mAttributedString.append(NSAttributedString(string: "テキストの前半 ", attributes: [
    .paragraphStyle: makeParagraphStyle(withIndent: 10),
]))
mAttributedString.append(NSAttributedString(string: "テキストの後半", attributes: [
    .paragraphStyle: makeParagraphStyle(withIndent: 20),
]))

17. Swift で属性付き文字列から特定の属性を取得するには attribute(_:at:effectiveRange:) を使います。 ゼロ幅スペースが range = NSRange(location: N, length: 1) にある場合、右隣の文字の段落属性を取得するサンプルコードは次の通りです:

let location = range.location
let paragraphStyle = attributedString.attribute(.paragraphStyle, at: location, effectiveRange: nil) as! NSParagraphStyle

18. トップパディングに関わるコードを含めてトークンコンテキストのコードを抜粋します。

struct TokenPosition: OptionSet {
    let rawValue: Int

    static let start  = TokenPosition(rawValue: 1 << 0)
    static let middle = TokenPosition(rawValue: 1 << 1)
    static let end    = TokenPosition(rawValue: 1 << 2)
}

final class TokenContext {
    let depth: Int  // デバッグ用
    let paragraphStyle: NSMutableParagraphStyle // インデント用
    var position: TokenPosition
    var marginTop: CGFloat
    var marginBottom: CGFloat

TokenPositionenum ではなく OptionSet で実装しています。 これは Info の要素数が 1 だった場合、TokenPosition.start かつ .end になるからです。