ツイートの文字数を **厳密に** 数える方法

## 概要

Twitter API 経由でツイートする場合には、リクエストを送る前に字数制限を越えていないか検証する必要があります。
しかし Twitter の文字数カウントは独自の複雑な仕様になっており、ナイーブな方法（言語標準の `length` など）では正しい結果が得られません。

そこで本記事では以下の内容を取り上げます。

- 文字数カウントに伴う難点の説明
- ツイートの長さを **厳密に** 数えられる公式ライブラリ [twitter-text][twitter-text] の紹介
- 他言語に `twitter-text` を移植するために必要な情報のまとめ
- Python 3 用の自作パッケージ [twitter-text-python](https://github.com/swen128/twitter-text-python) の紹介


## この記事が想定する読者

- Twitter bot などを作っていてツイートの文字数カウントが必要な人
- その他の状況で文字数カウントの沼にハマった人
- 文字コードについて手を動かしながら学ぶための題材が欲しい人


## 文字数カウントに伴う難点

### 一般的な問題

#### 文字コードの区切り方
私たちが普段目にしている文字列は、内部的にはバイトで表現される整数の列です。
この整数列を「一文字ごと」に区切るための方法が状況によって複数パターン考えられ、それぞれ異なる「文字数」になってしまいます。

現在広く普及している Unicode の場合、「一文字ごと」の区切り方の候補として次のようなものが考えられます。（このあたりの概念については、記事[『Unicodeとは？ その歴史と進化、開発者向け基礎知識』](https://www.buildinsider.net/language/csharpunicode/01)に簡潔にまとまっているので一読をお勧めします。）

* UTF-8 code unit
* UTF-16 code unit
* Unicode codepoint (= UTF-32 code unit)
* [Grapheme Cluster][grapheme] (書記素クラスタ)

また、 codepoint で区切る際には事前に [Normalization Form][UNF] に変換するか否かも重要です。
例えば `é` という字には 2 種類の同値な表現方法があり、それぞれ codepoint 列の長さが異なります。

- 通常のアルファベット `e` (U+0065) とアクセント `◌́` (U+0301) の列
- 合成済みの文字 `é` (U+00E9)

前者のように結合文字を分解した形式を Normalization Form D (NFD) と呼び、反対に合成した形式を Normalization Form C (NFC) と呼びます。


#### 区切り方による長さの違いを示す具体例

ascii 文字だけに閉じている場合 `Grapheme = Unicode codepoint = UTF code unit` が成立するため、何も難しいことなど無いように見えます。

| 分割単位        | 分割された列 | 長さ |
|-----------------|--------------|------|
| Grapheme        | A            | 1    |
| codepoint (NFC) | U+0041       | 1    |
| codepoint (NFD) | U+0041       | 1    |
| UTF-16 BE       | 0x0041       | 1    |
| UTF-8           | 0x41         | 1    |

一方で結合文字を含む場合 `Grapheme = Unicode codepoint` は一般に成立しません。しかし NFC に変換すれば一致する**こともあります**。

<table>
  <tr>
    <th>分割単位</th>
    <th colspan="3">分割された列</th>
    <th>長さ</th>
  </tr>
  <tr>
    <td>Grapheme</td>
    <td colspan="3">é</td>
    <td>1</td>
  </tr>
  <tr>
    <td>codepoint (NFC)</td>
    <td colspan="3">U+00E9</td>
    <td>1</td>
  </tr>
  <tr>
    <td>codepoint (NFD)</td>
    <td>U+0065</td>
    <td colspan="2">U+0301</td>
    <td>2</td>
  </tr>
  <tr>
    <td>UTF-16 BE</td>
    <td>0x0065</td>
    <td colspan="2">0x0301</td>
    <td>2</td>
  </tr>
  <tr>
    <td>UTF-8</td>
    <td>0x65</td>
    <td>0xCC</td>
    <td>0x81</td>
    <td>3</td>
  </tr>
</table>

次は特に複雑な絵文字の例です。 NFC に変換しても変わらず `Grapheme ≠ codepoint` であり、おまけに各 code point は UTF-16 の [surrogate pair][surrogate] で表現されているため `codepoint = UTF-16 code unit` すら成り立ちません。

<table>
  <tr>
    <th>分割単位</th>
    <th colspan="8">分割された列</th>
    <th>長さ</th>
  </tr>
  <tr>
    <td>Grapheme</td>
    <td colspan="8">👏🏽</td>
    <td>1</td>
  </tr>
  <tr>
    <td>codepoint (NFC)</td>
    <td colspan="4">U+0001F44F</td>
    <td colspan="4">U+0001F3FD</td>
    <td>2</td>
  </tr>
  <tr>
    <td>codepoint (NFD)</td>
    <td colspan="4">U+0001F44F</td>
    <td colspan="4">U+0001F3FD</td>
    <td>2</td>
  </tr>
  <tr>
    <td>UTF-16 BE</td>
    <td colspan="2">0xD83D</td>
    <td colspan="2">0xDC4F</td>
    <td colspan="2">0xD83C</td>
    <td colspan="2">0xDFFD</td>
    <td>4</td>
  </tr>
  <tr>
    <td>UTF-8</td>
    <td>0xF0</td>
    <td>0x9F</td>
    <td>0x91</td>
    <td>0x8F</td>
    <td>0xF0</td>
    <td>0x9F</td>
    <td>0x8F</td>
    <td>0xBD</td>
    <td>8</td>
  </tr>
</table>

[grapheme]: http://www.unicode.org/reports/tr29/#Grapheme_Cluster_Boundaries
[UNF]: https://www.unicode.org/reports/tr15
[surrogate]: http://www.unicode.org/versions/Unicode12.0.0/ch03.pdf#page=48


### Twitter 特有の問題

ツイートの文字数カウントにおいては、 NFC に変換された文字列の Unicode codepoint を基本的な処理単位としますが、 URL および絵文字は例外的に扱われます。また、文字の種類によって異なる重みが付けられる点も重要です。


#### URL を常に一定の文字数としてカウント

ツイートの中に URL が含まれる場合、その URL の文字列としての長さは無視され、常に定数分の長さとしてカウントされます。

この仕様を再現するために、「ツイートの中から URL を抽出」というまた別の問題が発生します。 Twitter の URL 抽出処理には、様々なコーナーケースを考慮して作られた独自の正規表現が使われているため、その挙動を正確に再現しなければいけません。

例えば次のようなツイート

```
日本語http://example日本語.jp/path/index.html日本語
```

があったとき、 Twitter のテキストパーサーは `http://example日本語.jp/path/index.html` を URL として抽出します。
一方、[適当に Stack Overflow から拾ってきた下記の正規表現](https://stackoverflow.com/a/3809435)で検索すると結果が異なり、何もマッチしません[^naive-regex]。

```plaintext:URLの正規表現（？）
https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)
```

[^naive-regex]: 見ればすぐにわかりますが、この正規表現は ascii 文字のドメインにしか対応していません。


#### 絵文字を常に一定の文字数としてカウント

Grapheme ではなく Unicode codepoint を処理単位とすると、前述した例のように複数の codepoint から成る絵文字に対処できません。

そのため、 [Unicode が定める絵文字のリスト](https://www.unicode.org/reports/tr51/#emoji_data)をもとに正規表現を作成して、絵文字に対応する codepoint 列を抽出します。


#### 文字の種類によって異なる重みを割り当て

長い間ツイートの文字数上限は 140 字でしたが、 [2017 年 9 月の仕様変更][twitter-blog]により 280 字に引き上げられました。
と言っても、影響を受けるのは主にアルファベットなどで構成されるツイートに限られ、日本語や中国語・韓国語などは依然として 140 字に制限されます。

このような変更がされた理由は、言語によって 1 文字あたりの情報量に大きな差があるためです。例えば日本語と英語を比較すると、同じ内容の文を記述するために必要な文字数が 2 倍ほど違います。

新仕様でツイートの長さ (Weighted Length) を測る際には、文字の種類によって異なる重みを割り当てて総和を取ります。
正確には、文字の重みは Unicode code point の特定範囲に割り当てられる整数値で、例えばアルファベットなら重み 1 、 CJK 文字なら重み 2 が割り当てられます。

```
"abc" -> 長さ 3

"日本語" -> 長さ 6

"日本語abc" -> 長さ 9
```

[twitter-blog]: https://blog.twitter.com/official/en_us/topics/product/2017/Giving-you-more-characters-to-express-yourself.html


## twitter-text ライブラリ

幸いなことに、ツイートの文字数カウントを Java, JavaScript, Ruby, Objective-C で正確に実装したライブラリ [twitter-text][twitter-text] が公式に公開されています。

このライブラリの主要な機能は `parseTweet` API で、文字列を引数に取りパース結果を返します。パース結果は下記の要素をもつオブジェクトです：

- `weightedLength`: ツイートの長さを表す非負整数値
- `permillage`: ツイートの長さと上限値の比を表す 0~1000 の整数値
- `isValid`: 妥当なツイートかを表す真偽値
- `displayTextRange`: 文字列全体の始端と終端のインデックス
- `validDisplayTextRange`: ツイートとして妥当な部分文字列の始端と終端のインデックス

例として、次の文字列を `parseResult` に入力した場合：

```
日本語http://example日本語.jp/path/index.html日本語 👪
```

以下のパース結果が得られます：

```json
{
    "weightedLength": 38,
    "permillage": 135,
    "isValid": true,
    "displayTextRange": [0, 44],
    "validDisplayTextRange": [0, 44]
}
```

なお twitter-text には `parseResult` 以外にもハッシュタグ抽出などの機能がありますが、本記事では扱いません。


## twitter-text を他言語に移植する

上記 4 つの言語以外を使っていて、ツイートの文字数カウントが必要になる場合があるかもしれません。（私の場合、実際に Python で Twitter bot を作成していた時に必要になりました。）

このライブラリは Twitter サーバーでのテキスト処理を正確に模倣できて初めて意味をなすので、他言語に移植する際にはオレオレ実装にならないよう特に細心の注意を払うべきです。そこで、正確な実装のために必要なテストケースと仕様の定義に関する情報を以下にまとめました。


### テストケース

[twitter-text][twitter-text] ライブラリには [YAML で記述された多数のテストケース][conformance]が付属しており、特定の言語に依存せず利用できるようになっています。これは `parseTweet` などの API を単体テストするためのもので、各テストケースは `(説明文, 入力, 正解出力)` の三つ組で表されます。

`twitter-text` を実装する時に真っ先にすべきなのは、この YAML ファイルを読み込んでテストを自動実行する仕組みを作ることです。


### 仕様の定義

[twitter-text][twitter-text] に関して、特定の言語に依存しない形で正確に定義された仕様は公開されていません。。そのため、主に Java のコードを見て私が推測した仕様を記載します。

#### Weighted Length

参照：[Java 実装における `parseTweet` 関数の定義](https://github.com/twitter/twitter-text/blob/master/java/src/main/java/com/twitter/twittertext/TwitterTextParser.java#L94-L206)

- `raw_text` の Unicode Normalization Form C (NFC) を `normalized_text` とする
- `normalized_text` を要素に分割
    - URL を抽出（後述）
    - URL 以外の部分から絵文字を抽出（後述）
    - URL と絵文字以外の部分から Unicode codepoint を抽出
- 各要素に重みを割り当てる
    - URL : [transformedURLLength](https://github.com/twitter/twitter-text/tree/master/config#transformedurllength)
    - 絵文字 : [defaultWeight](https://github.com/twitter/twitter-text/tree/master/config#defaultweight)
    - codepoint : 属する [range](https://github.com/twitter/twitter-text/tree/master/config#ranges) によって変化
- 全ての重みを加算して [scale](https://github.com/twitter/twitter-text/tree/master/config#scale) で割った値を `weighted_length` とする


#### URL 抽出

参照：[Java 実装における `extractUrlsWithIndices` 関数の定義](https://github.com/twitter/twitter-text/blob/master/java/src/main/java/com/twitter/twittertext/Extractor.java#L320-L373)

* 下記の正規表現パターン `extract-url` で検索
* マッチした文字列のうち、パターン `url` または `tco-url` にマッチする部分を URL とする
* ただし URL は以下の条件を満たさなければならない
    * 「`protocol` が存在」または「`preceding-chars` が `invalid-characters` にマッチしない」
    * ドメインを punycode 変換した時の URL 全体の長さが 4096 以下
    * `tco-slug` の長さが 40 以下

```
invalid-characters = [\u{FFFE} \u{FEFF} \u{FFFF}]

directional-characters = [\u{061C} \u{200E} \u{200F} \u{202A}-\u{202E} \u{2066}-\u{2069}]

laten-accents-chars = [\u{00C0}-\u{00D6} \u{00D8}-\u{00F6} \u{00F8}-\u{00FF} \u{0100}-\u{024F}]
                   || [\u{0253} \u{0254} \u{0256} \u{0257} \u{0259} \u{025B}]
                   || [\u{0263} \u{0268} \u{026F} \u{0272} \u{0289} \u{028B} \u{02BB}]
                   || [\u{0300}-\u{036F} \u{1E00}-\u{1EFF}]

cyrillic-chars = [\u{0400}-\u{04FF}]

chars = [a-z0-9] || laten-accents-chars

general-path-chars = [a-z 0-9 ! * ' ; : = + , . $ / % # _ ~ & @ | - \[ \] \u{2013}]
                  || latin-accents-chars
                  || cyrillic-chars

parens-one-nested = "(" general-path-chars+ ")"

parens-two-nested = "(" general-path-chars* parens-one-nested general-path-chars* ")"

parens = parens-one-nested | parens-two-nest

punycode = "xn--" [- 0-9 a-z]+

subdomain = (chars (chars || [- _])*)? chars "."

domain-name = (chars (chars || [-])*)? chars "."

punctuation-chars = [- _ ! \ " # $ % & ' ( ) \[ \] { } * + , . / : ; < = > ? @ ^ ` | ~]

unicode-chars = [^ \s \p{Z} \p{InGeneralPunctuation}] -- punctuation-chars

unicode-domain = (unicode-chars (unicode-chars || [-])*)? unicode-chars "."

domain = subdomain* domain-name (gtld | cctld | punycode)
       | (←protocol) domain-name cctld
       | (←protocol) unicode-domain (gtld | cctld)
       | domain cctld (→"/")

protocol = "http" s? "://"

port = [0-9]+

path-ending-chars = [a-z 0-9]
                  | [_ # / - +]
                  | latin-accents-chars
                  | cyrillic-chars
                  | parens

path-normal = general-path-chars*
              (parens general-path-chars*)*
              path-ending-chars

path-with-atmark = @ general-path-chars /

path-fragment = path-normal | path-with-atmark

path = "/" path-fragment*

query-chars = [a-z 0-9]
            | [! ? * ' ( ) ; : & = + $ / % # - _ . , ~ | @]
            | "[" | "]"

query-ending-chars = [a-z 0-9 - _ & = # /]

query = "?" query-chars* query-ending-chars

url = protocol? domain (":" port)? path? query?

tco-slug = [a-z 0-9]+

tco-url = ^ protocol "t.co/" tco-slug query?

preceding-char = [^ a-z 0-9 @ ＠ $ # ＃] -- invalid-characters
               | directional-characters
               | ^

extract-url = preceding-char url
```


#### 絵文字抽出

参照：[Java 実装における `TwitterTextEmojiRegex` の定義](https://github.com/twitter/twitter-text/blob/master/java/src/main/java/com/twitter/twittertext/TwitterTextEmojiRegex.java)

[UTS #51: Unicode emoji](https://www.unicode.org/reports/tr51/) が定める [RGI set](https://www.unicode.org/reports/tr51/#def_rgi_set)[^RGI] に含まれる絵文字を抽出する。

[^RGI]: RGI は recommended for general interchange の略


#### config

https://github.com/twitter/twitter-text/tree/master/config

Weighted Length の計算に関わるパラメーターを指定する。この記事の執筆時点で以下の 3 つのバージョンがあり、最新版は `v3`。

- `v1` : [2017 年 9 月の仕様変更][twitter-blog]以前の設定
- `v2` : Weighted Length を導入
- `v3` : 絵文字抽出処理を導入


## twitter-text-python

Python にも [twitter-text-python](https://github.com/edmondburnett/twitter-text-python) というパッケージがありますが、これは公式が提供するテストケースをパスしていないため、Twitter の文字数カウントと結果が一致する保証はありません。

というわけで、**新たに Python 3 用のパッケージ [twitter-text-parser](https://github.com/swen128/twitter-text-python) を自作して公開しました**。公式の JavaScript 実装をできる限り忠実に移植し、テストを全てパスしているため正確性は申し分ありません。


## おまけ - Twitter テキストパーサーの穴を突く

### Grapheme Cluster

Twitter のテキストパーサーは Unicode codepoint を基本的な処理単位としますが、結合文字に対処するために NFC への変換と絵文字の抽出を行います。

しかし世の中にはこのどちらでも対処できないタチの悪い Grapheme Cluster が存在します。

`ẛ̣` という文字は 2 つの codepoint `ẛ` (U+1E9B) と `◌̣` (U+0323) で構成され、 NFC に変換しても元のままです。この 2 つの codepoint の重みはそれぞれ 2 と 1 なので、 `ẛ̣` の Weighted Length は 3 になります。つまり、 `280 // 3 = 93` 回 `ẛ̣` を打ち込むと字数上限に達します。

<blockquote class="twitter-tweet"><p lang="und" dir="ltr">ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣</p>&mdash; PND (@__P_N_D__) <a href="https://twitter.com/__P_N_D__/status/1157517207561129985?ref_src=twsrc%5Etfw">August 3, 2019</a></blockquote>


### t.co URL の例外処理

ツイートから URL を抽出する仕様の「パターン `extract-urls` にマッチした文字列のうち、パターン `url` または **`tco-url` にマッチする部分**を URL とする」に注目してみます。

例えば次の文字列

```
https://t.co/aBKlrUWVcb/https://www.example.com
```

をツイートすると、パターン `extract-urls` が全体にマッチし、そのうち前半の https://t.co/aBKlrUWVcb だけが `tco-url` にマッチして URL として抽出されます。そして、残った `https://www.example.com` の部分は**無視され URL として扱われません**。

<blockquote class="twitter-tweet"><p lang="und" dir="ltr"><a href="https://t.co/aBKlrUWVcb">https://t.co/aBKlrUWVcb</a>/https://www.example.com</p>&mdash; PND (@__P_N_D__) <a href="https://twitter.com/__P_N_D__/status/1158260513693790210?ref_src=twsrc%5Etfw">August 5, 2019</a></blockquote>

普通、ツイートの中の URL は全てパースされ強制的に t.co でラップされますが、この仕様を突くとパースを回避して URL をそのままツイートの中に書き込むことができます。


## まとめ

文字数のカウントは、一見簡単そうに思えて非常に奥が深い問題です。特にツイートの長さを計算する場合は、実際に絵文字などが高頻度で使われるため、厄介な問題が無視できないものとして表面化してきます。

自分の好きな言語で twitter-text を実装すると、便利なライブラリが一つ増えるだけでなく、 Unicode に対する理解が深まるので、一度試してみてはいかがでしょうか。


## 参考リンク

### Unicode Standard

- [UAX #15: Unicode Normalization Forms](https://www.unicode.org/reports/tr15/)
- [UAX #29: Unicode Text Segmentation](http://www.unicode.org/reports/tr29/)
- [UTS #51: Unicode Emoji](https://www.unicode.org/reports/tr51/#emoji_data)
- [FAQ - Characters and Combining Marks](https://unicode.org/faq/char_combmark.html)


### Unicode 関連の解説記事

- [Unicodeとは？ その歴史と進化、開発者向け基礎知識 - Build Insider](https://www.buildinsider.net/language/csharpunicode/01)
- [The 7 Ways of Counting Characters - LINE Engineering](https://engineering.linecorp.com/en/blog/the-7-ways-of-counting-characters/)
- [Unicodeのgrapheme cluster (書記素クラスタ) | hydroculのメモ](https://hydrocul.github.io/wiki/blog/2015/1025-unicode-grapheme-clusters.html)
- [絵文字がある種のUnicodeバグを世界から一掃しつつある件について｜Rui Ueyama｜note](https://note.mu/ruiu/n/nc9d93a45c2ec)
- [「文字数」ってなぁに？〜String, NSString, Unicodeの基本〜 \- Qiita](https://qiita.com/takasek/items/19438ecf7e60c8d53bbc)
- [絵文字を支える技術の紹介 - Qiita](https://qiita.com/nonanona/items/b148c212ba7c24942e93)


### twitter-text

- [twitter/twitter-text: Twitter Text Libraries](https://github.com/twitter/twitter-text)
- [twitter-text Parser — Twitter Developers](https://developer.twitter.com/en/docs/developer-utilities/twitter-text.html)
- [Counting characters — Twitter Developers](https://developer.twitter.com/en/docs/basics/counting-characters.html)


[twitter-text]: https://github.com/twitter/twitter-text
[conformance]: https://github.com/twitter/twitter-text/tree/master/conformance