アドレスバーに %EF%B8%8F という9文字が出ました。
打ち間違いではありません。文字化けでもない。では何なのか。
見ていたのは、自分で仕掛けたもののほうでした。
スタジオおもち by HASH のサイトを開くと、アドレスバーの # から先が動きます。12マスぶんの絵文字を毎秒8回書き換えて、街のポストから山を越えておばあちゃんの家まで手紙が渡っていくのを107秒で上映しています。その9マス目に山を置いたところ、そこだけ様子が違いました。
先に3つ書いておきます。
ひとつ、%EF%B8%8F は U+FE0F という文字で、絵文字の一部が剥がれて表に出てきたものです。壊れているのは自分のデータではありません。
ふたつ、これを必要とする絵文字は Unicode 16.0 で207字あります。分布に偏りがあるので、避けるべき区画さえ覚えれば実務ではほぼ回避できます。
みっつ、対処は削除ではなく差し替えです。削ると白黒の記号が残ります。
以下、ひとつずつ実際に手を動かして確かめた記録になります。

2文字が引き離されると、合図だけが残る
中身が2文字だった
U+FE0F は異体字セレクタ16、VS16と呼ばれます。
役目はひとつだけで、直前の1文字に対して「記号ではなく絵文字として描け」と指示します。それ自体には字形も幅もありません。
だから ⛰️ は1文字ではありません。
| 見た目 | 中身 | コードポイント数 |
|---|---|---|
| ⛰️ | U+26F0 + U+FE0F | 2 |
| 🗻 | U+1F5FB | 1 |
| ❤️ | U+2764 + U+FE0F | 2 |
| 💗 | U+1F497 | 1 |
こうなっている理由は歴史にあります。
☀ や ✂ や ❤ は、絵文字より前から白黒の記号として文字コードに入っていました。あとから絵文字の仲間に加えるとき、既存の白黒表示を壊すわけにいきません。そこで「絵文字として見せたいときだけ合図を足す」という決着になりました。
ふだんは何も起きません。
表に出るのは、本体と合図が引き離されたときだけです。引き離すのは機械の側で、URLを組み直すとき、ファイル名を安全な形に変換するとき、CSVを別のソフトが読み直すとき、といった場面になります。2文字が1文字ぶんの意味を持っていることを知らない処理が、片方だけを別扱いすると起きます。
自分が踏んだのはアドレスバーでした。
location.hash に12マスぶんの文字列を書き込む処理をまわしていて、9マス目に ⛰️ を使っていました。ブラウザがURLを組み直したときに本体だけが白黒の ⛰ になり、はぐれた合図が %EF%B8%8F として並びます。地図の山だけが記号に戻って、その右に9文字ぶんの余計なものが増えました。
いまは 🗻 に差し替えてあります。差し替えた3文字のうち、山がこれでした。
207字を数えた
感覚で避けるのは無理だと思ったので、Unicode が配っているテストデータ(emoji-test.txt、バージョン16.0)を落として数えました。
単一のコードポイントで完結する絵文字と、うしろに U+FE0F が要る絵文字を分けるだけの作業です。
| 字数 | |
|---|---|
| そのままで絵文字として表示される | 1177 |
| U+FE0F が必要 | 207 |
全体の15%ほどで、数だけ見れば少ない。
問題は分布のほうでした。均等に散らばっていません。

