結合文字

直前の基底文字に結合して表示される Unicode 文字。ダイアクリティカルマークや濁点などが該当する。

結合文字 (Combining Character) とは、単独では表示されず、直前の基底文字に結合して一つの文字として表示される Unicode 文字です。結合用ダイアクリティカルマーク (U+0300 から U+036F) が代表的で、アクセント記号、ウムラウト、セディーユなどが含まれます。

たとえば、ラテン文字「a」(U+0061) に結合用アキュートアクセント (U+0301) を続けると「á」として表示されます。日本語では、結合用濁点 (U+3099) や結合用半濁点 (U+309A) が該当し、「か」+ U+3099 で「が」を表現できます。タイ語やアラビア語など、結合文字を多用する言語も多くあります。結合用ダイアクリティカルマークのブロックは U+0300 から U+036F の 112 文字ですが、結合記号として分類される文字は Unicode 全体では 2,000 を超えます。

結合文字の存在により、見た目が同じ文字でもコードポイント列が異なる場合があります。「á」は合成済み文字 U+00E1 (NFC 形式) としても、基底文字 U+0061 + 結合文字 U+0301 (NFD 形式) としても表現できます。Unicode 正規化 (NFC で合成、NFD で分解) を使って統一的に扱う必要があり、文字列比較の前に正規化を行わないと、見た目が同じ文字列が「不一致」と判定される問題が発生します。ただし NFC が必ず 1 文字にまとめてくれるとは限りません。「か」に U+3099 を続けた文字列は NFC で U+304C (が) に合成されますが、半角カタカナ「カ」に半角の濁点 U+FF9E を続けた文字列は NFC では 2 文字のまま残り、全角の「ガ」(U+30AC) になるのは互換文字も変換する NFKC を使ったときだけです。どの形式を選ぶかは、半角と全角を同じものとして扱いたいかどうかで決まります。実務で踏みやすいのはファイル名です。macOS の旧ファイルシステムである HFS+ はファイル名の結合文字を分解した形へ揃えていたため、Windows や Linux で作られた合成形のファイル名と、見た目が同じでもコードポイント列が一致しませんでした (現行の APFS は与えられた形をそのまま保存し、どちらの形でも同一のファイルとみなします)。Git にはこの差を吸収する core.precomposeUnicode という設定があり、分解形の名前が混ざる環境で有効にしておかないと同じファイルが別名として二重に現れることがあります。

結合文字は複数重ねることも可能です。1 つの基底文字に複数の結合文字を付加して、上にアクセント、下にセディーユといった複合的な修飾を表現できます。極端な例として、1 つの基底文字に数十の結合文字を重ねて上下にはみ出させる「ザルゴテキスト」と呼ばれる表現があり、投稿欄やチャットの行間を壊す嫌がらせに使われます。Unicode 側にも歯止めの考え方があり、正規化の仕様では、連続する結合文字が 30 文字を超えない形を安全に処理できる形式として定義しています。ユーザー入力を受け付ける側でも、これに合わせて連続する結合文字の本数に上限を設け、超えた分は保存時に落とすのが現実的な対処です。

セキュリティの観点では、結合文字を悪用した視覚的なスプーフィング (見た目は同じだがコードポイント列が異なる文字列) が問題になります。ユーザー名やドメイン名の検証では、正規化してから比較するのが前提です。ただし正規化で吸収できるのは同じ文字の表記ゆれだけです。ラテン文字の「a」をキリル文字の「а」に置き換えるような、別の文字を使ったなりすましは正規化しても残ります。こちらは、1 つの語の中で複数の文字体系が混ざっていないかを見る、という別の検査で扱います。

文字数カウントの観点では、結合文字は独立したコードポイントとしてカウントされるため、String.length の値と見た目の文字数が一致しません。「á」が NFD 形式の場合、コードポイント数は 2 ですが見た目は 1 文字です。書記素クラスタ単位でのカウントが、ユーザーの期待に最も近い結果を返します。JavaScript では Intl.Segmentergranularity: 'grapheme' で使うとこの単位で数えられます。入力欄の上限判定では、分解形のまま送られてきた「á」が 2 文字と数えられ、利用者から見て理由のない文字数超過が起こります。受け取った時点で NFC に正規化してから数えるようにすれば、この種の食い違いはほぼ消えます。

この記事を共有