チェック・トラブル対処Read this article in English

絵文字で文字数がずれる・半分だけ表示される — サロゲートペアと書記素

プレイヤーが名前欄に絵文字を1つ入れただけで、どこかがおかしくなる。絵文字のあった場所に黒いひし形の記号が出る、あるいは絵文字が消えている。16文字までの制限が、8文字しか見えていない名前を弾く。これはフォントの問題ではなく、データベースの文字コードの問題でもありません。どこかのコードが、プレイヤーに見えている単位とは別の単位で文字列を数えたり切ったりしています。

関係する単位は4つあり、絵文字ではその4つがすべて食い違います。この種のバグはすべて、どこかの関数が「この4つのうち2つは同じ数だ」と思い込んだ結果です。この記事では4つの単位を実測し、切り詰めが壊れる7パターン、言語ごとにlengthが何を数えているか、許可範囲の決め方を扱います。

絵文字1文字を4つの単位で測ると全部違う

まず4つの単位を分けます。

  • コードポイント: Unicodeが文字に割り当てた番号。U+FFFFより上が補助面(サプリメンタリプレーン)で、絵文字の多くはここにあります。
  • UTF-16コード単位: JavaScript、Java、C#が内部で文字列を保持する16ビットの枠。U+FFFFより上のコードポイントは枠2つを使い、この2枠1組をサロゲートペアと呼びます。
  • 書記素クラスタ: 読み手が「1文字」と呼ぶ単位。複数のコードポイントにまたがることがあり、境界の位置はUnicodeのテキスト分割規則で定義されています。
  • UTF-8バイト: 通信とファイルで実際に流れる形。1コードポイントあたり1〜4バイトです。
文字列                             .length  コードポイント  書記素  UTF-8バイト
"A"                                     1            1          1            1
"あ"  U+3042                             1            1          1            3
"😀"  U+1F600                            2            1          1            4
"👍🏽"  U+1F44D U+1F3FD                    4            2          1            8
"👨‍👩‍👧"  男 ZWJ 女 ZWJ 女児                  8            5          1           18
"🇯🇵"  U+1F1EF U+1F1F5                    4            2          1            8
"é"  U+00E9 (合成済み)                    1            1          1            2
"é"  U+0065 U+0301 (e + 結合アクセント)    2            2          1            3

// Node 24 で実測(列の順): s.length、[...s].length、
// [...new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(s)].length、
// Buffer.byteLength(s, "utf8")

この数字の意味と、切り詰めが壊れる7パターン

プレイヤーが数える数と一致するのは書記素の列だけで、全行で1になるのもこの列だけです。ひらがなの行は、コード単位では1でもUTF-8では3バイトです。絵文字が1つもなくても、バイト数の制限と文字数の制限はすでに食い違っています。アクセント付きの2行は、同じ見た目の語を2通りに綴ったものです。合成済みのコードポイント1つで書く方法と、素のeに結合アクセント(直前の文字に付く記号のコードポイント)を続ける方法があり、両者は別の文字列で長さも違います。正規化しないまま比較すると、同じ名前が一致しません。

極端なのは家族の絵文字の行です。見た目1文字がUTF-16で8コード単位なので、16コード単位までのフィールドには2つしか入りません。レイアウトの都合で文字数制限を設けたのなら、その制限はもうレイアウトを表していません。書記素の列を決めている境界規則と、どの絵文字の並びが存在するかを列挙したデータは、Unicodeコンソーシアムが公開しています。UnicodeとCLDRが実際に提供しているもので、これらの表の出どころと、ライブラリのバージョン差で同じ文字列の分割結果が変わる理由を説明しています。

以下はNode 24の実際の出力です。

// 1. サロゲートペアの内側で切る
"😀".slice(0, 1)         // "\ud83d" — 片方だけ。length は 1
"😀".substring(0, 1)     // 同じ半分
"😀".charAt(0)           // "\ud83d"、charCodeAt(0) は 55357
"😀".codePointAt(0)      // 128512 = U+1F600 — ペア全体を読む

