RAKSUL TechBlog

ラクスルグループのエンジニアが技術トピックを発信するブログです

新春!文字化けクイズ

初めまして、「文字化けおじさん」です。社内で文字化け事案が発生するといつの間にかその場に居る(リモートワーク環境下では、Slackのチャンネルに現れる)ことから、このような名前になりました。好きな食べ物は豆腐です。本業は印刷事業のサーバサイドエンジニアです。

さて、ラクスルではいくつかの事業が動いておりますが、どの事業も文字化けとは無縁ではありません。例えば印刷事業であれば印刷工程の途中で化けたり、運送事業(ハコベル)であれば住所の文字が化けたり等、様々なことが起こり得ます。文字化けの対応には幅広い知識が必要になることも多く、学習するのが大変であるということで、社内向けに文字化け学習用のコンテンツを用意しようとしておりました。途中まで作っていたのですが、新年のめでたさに乗じて一部を社外に公開しちゃえ!ということでこの記事ができました。

という訳で、クイズの時間です! これから、文字化けやそれに近い文字のトラブルを3問お見せしますので、それらのトラブルがどのような過程で生じたのか、当ててみて下さい。この記事の下の方には解答例を出しておりますが、正解はひとつだけとは限りません。解答例と異なる解答をしたとしても凹まないで下さい。

問1

Webアプリケーションからメールを送信したところ、受信側ではこのような文字列が表示されました。この文字化けの原因は何でしょうか? 縺ゅ¢縺セ縺励※縺翫a縺ァ縺ィ縺

問2

あなたはJavaScriptで、文字列の難読化ツールを作りました。このツールは入力された文字列をランダムに並べ替えてくれる、シンプルなものです。コア部分のソースコードは、次のようなものです。セミコロン書かない派の皆さんは、セミコロンは見なかったことにして下さい。

function shuffle(str) {
  let charArray = str.split(''); // 文字列を配列にする。
  let ans = ''; // 並べ替えた結果の文字列。ここに追記していく。
  while(charArray.length > 0) {
    const index = Math.floor(Math.random() * charArray.length); // 配列から1文字を選択。
    ans += charArray[index];
    charArray.splice(index, 1); // 配列から1文字を削除。
  }
  return ans;
}

