サロゲートペア・結合文字列・合字


サロゲートペア・結合文字・合字 のそれぞれの特徴と、起こすトラブルについて書こうと思う

いずれも文字数を誤認する原因となる。
ちょっと似ているところもあるけど、それぞれぜんぜん違う現象なので、まとめて書いておくといいかなと思って。

まとめ

サロゲートペア 結合文字列 合字 コードポイントのある合字
文字コード 基本UTF-16のみ たぶんUnicodeのみ なんでも たぶんUnicodeのみ
フォント 無関係 ちょっと関係あるかな フォントに依存 ちょっと関係あるかな
誤認形式 char は2個なのに、コードポイントは1個 コードポイントは複数なのに、文字は一個 文字は複数なのに、グリフは一個 コードポイントが 1個なのに文字が複数
Wikipedia サロゲートペア 結合文字 合字 合字

用語

ここではこんな言葉づかいをする。
一般的な用語かどうかはよくわからない。

  • コードポイント
    U+1F436 みたいなの。0〜0x10FFFF のいずれかの値。
  • 文字
    言葉で表現できないけど「動」「ぽ」「ǟ」「æ」なんかは1文字だと思う。「重力」「ちゃ」「ae」「ff」は2文字だと思う。
  • グリフ
    フォントデータベースに入っている文字の形。時々2文字以上の文字列に対応する一個のグリフがあったりする。 macOS に入っている Zapfino というフォントは「Zapfino」という文字列に対する一個のグリフがある。

サロゲートペア

歴史

昔。文字は16bitあれば全部表現できるだろうと思われていた時代。
Windows と Java で。
WCHAR ( wchar_t ) と char を16bit と定めた。
しかし 1996年ごろ。Unicode は文字コードを16bitに収めるのを諦めた。

1996年ぐらい以前は、すべての文字は 16bit整数 1個で表現されていて、それを前提としたソフトウェアがたくさんあった。
でも、1996年ぐらい以降は、文字は1個 または2個 の16bit 整数で表現されるというように仕様が変わったので、それまでに書かれたソフトウェアが数多くのトラブルを起こすようになった。

どういう経緯でそうなったのか知らないんだけど、どうも JavaScript も内部は UTF-16 らしい。しらなかった。

言葉と例

この、16bit整数2個がサロゲートペアで、サロゲートペアで表現される文字のことをサロゲートペア文字と呼んでいる。

サロゲートペア文字は、𩸽(ほっけ) と 𩹉(とびうお)が有名なんじゃないかと思う。

起こるトラブル

文字区切り

「先頭から3文字」のような処理をするときに「先頭から3個の char」のように書くと、サロゲートペアの途中で切られて惨事を起こす。

テキストエディタなどで。サロゲートペア文字のある場所ではカーソルキーを2回押さないと次の文字に行けないというツールがわりとよくあった。一回押した状態でなんか入力するといろいろぶっ壊れたりした。

文字数制限

UI 上は「10文字まで受け入れます」のようになっていても、DBやコード上で「char 10個まで受け入れる」というようになっていると、サロゲートペア文字を含む入力の文字数制限がおかしくなる。

実際、 NTFS 等のファイル名は「255文字まで」と書かれがちだが、実際は「WCHAR 255個まで」なので、サロゲートペア文字を含んでいる場合は 255文字まで入れられない。

UTF-16以外

UTF-8 だけど一文字3バイトまで、というような実装をしてしまったソフトウェアで、UTF-8 なのにサロゲートペアを使っている例がある。

MySQL などがそうであるらしい。

対策

ちゃんとやるしかないと思う。

UTF-32 か UTF-8 が選択可能なら、それを選べば回避できるけど、Java や Windows なら選択可能じゃない場合が多いよね。

結合文字列

Unicode には当初から「単独では使えないけど、前のコードポイントとくっつくと濁点つけたりアクセントつけたりする」というような機能(?)にコードポイントが割り当てられていた。

「た」+「前のコードポイントに濁点をつける」=「だ」

という具合。この「た」に当たる部分は「基底文字」。「前の文字に濁点をつける」に当たる部分は「結合文字」。まとめて「結合文字列」と呼ぶ。

一方、「だ」という最初から濁点がついている文字にもコードポイントが割り当てられている。結合文字列に分解できるような単独のコードポイントの文字のことを「合成済み文字」と呼ぶ。

起こるトラブル

起こるトラブルは、サロゲートペアとよく似ている。

文字区切り

「先頭から3文字」のような処理をするときに「先頭から3個のコードポイント」のように書くと、結合文字の前で切られて惨事を起こす。

文字数制限

UI 上は「10文字まで受け入れます」のようになっていても、DBやコード上で「コードポイント 10個まで受け入れる」というようになっていると、サロゲートペア文字を含む入力の文字数制限がおかしくなる。

実際、 NTFS 等のファイル名は「255文字まで」と書かれがちだが、実際は「WCHAR 255個まで」なので、結合文字列を含んでいる場合は 255文字(のように見える文字数)まで入れられない。