危ないのは古株のいる区画だけだった
| 区画 | 要 U+FE0F | そのまま可 |
|---|---|---|
| U+1F600–1F64F 顔 | 0 | 80 |
| U+1F900–1F9FF 人や手、小物 | 0 | 238 |
| U+1F300–1F5FF 動物や自然、もの | 78 | 554 |
| U+1F680–1F6FF 乗りもの | 13 | 92 |
| U+2600–27BF 記号 | 70 | 46 |
| U+2190–21FF 矢印 | 8 | 0 |
顔と U+1F900 番台は1字も引っかかりません。
危ないのは記号の区画で、ここだけ過半数が合図を必要とします。矢印にいたっては絵文字として使えるものが全滅でした。⬆️ も ➡️ も ⬅️ も、見た目は1文字で中身は2文字あります。
理由を調べたら納得はいきました。
記号の区画は絵文字より前からある古株の住処なので、あとから絵文字にされた文字が集中しています。逆に U+1F300 以降は最初から絵文字として設計されているので、合図が要りません。実務の判断としては「U+1F300 より前の文字を機械に渡す文字列で使わない」でだいたい足ります。
12マスの地図を組み直したときも、この線引きで選びました。街のビルも、川の波も、汽車も、おばあちゃんも、全部 U+1F300 以降にあります。合図が要ったのは山と雲と鳩の3つだけでした。
よく使うものだけ抜き出しました。右は見た目の近い代替で、全部データと照合して合図なしを確認してあります。
| 要 U+FE0F | 差し替え候補 |
|---|---|
| ❤️ | 💗 💖 🧡 💛 💚 💙 💜 🖤 🤍 |
| ⚠️ | 🚨 ❗ ⛔ 🔴 |
| ✔️ ☑️ | ✅ 🆗 |
| ✖️ | ❌ ❎ 🚫 |
| ➡️ ⬆️ ⬇️ ⬅️ | ⏩ 👉 🔜 |
| ▶️ | ⏩ 🔽 |
| ‼️ ⁉️ | ❗ ❓ 💢 |
| ✉️ | 📧 📩 💌 📮 📫 |
| ✏️ | 📝 📒 |
| ✂️ | 📎 🔪 🧵 |
| ⚙️ | 🔧 🔩 |
| ☎️ | 📞 📱 📠 |
| 🖥️ ⌨️ | 💻 📺 📱 |
| 🗓️ | 📅 📆 |
| ⏱️ ⏲️ | ⏰ ⏳ ⌛ |
| ♻️ | 🔄 🔁 🌱 |
| ☀️ ☁️ ❄️ | 🌞 ⛅ ⛄ 🌈 |
| ✈️ | 🛫 🛬 🚀 |
| ⛰️ 🗺️ 🏝️ | 🗻 🌏 🌴 |
| ♠️ ♥️ ♦️ ♣️ | 🃏 🎴 🀄 |
候補として挙げたもののうち 🖍 🛠 ▶ ☑ 🗒 🏔 🖌 は、それ自体が合図を必要とするので落としました。
目で選ぶと必ず間違えるので、ここは照合するしかありません。
一番効いたのが ❤️ でした。
世界でもっとも使われている絵文字が合図付きになっています。機械に渡す文字列にハートを入れるなら 💗 のほうが安全ということになります。
ついでに、絵文字全体が何文字でできているかも数えました。
fully-qualified の絵文字は3781字あります。そのうち1文字で完結するのは1177字だけで、残りの2604字は複数のコードポイントを組み合わせてできています。
| いくつの文字でできているか | 字数 |
|---|---|
| 1 | 1177 |
| 2 | 1121 |
| 3 | 89 |
| 4 | 435 |
| 5 | 575 |
| 6 | 12 |
| 7 | 149 |
| 8 | 128 |
| 10 | 95 |
2文字の1121字は3つに割れます。
肌の色を指定した手や人が655字、国旗が259字、そして今回の主役である「本体+U+FE0F」が207字。国旗は地域表示記号というアルファベット状の文字を2つ並べて作るので、🇯🇵 は J と P が並んでいるだけです。
4文字より長いものは、U+200D という接着剤で複数の絵文字をつないでいます。
家族や職業やカップルの絵文字がこれで、最長は10文字でした。🧑🏻❤️💋🧑🏼 がそれで、人ふたりと肌の色ふたつとハートとキスマークを接着剤3つでつないでいます。
ここで効いてくるのが、U+FE0F を含む絵文字は207字では済まないということでした。
どこかに含むものまで数えると1160字あります。差し引き953字は、複合絵文字の内部に合図が埋まっている形です。👨❤️💋👨 の中のハートがまさにそれで、8文字のうち4番目が U+FE0F になっています。
判定としては同じ扱いで構いません。文字列に U+FE0F が入っていれば引っかかります。
ただし対処は変わります。単独の絵文字なら合図の要らない字に差し替えられますが、複合絵文字は中身を差し替えようがありません。機械に渡す文字列では、そもそも複合絵文字を避けるほうが早いという話になります。
判定と差し替え
判定そのものは簡単でした。
207字のリストを持ち歩く必要はありません。渡そうとしている文字列に U+FE0F が入っているかを見ればいい。入っているなら、その絵文字は合図を必要としています。
迷ったのは対処のほうでした。
最初は落とすつもりでいました。正規表現で U+FE0F を全部消せば %EF%B8%8F は出なくなります。実際に消えました。ただし残るのは ⛰ であって ⛰️ ではありません。白黒の記号がそのまま表示されるだけで、意図した見た目にはなりませんでした。
落としていいのは、記号として出ても構わない場面に限られます。
絵として見せたいなら、合図の要らない別の文字に差し替えるしかない。上の表を作ったのはそのためでした。
文字数の数え方にも注意が要ります。
JavaScript の String.prototype.length はUTF-16の符号単位を数えるので、'❤️'.length は 3 を返します。本体がサロゲートペアなら 4 になります。スプレッド構文で分解すれば [...'❤️'].length が 2 になって、こちらが目的に合います。PHP なら mb_strlen() が 2 を返します。