公開してしばらくはトラブルも無く順調に稼働していましたが、ある日「入力した文字が文字化けするんですけれども」という苦情が寄せられました。そんなことが起きるはずがない!と思いつつ詳しく話を聞いたところ、入力した文字列は裃(かみしも)は古くは𧘕𧘔と書かれたというものでした。MacのChromeで実際に試したところ、確かに文字化けして 書も�れ裃くか古みは�し�とか)(たは� といった文字列が得られました。MacのSafariで試したところ、結果の文字列は元の文字列よりもずっと短くなり、時には空文字列になることもありました。この文字化け等の原因は何でしょうか?

問3

UTF-8で与えられた文字列をSJISに変換するシステムがあります。ある日、このシステムでエラーが発生しました。ログを確認したところ、入力は「オレンジ色のペン」という、普通の日本語の文字列でした。エラーメッセージによれば、この文字列はSJISに変換することができないとのことです。エラー情報の第一発見者である担当者がシステムに「オレンジ色のペン」と入力してもエラーは発生しませんでした。エラーの原因として、どのようなものが考えられますでしょうか?

問題はここまで

もう少し下にスクロールすると、解答例が表示されます。

さて、解答例です。

問1 解答例

縺ゅ¢縺セ縺励※縺翫a縺ァ縺ィ縺 これは「あけましておめでとう」という文字列をUTF-8のバイト列で表したものを、SJIS(CP932)のバイト列であるものとして無理矢理解釈したときに得られた文字列です。このような、漢字と、いわゆる半角の文字とが所々で交互に登場するパターンの文字化けは、見たことのある方も多いだろうと思います。日本語で使われる文字の大半はUTF-8で表現すると 3 bytes になりますが、SJISではいわゆる全角文字は 2 bytes になります。この1文字あたりのバイト数の違いを知っていると、UTF-8をSJIS読みしたのかな?という推測ができると思います。

この文字化けは、次のようなコマンドで得ることができます。シェルの環境がUTF-8(echoコマンドの出力がUTF-8)である必要があります。Macのbashで動作確認しました。 echo あけましておめでとう | iconv -f sjis -t utf8 また、このタイプの文字化けは、BOM (Byte Order Mark) なしのUTF-8なCSVファイルをExcelに読み込ませることで見たことがあるという方も多いかもしれません。日本語環境のExcelは、読み込んだCSVファイルのエンコーディングは、BOMが無ければSJIS(CP932)として解釈するからです。

問2 解答例

JavaScript/ECMAScript(以下、単にJSと言います)では、文字列は内部的には UTF-16 で保持されます。雑な話をすると、Unicodeのコードポイントが4桁で表現できるものは 2 bytes で表現できて直感的なコードが上手く稼働しますし、問題文に掲げた雑なコードも期待通り動きます。ところが、Unicodeにはコードポイントが5桁以上となる文字も存在します。こういった文字を UTF-16 で表現するには「サロゲートペア」という方法が利用されます。雑な説明をすると、サロゲートペアが適用される文字は、UTF-16 の世界では2文字のように取り扱われます。JSで str.split('') のような方法で文字列を1文字ずつの配列にしたとき、サロゲートペアも分離されてしまいます。分離した結果として生じるサロゲートペアの片割れは、UTF-16のバイト列として正しくなく、この正しくないバイト列の取り扱いは処理系によって異なります。MacのChromeであれば � U+FFFD REPLACEMENT CHARACTER に置きかわりますし、MacのSafariでは当該文字以降を無視するようです。

さて、問題文の文字列の話題に戻りましょう。登場する「𧘕𧘔」という漢字2文字は、Unicodeのコードポイントで表すとそれぞれ U+27615 U+27614 です。これを UTF-16 で表すにはサロゲートペアの仕組みが必要になります。例えばJSの '𧘕𧘔'.length というコードは 2 ではなくて 4 を返します。この漢字2文字のUTF-16(ここでは Big Endian)表現は、例えばbashであれば次のようにして得られます(Macのbashで動作確認しています)

$ echo -n 𧘕𧘔 | iconv -f utf8 -t utf-16be | xxd
00000000: d85d de15 d85d de14

それでは、問題文にあるシステムをサロゲートペアに気を付けつつ実装するとどのようになるでしょうか? 次のコードは、その実装例の一つです。

function shuffle(str) {
  let charArray = []; // サロゲートペアに気をつけつつ、見た目上の1文字が配列の1要素になる。
  for(char of str) {
    charArray.push(char);
  }
  let ans = ''; // 並べ替えた結果の文字列。ここに追記していく。
  while(charArray.length > 0) {
    const index = Math.floor(Math.random() * charArray.length); // 配列から1文字を選択。
    ans += charArray[index];
    charArray.splice(index, 1); // 配列から1文字を削除。
  }
  return ans;
}

for-of 構文で文字列を1文字ずつ見ると、サロゲートペアの観点では、見た目上の1文字が得られます。IEでは動作しないことに注意です。厳密には「for-of 構文だから」ではなくて、stringのiteratorで1文字ずつ取れば、サロゲートペアを意識して1文字ずつ取り出すことができるという仕組みになっています。

問3 解答例

「オレンジ色のペン」問題の原因は複数考えられます。解答例として最初に出すものは、Unicode正規化の話題です。Unicode正規化の正確な解説は他のサイトに委ねるとして、日本語話者向けに雑な説明をすると「日本語の濁点や半濁点の文字は、1文字として取り扱うことも、濁点・半濁点を分割した2文字として取り扱うこともできる」という話です。この例文「オレンジ色のペン」には濁点も半濁点も含まれていますが、この文字列にNFD正規化の処理をかけると、多くの環境では表示はそのままに、内部的には濁点・半濁点を分割した「オレンシ゛色のヘ゜ン」のような表現にすることができます。処理系によっては、このNFD正規化の処理がかけられた文字列をSJIS等にエンコーディング変換するとエラーになります。例えばrubyにおいては次のようになります。

irb(main):001:0> "オレンジ色のペン".encode(Encoding::SJIS)
=> "\x{8349}\x{838C}\x{8393}\x{8357}\x{9046}\x{82CC}\x{8379}\x{8393}"

irb(main):002:0> "オレンジ色のペン".unicode_normalize(:nfd).encode(Encoding::SJIS)
Traceback (most recent call last):
(バックトレースは省略)
Encoding::UndefinedConversionError (U+3099 from UTF-8 to Windows-31J)

「わざわざNFD正規化の処理なんかやるわけ無いじゃん」等と思うかもしれませんが、例えばMac(HFS+やAPFS)のファイル名ではNFD正規化された文字列が使われますし、Macの印刷ダイアログから作成されたPDFファイルから文字列をコピーしてくると、それもNFD正規化された文字列です(もしかするとNFDではなくてNFKDかもしれませんが、詳しくは確認しておりません。ご容赦下さい🙇‍♂️)

なお、他の原因として、例えば U+200B ZERO WIDTH SPACE のような制御文字が文字列の途中に混入している場合、というものも考えられます。

おまけクイズ

問2の解答例にあるコードは、問3で問題になったような文字列については、期待通りの動作をしません。お手元で修正してみて下さい。 少し書き換えるだけなので、この記事では解答例は示しません。

最後に

この世には、ここに書ききれない文字化け事案も沢山あるでしょうし、筆者の知らないものもきっと沢山あるはずです。仕事や趣味の開発で文字化けに遭遇すると気が滅入る人も多いかもしれませんが、文字エンコーディング等に関する知識を身につけた上でこの記事のようにクイズ感覚で文字化けに立ち向かっていけば、読者の皆さんの推理力は存分に発揮され、解決が早くなるかもしれません。

文字化けと対峙する時間が、楽しくて、かつ、素早く過ぎ去るものとなることを願っております。

また、繝ゥ繧ッ繧ケ繝ォ縺ァ縺ッ繧ィ繝ウ繧ク繝九い繧堤ゥ肴・オ謗。逕ィ荳ュ です!(賢明なる読者の皆さんの知識、あるいはこの記事の情報を応用すると、このメッセージを読むことができます。)