同一性

「だんご」で検索するとして。
文字列中に結合文字列の「だ」を含む「だんご」がある場合。
合成済み文字の「だ」を含む「だんご」で検索してもヒットしない、という事になりやすい。

禁止文字の判定

たとえばファイル名を入力してもらうとして。
ファイル名にはスラッシュは使えないファイルシステムだとして。
『1文字ずつ調べて、スラッシュがあったら「スラッシュ使えませんというメッセージを出して終了」』
という感じの処理を書きがちだと思うんだけど、この「1文字ずつ調べて」で、合成済み文字の1文字なのか、unicode のコードポイントの1文字なのかを間違えるとトラブルになる。

スラッシュの次に「U+20dd」(前の文字を丸で囲む) のような文字を置いたら合成されて「丸で囲まれたスラッシュ」になる。
合成済み文字としての1文字ではなく、Unicode コードポイントを走査してスラッシュを探さなくてはいけない。

対策

ちゃんとやるしかないと思う。

文字区切りや文字数のカウントは、コードポイント数ではなく文字数を数える API を使うようにすればよい。

同一性の問題は、正規化するのが普通。
ただ、正規化の方法はたくさんあって、やりたいことに合わせてちゃんと選ぶ必要がある。難しい。

合字

「合字」はDTP以前からある言葉でいろいろな意味があるんだけど、ここで取り上げるのは二文字以上の文字が一つのグリフになる現象。

最近は FiraCode なんかで活用されている。

具体的には下図の通り

ヒラギノ明朝のような普通のフォントでも合字はあって、「i」の前に「f」が来ると「i」の点が「f」に吸収されたり。「f」が2個並ぶと横棒がつながったうえ、左の「f」が右の「f」により掛かるような感じになる。

Zapfino はなぜか「Zapfino」という文字列専用のグリフがあって見ての通りぜんぜん違う形になる。形だけでなく大きさも変わる。特に高さが大きく増えるのが凶悪な感じ。

起こるトラブル

合字がトラブルになる例は、サロゲートペア・結合文字列 より少ないと思う。

文字区切り

昔の Visual Studio で。
ff が合字になるフォントを指定している場合。

t i f f [backspace]

などと入力したら、最後の backspace で f が2個消えてびっくりしたおぼえがある。

そういう感じのトラブルが起こることがある。

幅の計算

文字列の幅は、各文字の幅の合計と文字間隔の合計を加えたものにはならない。
極端な話、一文字増やすと合字が発生してかえって幅が小さくなることすらありうる。

文字を表示している場合、変更したら再描画する必要があるわけだけど、再描画する必要がある領域がどこなのかを計算する際に合字を考慮する必要がある。

コードポイントのある合字

2022.4.13 追記

合時にはもう一つのパターンがある。
以下がその例。

文字 コードポイント バラバラに書くと
U+FB00 f f
U+FB01 f i
U+FB02 f l
U+FB03 f f i

ff や ffl は常識的な文字数の数え方では 2文字、3文字 だが、合字で合体したあとの形に一個のコードポイントが割り当てられているので、字数よりコードポイントの数が少なくなる。

ruby
%w( shuffle shuffle ).map(&:size)
#=>  [5, 7]

ドイツ語の ß (S+Z)、や英語などの W (V+V または U+U)は1文字だとみなされるが、ffl や ff が 1 文字だとみなされる言語はないんじゃないかと思う。

起こるトラブル

同一性

shuffleshuffle を同一だと見ないたい場合、正規化が必要になる。

文字区切り

普通に区切ると1文字ずつにならない。

ruby
%w( shuffle shuffle ).map(&:chars)
#=> [["s", "h", "u", "ffl", "e"], ["s", "h", "u", "f", "f", "l", "e"]]

これでよければこのままでいいんだけど、 "ffl" じゃだめだろということならなんかする必要がある。
"ffl" を分離して f f l にするとなると、くっつけても元に戻らないことを覚悟する必要がある。

文字数の計算

shuffle を7文字と数えたい場合、なんかする必要がある。

それと。
ここまでの例は

  • char (wchar_t) の数より字数が少ない(サロゲートペア)
  • コードポイントの数より字数が少ない(結合文字列)
  • 文字の数よりグリフが少ない(合字)

と、char (wchar_t) がたくさん集まっても出来上がる文字やグリフは一個という流れだけど、この例は、コードポイントが一個だけど文字が複数、ということでちょっと向きが違う感じ。

最後に

古くは ASCII で等幅フォントしかなかったので、
char 1個で1文字。1文字に1グリフ。だった。
簡単だったけど、漢字も絵文字も出せなかった。

でも今、Unicode で暮らしているので、漢字と絵文字とタイ文字が混じった文章を書いたりできる。便利。

その代わり。
WCHAR ( char ) 一個で一文字ではないし。
コードポイント一個で一文字でもない。
一文字でグリフ一個でもない。

めんどくさいけど仕方ない。