📨

文字化けはなぜ起こるのか?どういう時に復元できるのか?

2024/06/21に公開

文字コードが何もわからないのでNode.js(TypeScript)を使って手を動かしながら入門するメモの第2弾です。

下記記事の続編になります。

https://zenn.dev/dyoshikawa/articles/nodejs-charset-introduction

今回は文字化けについて書いていきます。

文字化けさせてみる

実務で出番が多い(?)UTF-8↔️Shift_JISで文字化けさせてみます。

ああ という文字列を対象に試してみます。2つのパターンを見てみましょう。

import iconv from "iconv-lite";

const s = "ああ";
const addBOM = false;

// UTF-8のバイト列をShift_JISとしてデコードする
const utf8Buf = iconv.encode(s, "UTF-8", { addBOM });
const decoded: string = iconv.decode(utf8Buf, "Shift_JIS", {
  addBOM,
});
console.log(decoded); // 縺ゅ≠
import iconv from "iconv-lite";

const s = "ああ";
const addBOM = false;

// Shift_JISのバイト列をUTF-8としてデコードする
const sjisBuf = iconv.encode(s, "Shift_JIS", { addBOM });
const decoded: string = iconv.decode(sjisBuf, "UTF-8", {
  addBOM,
});
console.log(decoded); // ����

このように、 ああ の場合、UTF-8のバイト列をShift_JISとしてデコードすると 縺ゅ≠ に、Shift_JISのバイト列をUTF-8としてデコードすると ���� に文字化けします。

縺ゅ≠ って何?

UTF-8の のバイト列は e38182 なので、 ああe38182e38182 になります。

ここで はどのようなバイト列になるのか調べてみましょう。

JIS X 0208コード表 - CyberLibrarian

上記表を見ると区69点64の行、+1の列に があります。SJISでは E380 と定義されているので、+1すると E381 です。よってShift_JISにおけるバイト列 e381 になることがわかります。

同じように 縺ゅ≠ それぞれの文字を調べると、

文字 バイト列 (Shift_JIS)
e381
82e3
8182

であることがわかります。よって、Shift_JISにおける 縺ゅ≠ はバイト列にすると e38182e38182 となり、UTF-8の ああ と完全に一致します。

つまり、 e38182e38182 をUTF-8として解釈(デコード)すると ああ となり、Shift_JISとして解釈すると 縺ゅ≠ となるわけですね。

これが文字化けのメカニズムです。バイト列を変更(エンコード)しないまま意図していない符号化方式(UTF-8、Shift_JISなど)でデコードすると文字化けが発生するということになります。

���� って何?

Shift_JISの のバイト列は 82a0 です。なので ああ82a082a0 になります。

82a082a0 をUTF-8で解釈していくことを考えます。

UTF-8 - Wikipedia

UTF-8(ユーティーエフはち、ユーティーエフエイト)はISO/IEC 10646 (UCS) とUnicodeで使える8ビット符号単位(1–4バイトの可変長)の文字符号化形式および文字符号化スキーム。

上記のようにUTF-8は8ビット(=1バイト)単位、1文字あたり1〜4バイトの符号化方式のため、区切り方のパターンは以下になります。

  • 82 a0 82 a0
  • 82 a0 82a0
  • 82 a082a0
  • 82a0 82 a0
  • 82a0 82a0
  • 82a082 a0
  • 82a082a0

ところがこのバイト列をUTF-8として解釈しようとした時に対応する文字がありません。例えば1バイト文字は {00-7F} の範囲で定義されていますが 82 は範囲外です。 82a0 はどうでしょうか? UTF-8において2バイト文字は {C0-DF}{80-BF} であり、やはり範囲外です。

このように対応する文字が何もない場合に、Unicodeのルールで が割り当てられることになっています。

はREPLACEMENT CHARACTERと呼ばれる文字で、Unicodeにおいて U+FFFD として定義されています。

特殊用途文字 (Unicodeのブロック) - Wikipedia

U+FFFD � replacement character
不明な文字、認識できない文字、表現できない文字を置き換えるために使用される

が4つ並んでいるのはなぜでしょうか? 実装を覗いていないのでおそらくにはなりますが、 82a082a0 は4バイトなので、1バイト単位で置き換えられて ���� となったのかなと思います。このあたりはデコーダーの実装によって変わるかもしれません。

元の文字列に復元できるの?

では、文字化けした文字列をそれぞれ元の文字列に戻すことはできるのでしょうか?実際にやってみます。

import iconv from "iconv-lite";

const s = "ああ";
const addBOM = false;

// UTF-8のバイト列をShift_JISとしてデコードする
const utf8Buf = iconv.encode(s, "UTF-8", { addBOM });
const decoded1: string = iconv.decode(utf8Buf, "Shift_JIS", {
  addBOM,
});
console.log(decoded1); // 縺ゅ≠

// 再度バイト列化してデコードすると元に戻る
const sjisBuf = iconv.encode(decoded1, "Shift_JIS", { addBOM });
const decoded2 = iconv.decode(sjisBuf, "UTF-8", {
  addBOM,
});
console.log(decoded2); // ああ
import iconv from "iconv-lite";

const s = "ああ";
const addBOM = false;

// Shift_JISのバイト列をUTF-8としてデコードする
const sjisBuf = iconv.encode(s, "Shift_JIS", { addBOM });
const decoded1: string = iconv.decode(sjisBuf, "UTF-8", {
  addBOM,
});
console.log(decoded1); // ����

// 再度バイト列化してデコードしても元に戻らない
const utf8Buf = iconv.encode(decoded1, "UTF-8", { addBOM });
const decoded2 = iconv.decode(utf8Buf, "Shift_JIS", {
  addBOM,
});
console.log(decoded2); // �ソス�ソス�ソス�ソス

ああ に対して、UTF-8のバイト列をShift_JISとしてデコードして 縺ゅ≠ になった後、再度Shift_JISとしてバイト列にエンコードし、UTF-8としてデコードすることで ああ に戻せました。

一方、Shift_JISのバイト列をUTF-8としてデコードして ���� になった後、再度UTF-8としてバイト列にエンコードし、Shift_JISとしてデコードしても �ソス�ソス�ソス�ソス となり、元には戻りませんでした。

どうして 縺ゅ≠ああ に戻せたの?

縺ゅ≠ をShift_JISとしてバイト列にすると 82a082a0 です。これをUTF-8として解釈すると ああ となりますね。つまり 元のバイト列が生きていた ので戻せました。

どうして �����ソス�ソス�ソス�ソス になっちゃったの?

はUnicode上のIDでは U+FFFD で、UTF-8でバイト列にすると efbfbd になります。

import iconv from "iconv-lite";

const toHex = (buf: Buffer): string => {
  return buf.toString("hex");
};

const s = "�";
const addBOM = false;
console.log(toHex(iconv.encode(s, "UTF-8", { addBOM }))); // => efbfbd

efbfbd をShift_JISで解釈することを考えてみます。前述したようにいろいろ区切り方は考えられるわけですが、全パターンの列挙は割愛して ef bf bd と1バイトごとで区切るとします。

並んでいる順番的にはまず ef なのですが、先に bfbd に触れます。

とほほの文字コード入門 - とほほのWWW入門

上記のJIS X 0201(半角カナの定義はJIS X 0208ではなくこちらのようです)の表によると、

文字 バイト列 (Shift_JIS)
ソ bf
bd

となっています。

そして ef ですが、これはShift_JISで対応する文字がありません。そのため が出力されているものと思います。

ただ、 はUnicodeのルールであり、Shift_JISはまた違うのでは?と思いました。できれば専門家のブログを見つけたいところなのですが、筆者がGoogle検索でこの点を説明した文献を見つけることができませんでした。

なので最後の手段「ソースはChatGPT」を使うことにします。GPT-4Oに聞いたところ以下のように回答されました。

Shift_JISにおいて、置換文字(replacement character)は「�」(U+FFFD)として定義されています。これは、Unicode標準で定義されている特定の文字で、デコードエラーや不正なバイトシーケンスが見つかった場合に使用されます。

Shift_JIS自体は特定の置換文字を定義していないため、Shift_JISからUnicodeへの変換を行う際に、デコードエラーが発生した場合に「�」(U+FFFD)が使用されるのが一般的です。この動作はUnicode標準に依存しており、Shift_JISの規格には直接含まれていません。

具体的な実装においては、使用するライブラリやツールによって異なる場合がありますが、一般的には「�」(U+FFFD)が使用されます。

人間が書いた資料での裏取りはありませんが、実際に起きていることと辻褄は合っているように思います。

話を前に進めると、以上より efbfbd�ソス となります。

���� のバイト列は efbfbdefbfbdefbfbdefbfbd なので �ソス�ソス�ソス�ソス となります。

REPLACEMENT CHARACTERになると元の文字を復元できない

REPLACEMENT CHARACTER に置き換わった場合、元がどんなバイト列であっても(UTF-8の場合) efbfbd になってしまいます。つまり 元のバイト列が消失した ことになるため復元はできません。

ただ、すでに引用したwikipediaページでは

特殊用途文字 (Unicodeのブロック) - Wikipedia

仮に、UTF-8での入力を想定したテキストエディタで、ISO-8859-1エンコード( 0x66 0xFC 0x72 )でドイツ語の単語 "für"を含むテキストファイルを開いたとする。最初と最後のバイトはASCIIにおいて有効なUTF-8エンコードであるが、中間のバイト( 0xFC )はUTF-8で有効なバイトではない。したがって、テキストエディターはこのバイトを置換文字記号に置き換えて、有効なUnicode コードポイントの文字列を生成できる。このときf�rと表示される。さらに、この状態でファイルの保存を行ったとき、正しく実装されていないテキストエディタにおいては、この置換文字符号のコードポイントが(UTF-8形式で)保存される可能性がある。

と述べられています。デコード時に表示としては となるところをバイト列としては元の値を維持するのが「正しい実装」ということなのでしょうが、筆者の手元で試した限りではメジャーなエディタであるVSCodeでも「正しく実装されていないテキストエディタ」の挙動でした(つまり、 に文字化けした状態で保存するとバイト列も efbfbd で確定してしまう)。

Node.jsではどうなのかというと、

import iconv from "iconv-lite";

const s = "ああ";
const addBOM = false;

// Shift_JISのバイト列をUTF-8としてデコードする
const sjisBuf = iconv.encode(s, "Shift_JIS", { addBOM });
const decoded: string = iconv.decode(sjisBuf, "UTF-8", {
  addBOM,
});
console.log(decoded); // ����
console.log(decoded.charCodeAt(0)?.toString(16)); // fffd

Code Unitがfffd(Node.js自体は内部的にUTF-16を使っている)になっており、完全にREPLACEMENT CHARACTERの値なので復元はできないと思われます。

参考

Discussion