UTF-8を自力でデコードする
キーバインドを一覧する CLI ツールをつくっています。左からソース、モード、キー、説明の 4 列で、説明が長いときは末尾を ·· に置き換えて切り詰めます。
列を揃えるには、その文字列が端末で何カラム分になるかを知る必要があります。ASCII なら 1 カラム、日本語なら 2 カラム。バイト数とも文字数とも違う数え方です。
概算でいいと決めた
最初に書いたのはこんな関数でした。
- 0x80 未満のバイト(ASCII)なら 1 カラム
- それ以外は 2 カラム
正確ではありません。ただ、この幅を使うのは最終列を切り詰めるときだけです。実際より大きく見積もったところで、切れる位置が数桁早くなるだけで、行がはみ出して折り返すことはありません。ずれるにしても安全な方向なので、当面これでいくことにしました。
通ったのに意味のなかったテスト
テストを書きます。ASCII が 1、日本語が 2 は素直に書けました。あとは非 ASCII なのに半角、というケースが欲しい。半角カタカナです。
{"半角カタカナ", "ァ", 2},
通りました。が、これは半角カタカナではありません。全角の小書きのアです。打ったつもりの ァ と、実際に入っていた ァ を取り違えていました。
並べて書けば違いは分かりますが、片方だけ置かれると自分には見分けがつきません。
ァ ァ
エディタの中でも、テストの差分の中でも同じです。通っていたテストは、狙ったケースを一つも踏んでいませんでした。
バイト列を手で分解する
どちらがどちらなのか、バイト列を見るしかありません。UTF-8 のデコードを手でやりました。
3 バイト文字はこういう形です。
1110xxxx 10xxxxxx 10xxxxxx
先頭バイトの上位ビットで長さが決まり、後続バイトは 10 で始まります。x の部分をつなげたものがコードポイントになります。
ァ は E3 82 A1 でした。
11100011 10000010 10100001
0011 000010 100001
→ 0011 000010 100001
→ 0x30A1
ァ は EF BD A7。
11101111 10111101 10100111
1111 111101 100111
→ 1111 111101 100111
→ 0xFF67
U+30A1 と U+FF67 で、当然ながら別の文字でした。そしてどちらも 3 バイトです。
バイト長からは幅が分からない
ここで自分の勘違いに気づきました。
| バイト数 | 文字数 | 表示幅 | |
|---|---|---|---|
ァ U+30A1 | 3 | 1 | 2 |
ァ U+FF67 | 3 | 1 | 1 |
「非 ASCII は 3 バイト、3 バイトなら全角」と無意識につないでいました。前半はだいたい合っていますが、後半が成り立ちません。半角カタカナは 3 バイトで 1 カラムです。
妥協をテストに書く
正しくは ァ は 1 カラムなので、書きたかったテストは本来落ちるはずのものでした。打ち間違えたおかげで緑になり、関数の穴もそのまま隠れていたことになります。
直すかどうか少し考えて、直さないことにしました。用途は最終列の切り詰めだけで、多めに見積もるぶんには壊れません。代わりに、妥協したことをテスト名に書いておきました。
{"半角カタカナは幅2として扱う", "ァ", 2},
文字自体も string(rune(0xFF67)) で書くようにしました。ソースに直接置くと、また見分けがつかなくなります。
このテストは正しい幅を検証していません。どう割り切ったかを残しているだけです。あとで読み返した自分がバグだと思って直しにかかったときに、一度止まってもらえれば十分です。
真面目にやるなら
表示幅は Unicode の East Asian Width(UAX #11)で定義されていて、Wide や Narrow のほかに Ambiguous という区分があります。曖昧に分類された文字は端末の実装しだいで 1 にも 2 にもなるので、規格を読んでも最後は決まりません。
Go なら mattn/go-runewidth がこの判定と Ambiguous の扱いの切り替えまで面倒を見てくれます。正確さが要るようになったら入れます。
今回はそこまで必要ないと判断して、雑なままにしています。とはいえ雑でいいと決めるには何を捨てているのかを知る必要があって、手でバイトを分解したのはそのためでした。