// 2. 半分になった絵文字は UTF-8 を通過できない
Buffer.from("😀".slice(0, 1), "utf8")  // ef bf bd -> U+FFFD。絵文字は消える
"😀".slice(0, 1).isWellFormed()        // false

// 3. 国旗2つの間で切ると第三国の国旗になる
"🇬🇧🇷🇺".length           // 8 — 国旗2つ、地域表示記号4つ
"🇬🇧🇷🇺".slice(2, 6)      // "🇧🇷" — B + R、つまりブラジルの国旗
"🇯🇵".slice(0, 2)         // "🇯" — 地域表示記号1つ。多くのフォントでは囲み文字のJになる

// 4. 修飾子だけが取り残される
"👍🏽".slice(2, 4)         // "🏽" — 肌の色の修飾子だけ。多くのフォントでは色見本になる
[..."👍🏽"].reverse().join("")   // "🏽👍" — 書記素が1つから2つになる

// 5. 逆順にする
"a😀b".split("").reverse().join("")  // "b\ude00\ud83da" — 両方の半分が壊れる
[..."a😀b"].reverse().join("")       // "b😀a" — 単純なペアなら安全
[..."👨‍👩‍👧"].reverse().join("")        // "👧‍👩‍👨" — 1文字のままだが別の家族

// 6. 行き場を失った結合文字は直前の文字に付く
"café".normalize("NFD").slice(4)  // "\u0301" — アクセントだけ
"Name" + "\u0301"                 // "Namé" と表示される。Name の e に付く

// 7. 正規表現のドットは u フラグがないとコード単位を数える
"😀".match(/./g).length   // 2
"😀".match(/./gu).length  // 1
"😀".replace(/./g, "*")   // "**"
"😀".replace(/./gu, "*")  // "*"

このうち3つは「グリフが壊れる」より厄介

国旗のケースが厄介なのは、静かで、しかも「壊れる」のではなく「間違う」からです。国旗の絵文字は、地域コードの2文字に対応する地域表示記号(リージョナルインジケータ)2つの組です。国旗2つの間で文字列を切ると、生き残った記号どうしが新しく組になります。イギリスに続いてロシアが並んだ文字列をコード単位2の位置で切ると、ブラジルの国旗になります。切り詰められたチャット行やプレイヤー名が、エラーも出さずに、誰も入力していない国を表示します。

2つめは絵文字が消えるケースです。片方だけのサロゲートは正しい文字ではないため、UTF-8に符号化する時点で別のものに置き換えられます。置き換わる先はU+FFFD(疑問符入りのひし形の代替文字)か半角の疑問符で、実行環境によって変わります。原因の切り詰めが表示用のつもりだったとしても、その文字列が保存されるか通信に乗った瞬間に元の絵文字は復元できなくなります。

3つめの結合文字は、いちばんUnicodeの問題に見えない失敗です。付く相手を失ったアクセントは、新しい文脈で直前に来た文字に付きます。日本語でも同じで、分解形の「が」はか+濁点の2コードポイントで、間で切ると「か」になります。症状は「無関係な単語に変なアクセントが付いている」という形で、切った処理からフィールドいくつも離れた場所に出ます。

各言語のlengthが数えているものと、DBの列長

数える単位は言語が決めます。制限をクライアントからサーバに移す、あるいはゲーム内のコードからWeb側の管理画面に移すと、たいてい単位が黙って変わります。

Swiftだけは事情が違います。String.countは書記素クラスタを数えるため全行で1になり、2通りに綴ったアクセント付きの語も、何も指定しなくても等しいと判定します。ただし同じ文字列をNSStringに橋渡しすると、家族の絵文字のlengthは8に戻ります。Python 3はコードポイントを数えるので、スライスでサロゲートペアが割れることはありません。しかし肌の色の修飾子やZWJの並びは依然として割れます。C++のstd::stringはバイトを数えるため、バイト単位の部分文字列取得がもっとも危険です。絵文字に限らず、普通の日本語テキストでもマルチバイト列の内側で切れます。