同じ文字列で数が3つ出てくる
コード
判定と分解です。
機械に渡す直前で1回通せば足ります。
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | // U+FE0F を含むか const needsSelector = (s) => /\uFE0F/.test(s); // コードポイント単位の長さ const cpLength = (s) => [...s].length; // 合図を落とす(白黒の記号になることを承知のうえで) const stripSelector = (s) => s.replace(/\uFE0F/g, ''); needsSelector('❤️'); // true needsSelector('💗'); // false cpLength('❤️'); // 2 '❤️'.length; // 3 ← UTF-16 の符号単位 encodeURIComponent('❤️'); // '%E2%9D%A4%EF%B8%8F' encodeURIComponent('💗'); // '%F0%9F%92%97' |
PHP はこれで足りました。
| 1 2 3 4 5 6 7 | function rapls_needs_selector( string $s ): bool { return (bool) preg_match( '/\x{FE0F}/u', $s ); } function rapls_strip_selector( string $s ): string { return preg_replace( '/\x{FE0F}/u', '', $s ); } |
207字を自分で数え直したいときの手順です。
emoji-test.txt は fully-qualified の行にコードポイント列が並んでいるので、2個でうしろが FE0F のものを拾えばいい。
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | import re, urllib.request URL = ('https://raw.githubusercontent.com/unicode-org/unicodetools/' 'main/unicodetools/data/emoji/16.0/emoji-test.txt') src = urllib.request.urlopen(URL).read().decode('utf-8') need, safe = [], [] for line in src.splitlines(): m = re.match(r'^([0-9A-F ]+);\s*(\S+)\s*#\s*(\S+)\s+E[\d.]+\s+(.*)$', line.strip()) if not m or m.group(2) != 'fully-qualified': continue cps, glyph, name = m.group(1).split(), m.group(3), m.group(4) if len(cps) == 2 and cps[1] == 'FE0F': need.append((glyph, cps[0], name)) elif len(cps) == 1: safe.append((glyph, cps[0], name)) print(len(need), len(safe)) # 207 1177 |
どうしてもコード側にリストを持ちたい場合の207字です。
連続をまとめると116レンジになります。
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 | const NEEDS_VS16 = [ [0xA9,0xA9], [0xAE,0xAE], [0x203C,0x203C], [0x2049,0x2049], [0x2122,0x2122], [0x2139,0x2139], [0x2194,0x2199], [0x21A9,0x21AA], [0x2328,0x2328], [0x23CF,0x23CF], [0x23ED,0x23EF], [0x23F1,0x23F2], [0x23F8,0x23FA], [0x24C2,0x24C2], [0x25AA,0x25AB], [0x25B6,0x25B6], [0x25C0,0x25C0], [0x25FB,0x25FC], [0x2600,0x2604], [0x260E,0x260E], [0x2611,0x2611], [0x2618,0x2618], [0x261D,0x261D], [0x2620,0x2620], [0x2622,0x2623], [0x2626,0x2626], [0x262A,0x262A], [0x262E,0x262F], [0x2638,0x263A], [0x2640,0x2640], [0x2642,0x2642], [0x265F,0x2660], [0x2663,0x2663], [0x2665,0x2666], [0x2668,0x2668], [0x267B,0x267B], [0x267E,0x267E], [0x2692,0x2692], [0x2694,0x2697], [0x2699,0x2699], [0x269B,0x269C], [0x26A0,0x26A0], [0x26A7,0x26A7], [0x26B0,0x26B1], [0x26C8,0x26C8], [0x26CF,0x26CF], [0x26D1,0x26D1], [0x26D3,0x26D3], [0x26E9,0x26E9], [0x26F0,0x26F1], [0x26F4,0x26F4], [0x26F7,0x26F9], [0x2702,0x2702], [0x2708,0x2709], [0x270C,0x270D], [0x270F,0x270F], [0x2712,0x2712], [0x2714,0x2714], [0x2716,0x2716], [0x271D,0x271D], [0x2721,0x2721], [0x2733,0x2734], [0x2744,0x2744], [0x2747,0x2747], [0x2763,0x2764], [0x27A1,0x27A1], [0x2934,0x2935], [0x2B05,0x2B07], [0x3030,0x3030], [0x303D,0x303D], [0x3297,0x3297], [0x3299,0x3299], [0x1F170,0x1F171], [0x1F17E,0x1F17F], [0x1F202,0x1F202], [0x1F237,0x1F237], [0x1F321,0x1F321], [0x1F324,0x1F32C], [0x1F336,0x1F336], [0x1F37D,0x1F37D], [0x1F396,0x1F397], [0x1F399,0x1F39B], [0x1F39E,0x1F39F], [0x1F3CB,0x1F3CE], [0x1F3D4,0x1F3DF], [0x1F3F3,0x1F3F3], [0x1F3F5,0x1F3F5], [0x1F3F7,0x1F3F7], [0x1F43F,0x1F43F], [0x1F441,0x1F441], [0x1F4FD,0x1F4FD], [0x1F549,0x1F54A], [0x1F56F,0x1F570], [0x1F573,0x1F579], [0x1F587,0x1F587], [0x1F58A,0x1F58D], [0x1F590,0x1F590], [0x1F5A5,0x1F5A5], [0x1F5A8,0x1F5A8], [0x1F5B1,0x1F5B2], [0x1F5BC,0x1F5BC], [0x1F5C2,0x1F5C4], [0x1F5D1,0x1F5D3], [0x1F5DC,0x1F5DE], [0x1F5E1,0x1F5E1], [0x1F5E3,0x1F5E3], [0x1F5E8,0x1F5E8], [0x1F5EF,0x1F5EF], [0x1F5F3,0x1F5F3], [0x1F5FA,0x1F5FA], [0x1F6CB,0x1F6CB], [0x1F6CD,0x1F6CF], [0x1F6E0,0x1F6E5], [0x1F6E9,0x1F6E9], [0x1F6F0,0x1F6F0], [0x1F6F3,0x1F6F3] ]; const codeNeedsVS16 = (cp) => NEEDS_VS16.some(([a, b]) => cp >= a && cp <= b); |
次の自分に渡すメモ
- 絵文字を機械に渡す前に U+FE0F の有無を見る。207字を覚える必要はない
- 絵として出したいなら落とさず差し替える。落とすと白黒の記号が残る
- 選ぶなら U+1F300 以降から。顔と U+1F900 番台は1字も引っかからない
- 記号の区画(U+2600–27BF)と矢印は疑ってかかる。矢印は全滅
- 代替を目で選ばない。
🖍も🛠も🏔も合図付きだった - 長さは
[...s].lengthかmb_strlen()で数える。s.lengthは別のものを数えている - 複合絵文字(国旗、肌の色、家族)は中身を差し替えられない。機械に渡す文字列では使わない
- emoji-test.txt は Unicode の版が上がるたびに更新されるらしい。数字を書くときはバージョンを添える
きっかけになった仕掛けは hashgk.com でまだ動いています。107秒で一周します。


コメント