\ud83d\ude00を絵文字に戻す:unicode_escapeが化ける理由
JSON やログに \ud83d\ude00 と出ていたら、それは絵文字「😀」(U+1F600)1文字である。ところが、この12文字を Python の codecs.decode(s, 'unicode_escape') で戻すと、見た目は戻ったようで中身は2文字になり、ファイルに UTF-8 で書こうとした瞬間に UnicodeEncodeError になる。
ハシトシステムの Unicode エスケープ変換 は、文字列と \uXXXX 形式を相互に変換するツールで、絵文字を入れると \ud83d\ude00 のように2つのエスケープを出す。この記事では、なぜ2つになるのか、それを JavaScript と Python で戻すと何が起きるのかを実測で確かめる。
なぜ絵文字は \u が2つになるのか
\uXXXX は16進4桁なので、表せるのは U+0000〜U+FFFF(基本多言語面)までである。U+1F600 のようにそれより大きい文字は、UTF-16 の サロゲートペア(上位サロゲート U+D800〜U+DBFF と下位サロゲート U+DC00〜U+DFFF の組)に分けて、2つの \uXXXX で書く。
JSON の仕様である RFC 8259 の 7章も、基本多言語面の外の文字は「UTF-16 のサロゲートペアを表す12文字の並び」で書くと定めている。JavaScript の文字列も中身は UTF-16 なので、'😀'.length は 2 になる。
U+1F600 の場合は次のように分かれる。
| 値 | |
|---|---|
| コードポイント | U+1F600 |
| 0x10000 を引く | 0x0F600 |
| 上位10ビット + 0xD800 | 0xD83D |
| 下位10ビット + 0xDC00 | 0xDE00 |
漢字でも、基本多言語面の外にある「𠮷」(U+20BB7)はツールで \ud842\udfb7 の2つになる。
JavaScript では JSON.parse で戻る
Node.js v24.11.1 で、公開中のツールの関数と JSON の関数に同じ文字列を渡した(コードは後ろの「実際に試す」にある)。
| 入力・操作 | 結果 |
|---|---|
ツールで A😀あ をエスケープ |
A\ud83d\ude00\u3042 |
ツールで \uD83D\uDE00 を戻す |
😀 |
ツールで \u{1F600} を戻す |
😀 |
ツールで \U0001F600 を戻す |
変換されずそのまま |
JSON.parse に "\uD83D\uDE00" |
😀 |
JSON.parse に "\u{1F600}" |
SyntaxError |
JSON.stringify('😀'.slice(0, 1)) |
"\ud83d" |
\u{1F600}は JavaScript の文字列リテラルの書き方で、JSON にはない。JSON.parseは SyntaxError になる。ツールは貼り付けやすさのため、戻すときだけこの形も受け付ける。\U0001F600(大文字の U と8桁)は Python などの書き方で、ツールは変換しない。'😀'.slice(0, 1)のように絵文字の途中で切ると、上位サロゲートだけの「片割れ」(lone surrogate)が残る。JSON.stringifyは片割れを"\ud83d"とエスケープして出す。MDN によると、これは「well-formed JSON.stringify」と呼ばれる変更で、以前のエンジンは片割れをそのまま出力していた。
Python では unicode_escape だと2文字になる
Python 3.11.9 で、\ud83d\ude00 の12文字を戻した結果が次のとおり。
| 方法 | 結果 | 文字数 | UTF-8 に書き出す |
|---|---|---|---|
codecs.decode(s, 'unicode_escape') |
'\ud83d\ude00' |
2 | UnicodeEncodeError |
上の結果を .encode('utf-16', 'surrogatepass').decode('utf-16') |
'\U0001f600' |
1 | 可 |
json.loads('"' + s + '"') |
'\U0001f600' |
1 | 可 |
unicode_escape は Python のソースコードのエスケープを読む codec で、\uXXXX を1つずつ独立した文字に直す。そのため、上位と下位のサロゲートが別々の2文字として残る。Python の str は UTF-16 ではなくコードポイントの並びなので、この2文字が自動で1つの絵文字に組み合わさることはない。UTF-8 の codec はサロゲートの範囲のコードポイントを書けないので、.encode('utf-8') の時点でエラーになる。
一方、json.loads は JSON の規則どおりにサロゲートペアを1文字に組み合わせて返す。元が JSON の文字列なら、json.loads で戻すのが正しい。
すでに2文字に分かれてしまった文字列は、Python ドキュメントの codecs にある surrogatepass エラーハンドラで直せる。surrogatepass はサロゲートのコードポイントを普通のコードポイントとして encode・decode させるハンドラで、いったん UTF-16 のバイト列にしてから decode し直すと、隣り合ったペアが1文字にまとまる。
逆向きでは、json.dumps('😀') は既定(ensure_ascii=True)で "\\ud83d\\ude00" を出す。JSON を読む側が人間なら ensure_ascii=False を付けると絵文字のまま出力される。
片割れが混ざった文字列は外に出すときに壊れる
絵文字の途中で切った文字列や、"\ud83d" だけの JSON を読んだ結果には、サロゲートの片割れが入る。片割れは、どの実装でも「読むところまでは通り、外に出すところで壊れる」。
| 操作 | 結果 |
|---|---|
JS: 'abc😀'.slice(0, 4).isWellFormed() |
false |
JS: 同じ文字列を encodeURIComponent |
URIError: URI malformed |
JS: 同じ文字列を UTF-8 のバイト列に(Buffer.from) |
616263efbfbd(末尾が U+FFFD に置き換わる) |
JS: 同じ文字列の toWellFormed() |
abc� |
Python: json.loads('"\\ud83d"') |
'\ud83d'(エラーにならない) |
Python: その結果を .encode('utf-8') |
UnicodeEncodeError |
RFC 8259 の 8.2節は、"\uDEAD" のような片割れを含む JSON を受け取ったソフトウェアの振る舞いは予測できないと書いている。Python の json モジュールのドキュメントも、片割れを含む文字列を既定で受け付け、そのまま出力すると明記している。JavaScript でも、JSON.parse('"\\uD83D"') は長さ1の文字列を返してエラーにならない。
URL に入れる、UTF-8 でファイルや DB に書く、といった外に出す処理の直前で、JavaScript なら isWellFormed() で確かめるか toWellFormed() で U+FFFD に置き換える。MDN によると、この2つのメソッドは2023年10月から主要ブラウザで使える。そもそも片割れを作らないために、文字数で切るときは Intl.Segmenter などで書記素(見た目の1文字)単位に切る。
実際に試す
Node.js 20 以降(isWellFormed と toWellFormed を使うため。記事では v24.11.1)と Python 3(記事では 3.11.9)で試せる。
1. ツールの関数と JSON の関数を比べる(Node.js)
1 | |
1 | |
出力は JSON.stringify を通しているので、バックスラッシュが2つに見える。ページの中身が変われば結果も変わるので、2026-11-04 時点の結果として読んでほしい。
2. Python で12文字を絵文字に戻す
1 | |
1 | |
コンソールの文字コードに左右されないよう、ascii() で表示している。\U0001f600 が絵文字1文字、\ud83d\ude00 が片割れ2文字である。
ハシトシステムの Unicode エスケープ変換では
Unicode エスケープ変換 は、ブラウザの中だけで文字列と \uXXXX を相互に変換する。2026-11-04 時点のコードでは、次のように動く。
- エスケープは UTF-16 の1単位ごとに
\uXXXX(16進は小文字)を出す。絵文字や「𠮷」のような基本多言語面の外の文字は2つになる。この出力は JSON の文字列と JavaScript の文字列リテラルにそのまま使える。 - 既定では ASCII 以外だけをエスケープする。「すべてエスケープ」にすると
Aも\u0041になる。 - 戻すときは
\uXXXXと\u{1F600}の両方を受け付ける。Python の\U0001F600は変換しない。 - 戻すときに片割れのエスケープ(
\ud83dだけなど)があっても、エラーにせずそのまま片割れの文字にする。ペアがそろっているかは、戻した結果に絵文字が正しく出ているかで確かめるとよい。
UTF-8 のバイト列としての見え方は JWT の日本語が化ける理由の記事、URL に入れるときの encodeURIComponent の挙動は encodeURIComponent と URLSearchParams の違いの記事、JSON の整形で値が変わる話は JSON 整形の記事 にも書いた。文字のコードポイントは コードポイント確認 で1文字ずつ見られる。
まとめ
\ud83d\ude00は絵文字1文字。\uXXXXは4桁なので、U+10000 以上の文字は UTF-16 のサロゲートペアとして2つで書く。- Python で JSON 由来の
\uエスケープを戻すならjson.loads。unicode_escapeは片割れ2文字になり、UTF-8 に書けない。分かれてしまったらencode('utf-16', 'surrogatepass').decode('utf-16')で組み直す。 \u{1F600}は JavaScript の書き方で JSON では使えない。\U0001F600は Python の書き方。- サロゲートの片割れは、JSON として読むところまでは通り、URL・UTF-8・DB に出すところで壊れる。JavaScript では
isWellFormed()/toWellFormed()で出す前に確かめる。 - 文字列を文字数で切るときは、絵文字の途中で切らないよう書記素単位で切る。
参考
- RFC 8259 The JavaScript Object Notation (JSON) Data Interchange Format — 7. Strings、8.2. Unicode Characters
- Python 3 ドキュメント codecs — Text Encodings(unicode_escape)、Error Handlers(surrogatepass)
- Python 3 ドキュメント json — ensure_ascii、Character Encodings
- MDN Web Docs: String.prototype.isWellFormed()、JSON.stringify()(Well-formed JSON.stringify)