もう一つはデータベースです。MySQLのutf8mb3は1文字あたり最大3バイトなので、4バイト必要な絵文字はその列にそもそも格納できません。utf8mb4なら4バイトまで入ります。どの名前がどれの別名か、サーバの既定値が何かはバージョンによって変わるので、MySQLリファレンスマニュアルの文字セットの章で現在の挙動を確認してください。自分の列の長さ指定が文字数なのかバイト数なのか、インデックスにバイト長の上限がないかも確認します。ASCIIなら余裕だった上限は、4バイト文字を許可した時点で4分の1の余裕しかありません。

文字列          JS .length  Java length()  C# .Length  Python len()  Swift .count  C++ .size()
"A"                      1              1           1             1             1            1
"あ"                      1              1           1             1             1            3
"😀"                      2              2           2             1             1            4
"👍🏽"                      4              4           4             2             1            8
"👨‍👩‍👧"                      8              8           8             5             1           18
"🇯🇵"                      4              4           4             2             1            8
"é" U+00E9               1              1           1             1             1            2
"é" e + U+0301           2              2           2             2             1            3

// UTF-16コード単位: JavaScript、Java、C#     コードポイント: Python 3
// 書記素クラスタ: Swift                       UTF-8バイト: C++ の std::string
// Node 24 / OpenJDK 17 / .NET 9 / CPython 3.9 / Swift 6.3 / clang C++17 で実測

// 同じ「半分になった絵文字」を UTF-8 に符号化すると:
//   Node 24 と .NET 9  ->  ef bf bd   (U+FFFD)
//   OpenJDK 17         ->  3f         (半角の疑問符)

安全に数える・切る・検証するコード

対処は1つのルールに尽きます。プレイヤーに見えている文字列を、分割器から得た数以外のインデックスで触らないことです。JavaScriptならIntl.Segmenterのgrapheme粒度、C#ならStringInfoのテキスト要素を使います。Swiftは追加のものが要りません。Javaだけは例外で、OpenJDK 17のBreakIteratorのcharacter instanceは家族の絵文字を5、国旗を2と数えました。使っているJDKで確認するか、ICUベースの分割器を使ってください。

整形式(well-formed)かどうかのサーバ側の検査は必要ですが十分ではありません。割れたサロゲートペアは検出できても、末尾に取り残されたZWJ、単独の肌の色修飾子、地域表示記号1つは、いずれも整形式のUTF-16なので検査を通過します。悪意のあるクライアントはこれを送れます。書記素の数も併せて確認し、最後の書記素がZWJで終わっている文字列や、修飾子だけで構成された書記素を含む文字列は拒否してください。

使用可能文字をスクリプト(文字体系)の許可リストで書くと、別の罠があります。ラテン文字・かな・漢字を許可するパターンは、分解形のアクセント付きの名前を弾きます。結合文字はMark(記号)カテゴリで、どのスクリプトにも属さないからです。フィルタの前にNFCへ正規化するか、Markを明示的に許可します。

const seg = new Intl.Segmenter(undefined, { granularity: "grapheme" });
const count = (s) => [...seg.segment(s)].length;

// 書記素の境界で切る。内側では絶対に切らない
function cut(s, max) {
  const out = [];
  for (const { segment } of seg.segment(s)) {
    if (out.length >= max) break;
    out.push(segment);
  }
  return out.join("");
}
cut("🇬🇧🇷🇺Ku👨‍👩‍👧", 3)      // "🇬🇧🇷🇺K" — どの長さで切っても整形式
"🇬🇧🇷🇺Ku👨‍👩‍👧".slice(0, 5)  // "🇬🇧\ud83c" — 整形式ではない

// 整形式の検査が捕まえるのは「割れたペア」だけ
("Yuki" + "😀".slice(0, 1)).isWellFormed()  // false — 拒否できる
"Yuki👨\u200d".isWellFormed()                // true — 末尾の ZWJ は通る
"Yuki🏽".isWellFormed()                      // true — 単独の修飾子は通る
"Yuki🇯".isWellFormed()                       // true — 国旗の片方だけも通る

