絵文字が半分だけ表示される、または消える — サロゲートペアの罠
プレイヤー名やチャットメッセージに絵文字が1つ含まれているだけで、何かがおかしくなる。絵文字が2つの断片に分かれて表示される、跡形もなく消えて隙間だけ残る、あるいは余裕があるはずの文字数制限に引っかかって拒否される。どれもフォントの問題には見えません。その絵文字はゲーム内の他の場所では正常に表示されているからです。共通の原因は、どこかのコードが文字列を誤った単位で数えたり切ったりしていることです。
この問題を正確に理解するには、普段は曖昧に使われがちな用語を区別する必要があります。コードポイント、UTF-16コード単位、書記素クラスタ(grapheme cluster)はそれぞれ別物で、このバグは常に「コードがどれを前提にしているつもりだったか」の食い違いから生まれます。
コードポイントとUTF-16コード単位はなぜ違うのか
Unicodeのすべての文字はコードポイント、つまりそれを識別する番号を持っています。多くのラテン文字、かな、CJK文字のコードポイントは16ビット1単位に収まります。しかし多くの絵文字や一部の珍しいCJK文字はその範囲の外にあり、1つのコードポイントを表現するのに16ビット単位が2つ必要になります。これをサロゲートペアと呼びます。
JavaScriptの文字列の length やインデックスベースの操作(charAt、インデックスによる切り出し)は、コードポイントでも見た目の文字数でもなく、UTF-16コード単位を数えています。そのためサロゲートペアを必要とする絵文字1つは length が2と報告され、インデックス1で切り出すとペアの真ん中で分断してしまい、そもそも文字の境界になっていません。
const s = "🎮"; s.length // 2 — サロゲートペア1組であり、1文字ではない s.slice(0, 1) // "\ud83c" — ペアの半分。壊れたグリフとして描画される [...s].length // 1 — コードポイント単位で走査すれば正しい
書記素クラスタ — コードポイントよりさらに上の層
コードポイント単位で走査するだけでも十分でない場合があります。1文字に見える絵文字の多くは、実はゼロ幅結合子(ZWJ)でつながれた複数のコードポイントの並びであったり、基底の絵文字に異体字セレクタや肌の色を指定する修飾コードポイントが続いたものだったりします。たとえば家族の絵文字は、複数の人物絵文字がZWJで結合されて1つの見た目のグリフになっています。画面上で人が「1文字」として認識する単位を書記素クラスタと呼び、これは複数のコードポイントにまたがることがあり、そのコードポイントそれぞれがさらに複数のUTF-16単位にまたがることもあります。
同じ書記素クラスタに属する2つのコードポイントの間で文字列を切っても、エラーにはなりません。しかし結合の並びは壊れます。結果として、1つに結合されるはずの絵文字が2つ、3つの別々の絵文字として並んで表示されたり、付ける相手を失った異体字セレクタだけが残ったりします。
- コードポイント: Unicode文字1つを表す番号
- UTF-16コード単位: JavaScriptの文字列が実際にインデックス付けと計測に使う16ビット単位。1コードポイントはこれ1つまたは2つ分
- 書記素クラスタ: 読み手が「1文字」として知覚する単位。ZWJや修飾子で結合された複数のコードポイントから構成されることがある
単純な部分文字列取得や切り詰めが壊れる理由
文字列をインデックスで切って「N文字に切り詰める」処理や、length で文字数制限を測る処理はすべて、暗黙に「1コード単位=1文字」を前提にしています。この前提は普通のASCII文字や大半のCJKテキストでは成り立つため、このバグは誰かが絵文字でテストするまで気づかれずにリリースされ、QAの検出ではなくプレイヤーからの問い合わせとして発覚することが非常に多いのです。
// ネームの文字数制限を単純に切り詰める処理
function truncate(str, max) {
return str.slice(0, max); // サロゲートペアやZWJの並びの途中で切れる
}
truncate("Player🎮Name", 7)
// 絵文字のサロゲートペアの途中で切れ、壊れたグリフになることがある安全に数え、安全に切る方法
対処法は、UTF-16コード単位でインデックスを扱うのをやめ、書記素クラスタを理解する操作に置き換えることです。JavaScriptの Intl.Segmenter を granularity に grapheme を指定して使うと、文字列を見た目の1文字ずつ走査でき、サロゲートペア、ZWJの並び、修飾子を正しく1つのまとまりとして扱えます。文字数のカウントも切り詰めも、これによって自動的に正しくなります。
- 絵文字やその他の補助面の文字を含みうるテキストは、生のインデックスで切り出したり切り詰めたりしない
- プレイヤー向けテキストの文字数カウントと切り詰めには Intl.Segmenter(grapheme粒度)を使う
- ゲームプレイやUI上の理由で文字数制限が必要な場合は、コード単位ではなく書記素クラスタ単位で適用する
const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
const graphemes = [...segmenter.segment("Player🎮♂️Name")].map((s) => s.segment);
graphemes.length // 正しい見た目の文字数
graphemes.slice(0, 7).join("") // 安全な切り詰め — 絵文字が分断されない予防策
文字数制限があるフィールド(プレイヤー名、チャット、カスタム称号など)のテストデータに、単体の絵文字だけでなくZWJの並びや肌の色修飾付きの絵文字も含めます。切り詰め・保存・再描画を経ても分断されずに往復するなら、そのフィールドは安全です。この種の入力で分断されるなら、プレイヤーの前でも同じように分断されます。しかもサロゲートの分断は多くの場合、静かに失敗するのではなく目に見える代替グリフとして表示されるため、ゲーム内では単体テストで見るよりずっと壊れて見えます。