| marp | true |
|---|---|
| author | @YujiSoftware |
| title | Javaで絵文字を正しく扱おう🥲 |
| description | JJUG CCC 2026 Spring のセッション資料です。 |
| image | https://yuji.software/emoji/img/card.png |
| theme | gaia |
| style | :root { --color-background: #fff; --color-foreground: #000; --color-highlight: #1a0dab; --color-dimmed: #888; font-size: 40px; line-height: 1.2; } /** 詰め気味に微調整 **/ section > ul, marp-pre { margin: 0.5em 0; } h1 { margin-bottom: 0.5em; } section.lead img { border-radius: 50%; vertical-align: middle; } img[alt~="center"] { display: block; margin: 0 auto; } .info { border: 2px solid #dee2e6; background-color: #cff4fc; color: #052c65; padding: 0.5em; border-radius: 10px; margin: 10px 0; } .info::before { content: "📝"; } span.emphasis { color: red; font-weight: bold; } span.underline { text-decoration: underline; } span.supplement { font-size: 36px; } |
- 絵文字にまつわる罠を3つ紹介
- その罠を Java でどう回避すればいいか
今日の資料のURLは https://yuji.software/emoji/ です。
Java 26 でこのコードを実行した結果は?
void main() {
IO.println(Character.isEmoji('🤧'));
}- true
- false
- コンパイルエラー
- 実行時エラー
3. コンパイルエラー
IsEmoji.java:3: エラー: 文字リテラルに複数のUTF-16コード・ユニットが含まれています
Character.isEmoji('🤧');
^- 1文字なのに複数???
- Java 24 までは コンパイルできていた
- 戻り値は、なぜか false
jshell> Character.isEmoji('🤧')
$1 ==> false- もちろん、true になる場合もある
jshell> Character.isEmoji('☺')
$1 ==> true
jshell> Character.isEmoji('✌')
$2 ==> true- 全世界のあらゆる文字を含む文字集合
- 文字に コードポイント(符号位置) を割り当てている
- 範囲は、U+0000 ~ U+10FFFF (21ビット)
- U+0000 - U+FFFF までを基本多言語面という
- 例:あ= U+3042
- U+10000 - U+10FFFF を追加面という
- 例:🤧 = U+1F927
- U+0000 - U+FFFF までを基本多言語面という
- Unicode を 16bit のコードユニット(符号単位) で扱う
- Java では、コードユニット = char 型
- 例:あ = \u3042
- 追加面に配置されている文字は16ビットで表現できないので、サロゲートペア(2つのコードユニットの組)で表す
- 例:🤧 = \uD83E \uDD27 ↑ ↑ 上位サロゲート 下位サロゲート
Character.isEmoji('🤧');
↓
Character.isEmoji(\uD83E\uDD27);- コンパイラが文字リテラルをコードユニットに置き換える
- この絵文字の場合、2つのコードユニットになってしまう
→ 言語仕様違反によりコンパイルエラーになる
文字リテラルはUTF-16コードユニットのみを表すことができ、つまり\u0000から\uffffまでの値に限定されます。(JLS 3.10.4. Character Literals より)
- isEmoji の引数にコードポイントを直接指定する
Character.isEmoji(0x1F927)- 文字列からコードポイントを取得して指定する
Character.isEmoji("🤧".codePointAt(0))Character.isEmoji("🤧".charAt(0))- charAt で コードユニットを取得している
- 上位サロゲートのみになる(
\uD83E)- これは表示すると � (壊れた文字)になる
- 絵文字ではないので、isEmoji が false となる
- 上位サロゲートのみになる(
Java 24 までのコンパイラも、文字リテラルに対して同様の間違いをしていた
Character.isEmoji('✌')- ✌ = U+270C = \u270C
- コードポイントもコードユニットも同じ
- codePointAt でも charAt でも同じ結果になる
- charAt でも文字によっては正しく動くという微妙な挙動になるので要注意
- 絵文字以外でもサロゲートペアは使われている
- 英数字以外を含むなら、必ずコードポイント単位で扱う
- さらに厳密には、書記素クラスタ単位で扱う(後述)
| 文字 | (よみ) | コードポイント | コードユニット |
|---|---|---|---|
| 𩸽 | ほっけ | U+29E3D | \uD867 \uDE3D |
| 𩹉 | トビウオ | U+29E49 | \uD867 \uDE49 |
| 🄐 | カッコエー | U+1F100 | \uD83C \uDD10 |
Character.isEmoji(…) で true になる文字は?
| 文字 | 説明 | コードポイント | |
|---|---|---|---|
| 1 | 0 | (数字の)ゼロ | U+0030 |
| 2 | ? | はてな | U+003F |
| 3 | ※ | 米印 | U+203B |
| 4 | ♫ | 音符 | U+266B |
Character.isEmoji(…) で true になる文字は?
| 文字 | 説明 | コードポイント | isEmoji | |
|---|---|---|---|---|
| 1 | 0 | (数字の)ゼロ | U+0030 | true |
| 2 | ? | はてな | U+003F | false |
| 3 | ※ | 米印 | U+203B | false |
| 4 | ♫ | 音符 | U+266B | false |
- Unicode でそう定義されているから
- emoji-data.txt という定義ファイルの Emoji プロパティ一覧に 0(U+0030)が含まれている
- Character.isEmoji(int codePoint) はこの定義に従って戻り値を決めている
0023 ; Emoji # E0.0 [1] (#️) hash sign
002A ; Emoji # E0.0 [1] (*️) asterisk
0030..0039 ; Emoji # E0.0 [10] (0️..9️) digit zero..digit nine
00A9 ; Emoji # E0.6 [1] (©️) copyright
00AE ; Emoji # E0.6 [1] (®️) registered
- 絵文字シーケンスにより、絵文字となるから
- ↑ 複数のコードポイントを組み合わせて、1つの絵文字とする仕組み
- 例:0️⃣ (キーキャップ 0)という絵文字
- U+0030, U+FE0F, U+20E3 という3つのコードポイントの並びで1つの絵文字が構成されている
- U+0030
- 数字のゼロ 0
- U+FE0F
- 異体字セレクター16(Variation Selector 16, VS16)
- 明示的に絵文字で表示するという指定
- U+20E3
- キーキャップを表す合成文字(□)
- A. 互換性のため
- 0️⃣ に単独のコードポイントを割り当てた場合
- 対応していない環境では □(通称:豆腐)で表示される
- 何も伝わらない
- 対応していない環境では □(通称:豆腐)で表示される
- 絵文字シーケンスの場合
- 対応していない環境では 0 □ と表示される
- 最低限の内容は伝わる
- 対応していない環境では 0 □ と表示される
- 最近追加された絵文字の多くが絵文字シーケンスで構成されている
- 例:
- ❤️🔥 = ❤ 🔥( U+2764, U+FE0F, U+200D, U+1F525 )
- ❤️🩹 = ❤ 🩹( U+2764, U+FE0F, U+200D, U+1FA79 )
- 🍋🟩 = 🍋 🟩( U+1F34B, U+200D, U+1F7E9 )
- ライム = レモン + Zero Width Joiner + 緑色の四角
-
シーケンスの途中でぶった切ると文字が変わってしまう
- 例: "すっぱい🍋🟩".substring(0, 6); ==> "すっぱい🍋"
-
文字列に手を加える際は、ユーザが認識する1文字 = 書記素クラスタ(Grapheme Cluster)単位で処理をする必要がある
- Java 20 以降なら、BreakIterator.getCharacterInstance() を使う
- それ以前のバージョンなら、ICU4J の BreakIterator を使う
https://github.com/YujiSoftware/emoji/blob/main/src/Break.java
public static List<String> deconstruct(String text) {
BreakIterator it = BreakIterator.getCharacterInstance();
it.setText(text);
List<String> clusters = new ArrayList<>();
int prev = 0;
while (it.next() != BreakIterator.DONE) {
clusters.add(text.substring(prev, it.current()));
prev = it.current();
}
return clusters;
}- "🍋🟩が3️⃣個!" → 5つに分割される
| Index | String | Codepoints |
|---|---|---|
| 0 | 🍋🟩 | U+1F34B, U+200D, U+1F7E9 |
| 1 | が | U+304B, U+3099 |
| 2 | 3️⃣ | U+0033, U+FE0F, U+20E3 |
| 3 | 個 | U+500B |
| 4 | ! | U+0021 |
- 🍋🟩(U+1F34B, U+200D, U+1F7E9)は何文字?
- 考え方によってばらばら
| 実装 | 単位 | 数 |
|---|---|---|
| 人間 | 書記素クラスタの数 | 1 |
| PostgreSQL の length 関数 | コードポイントの数 | 3 |
| Java の String.length() メソッド | コードユニットの数 | 5 |
| Go の len 関数 | UTF-8 のバイト数 | 11 |
-
仕様次第
- 見た目で判断したい → 書記素クラスターの数
- データサイズを厳密に管理したい → UTF-8バイト数
- データベースのカラムで制限したい → コードポイント数
-
システム全体で同じになるように、意識合わせが必要
- 書記素クラスター
- BreakIterator.getCharacterInstance()
- コードポイントの数
- String#codePointCount(int beginIndex, int endIndex)
- UTF-16 コードユニットの数
- String#length()
- UTF-8 バイト数
- ない (実装するか Google Guava の Utf8 を使う)
- 絵文字には罠がある
- サロゲートペア
- 絵文字シーケンス
- 文字の数え方
- …など(ほかにもあります!)
- 絵文字からは逃げられない…!
- 罠に気をつけて、正しく絵文字を扱えるようにしましょう!
- 文字シーケンスは絵文字以外でも使われる
- 例:日本語の濁音の表記は2種類ある
- 単独コードポイント「が」(U+304C)
- 文字シーケンス「が」(U+304B, U+3099)
- 例:日本語の濁音の表記は2種類ある
- そのため、絵文字の有無に関わらずシーケンスを考慮して処理を行う必要がある
単独コードポイントの「が」が使われることが多いが、macOS のファイル名は文字シーケンスの方になっている
- Character クラスには絵文字判定メソッドが複数ある
- isEmoji
- isEmojiPresentation
- isEmojiModifier
- isEmojiModifierBase
- isEmojiComponent
- isExtendedPictographic
- 何が違うのか?
- 絵文字になるかどうか
- 絵文字の構成要素になりうる文字
- 単独では絵文字にならない文字(数字の 0 や # )を含む
- デフォルトで絵文字の表示になるかどうか
- 例:
- ハート ♥ (U+2665)は
false- 明示的に VS16 を付与すると絵文字
♥️ になる
- 明示的に VS16 を付与すると絵文字
- コーヒー ☕ (U+2615)は
true- 明示的に VS16 がなくても絵文字
- ハート ♥ (U+2665)は
- 環境によっては、このプロパティ通りにならない
- iOS とか Android とか
- 絵文字修飾子かどうか
- Emoji Modifier Base と組み合わせて変化させる絵文字
- いまのところ、5つの肌色だけが定義されている
- 🏻️(U+1F3FB)
- 🏼️(U+1F3FC)
- 🏽️(U+1F3FD)
- 🏾️(U+1F3FE)
- 🏿️(U+1F3FF)
- 絵文字修飾子ベースかどうか
- Emoji Modifier Base と組み合わることができる絵文字
- 人の顔や手など
- Emoji Modifier Base と組み合わることができる絵文字
- 例:
- ✌️(U+270C)
- ✌🏽(U+270C, U+1F3FC)
- 🎅️(U+1F385)
- 🎅🏽(U+1F385, U+1F3FC)
- ✌️(U+270C)
- 絵文字コンポーネントかどうか
- 絵文字シーケンスでのみ使われる文字
- 例:
- 数字: 0〜9(U+0030〜U+0039)
- 肌の色: 🏻️〜🏿️(U+1F3FB〜U+1F3FE)
- 髪の色:🦰️〜🦳️(U+1F9B0〜U+1F9B3)
- 拡張絵文字かどうか
- 書記素クラスタの分解時に絵文字シーケンスを壊さないためのプロパティ
- 数字の 0 や # は含まない
- 将来の絵文字用に予約された領域も含まれている
- 書記素クラスタの分解時に絵文字シーケンスを壊さないためのプロパティ
- 簡易な実装
- 書記素クラスタに isExtendedPictographic が true になるコードポイントが含まれていれば絵文字と判定
- この実装の問題点
- 旗シーケンス(🇺🇸 など)やキーキャップシーケンス(0️⃣ など)が絵文字と判定されない
- ▶︎(U+25B6)などの記号も絵文字と判定される
- 存在しない
- Unicode の emoji-test.txt との総当りで判定すればできなくもない
- ただ、絵文字で表示されるかどうかは最終的には環境依存
- 例:▶︎(U+25B6)は、iOS などでは絵文字
▶️ になる
- 例:▶︎(U+25B6)は、iOS などでは絵文字
- 絵文字も平等に「文字」として扱うしかない
- Unicode Emoji
- ユニコードコンソーシアムの絵文字に関する仕様
- emoji-data.txt
- Emoji のプロパティをまとめたファイル
- emoji-test.txt
- すべての絵文字の一覧
- 絵文字シーケンスの組み合わせもこれで確認できる
- JDK-8354908: javac mishandles supplementary character in character literal
- Java 25 で修正されたバグ
- 当初のチケット名は
"Character.isEmoji(int) returns incorrect results"
- つまり、API のバグだと思われていた
- 絵文字がある種のUnicodeバグを世界から一掃しつつある件について|Rui Ueyama
- まさにこれ
- 絵文字:jdecked/Twemoji