// 「絵文字を含むか」の判定は間違えやすい
/\p{Emoji}/u.test("1")                   // true — 数字も Emoji 属性を持つ
/\p{Extended_Pictographic}/u.test("🇯🇵")  // false — 国旗は絵ではない
/^\p{RGI_Emoji}$/v.test("👨‍👩‍👧")           // true — 推奨されている並び
/^\p{RGI_Emoji}$/v.test("👨\u200d👩")      // false — 1文字だが推奨外の並び

// 保存前と比較前に正規化する
"Nie\u0301" === "Nié"                                     // false
"Nie\u0301".normalize("NFC") === "Nié".normalize("NFC")    // true

// Intl.Segmenter、isWellFormed、v フラグはいずれも比較的新しい機能なので、
// 依存する前に実行環境の対応を確認する。

表示、経路の文字コード、そして許可範囲の決め方

最後は表示です。カラー絵文字フォントが存在し、フォントのフォールバック連鎖から到達できなければ、コードが完璧に保った書記素は空の四角として表示されます。診断は文字が空の四角として表示される原因で扱っています。Unityでは、TextMeshProのインライン絵文字は本文フォントではなくスプライトアセットから供給されます。対応づけは、使っているバージョンのTextMeshProドキュメントのスプライトの章で確認します。Unrealの場合は、パッケージ版でシステムの絵文字フォントが使える前提を置かず、公式ドキュメントのテキストとフォントの節を確認してください。書記素クラスタは改行でも分断してはいけません。規則はCJKテキストの改行と折り返しで扱っています。

プレイヤー名はセーブファイル、通信メッセージ、サーバログ、解析イベント、通報キュー、CSVエクスポートを通ります。途中の1ホップでもUTF-8で一貫していなければ絵文字は永久に失われます。連鎖の途中にレガシーなコードページが入ると何が起きるかは文字化けのよくある原因と直し方で扱っています。フィールド単体ではなく、往復の全経路をテストしてください。

どこまで許可するかはプロダクトの判断で、出荷前に決めたほうが安く済みます。取りうる立場は、整形式の書記素なら何でも受け入れる、限定した部分集合だけ受け入れる、識別用フィールドでは拒否してチャットでは許可する、のいずれかです。集合を狭めるほど驚きは減ります。ZWJの並びは3人家族で8コード単位、4人家族で11コード単位を使い、レンダラがその並びを知っている場合にだけ1つのグリフになります。正しい書記素ではあっても推奨されていない並びは、構成要素がばらばらに描かれます。

国旗は別の判断が必要です。プレイヤー名に入った国や地域の旗は、ほかのプレイヤーから主張として読まれます。技術的な理由ではなくそれだけを理由に、地域表示記号を許可しないタイトルもあります。肌の色の修飾子は、基になる絵文字を許可しているなら通常は許可します。どう決めるにしても、ルールはサーバ側に1つ置き、クライアントはそれを写すだけにします。

描画できなかったときの扱いも決めます。表示の時点で代替文字に差し替えるなら元の値は保存に残るので、フォントやエンジンを更新したあとで名前が元どおり出てきます。保存の時点で削ってしまうと永久に失われます。

// 文字数制限のあるフィールドのテストデータ
A                  ASCII の基準
あ                  1コード単位、UTF-8では3バイト
😀                  サロゲートペア
👍🏽                  基の絵文字 + 肌の色の修飾子
👨‍👩‍👧                  ZWJの並び。8コード単位、18バイト
🇯🇵                  地域表示記号2つ
🇬🇧🇷🇺                  国旗2つ。間で切ると 🇧🇷 になる
é (e + U+0301)     結合文字。2コードポイント
\ud83d              片方だけの上位サロゲート。悪意あるクライアントが送る形
👨\u200d             末尾に取り残された ZWJ

// 合格条件: 切り詰め・保存・取得・再描画を経て値が変わらないか、
// メッセージ付きで拒否されること。黙って別の値に変わるのは不可。

関連記事