🖥️

あ、この文字、なんで入ってるの? (2)

に公開

この記事は、FEConf2024で発表された<あれ、この文字、なんで入ってるの? (副題: 知っておくとたまに役立つハングルUnicode完全マスター)>の内容をまとめたものです。発表内容を2回に分けてお届けします。第1回ではUnicodeの概要と、その中でのハングルの扱いについて解説しました。第2回では、置換文字()が表示される原因と、その解決策について探ります。本文に挿入されている画像の出典は、すべて同名の発表資料であり、個別の出典表記は省略しています。


「あれ、この文字、なんで入ってるの? (副題: 知っておくとたまに役立つハングルUnicode完全マスター)」
チェ・ハンジェ、Denier CTO

前回の記事では、Unicodeの概要と、その中でのハングルの扱いについて見てきました。今回の記事では、いよいよ謎の置換文字()が表示される原因を突き止め、その解決策を探っていきます。

問題分析 - が入る理由

それでは、本格的に謎の置換文字()が入る理由について見ていきましょう。

Windowsが問題なのか?:'euc-kr'

まず、「Windowsのエンコーディング方式が原因ではないか?」という仮説から検証してみましょう。WindowsはUTF-8の代わりに、euc-krというエンコーディングを主に使用して文字を保存することがあります。このエンコーディングの標準名はKS X 1001(旧名: KSC 5601-1987)で、ハングルと漢字を符号化するための規格です。しかし、この1987年版の仕様に含まれるハングルは、頻繁に使われる2,350文字のみでした。現代ハングルの総数が11,172文字であることを考えると、全体のわずか20%程度しかカバーできていません。このように収録されていない文字が多いため、ハングルを表現する上で様々な問題が生じました。

エンコーディング vs 文字コード表

様々な問題点について見ていく前に、エンコーディングと文字コード表の違いについて簡単に見ていきましょう。文字コード表(Character Set)とは、どの文字がどの番号に対応するかを定めた対応表そのものを指します。例えば、Unicodeは「A」という文字は「U+0041」に対応する、と定義する仕様書です。一方、エンコーディング(Encoding)とは、その番号をコンピュータが実際にメモリに保存したり、ネットワークで送受信したりするために、どのようなバイト列に変換するかのルールを定めたものです。

この二つはしばしば混同されますが、多くの文字コード表はそれ自体がエンコーディング方式を兼ねているためです。euc-krも、ハングルを特定の番号に対応付ける文字コード表でありながら、その番号をそのままバイト値として保存するため、エンコーディング方式の一つとも言えるのです。

エンコーディングの代表的な例はUTF-8、文字コード表の代表的な例はUnicodeと言えるでしょう。

ハングルコード標準化による問題発生:KSC-5601 / euc-kr

前述の2,350文字のハングルコードが先に標準化されて使われるようになり、当時のコンピュータネットワーク上のほとんどのハングルがこの標準ベースで表現され始めました。やがて、これにより様々な問題が発生しました。

「서설믜(ソ・ソルミ)」という名前を持つ方のエピソードが代表的です。「믜」という文字がeuc-krの文字セットに含まれていなかったため、彼女はオンラインでの会員登録はもちろん、銀行口座の開設や大学願書の提出さえも、自身の名前を正しく入力できなかったという問題に直面しました。

もう一つの面白い事例は、「ハングル815」というエディタツールです。euc-krには「쓩」という文字はありますが、「쓔」という文字がありません。そのため、当時の他のエディタでは「ㅆ」と「ㅠ」をタイプした直後に文字を表現する方法がなく、タイピングが進まないバグがありました。そこで、ハングル815エディタの広告では「쓩」という文字を表現できると自慢していました。

cp949

これらのeuc-krの問題を解決するために登場したのが、cp949という文字セットです。euc-krの2,350文字を除いた残りの文字まで含めた文字セットです。ただし、このcp949は公式な名前ではありません。HTMLではeuc-krと表記するようになっていますが、ほとんどのマシンではcp949として処理します。

cp949は、元の2,350文字の位置はそのままに、残りのハングル文字を空いているコード領域に「追加」する形で拡張されました。そのため、コード順と辞書順(가나다順)が一致せず、cp949でエンコードされた文字列を正しくソートするには、別途特別な処理が必要になります。

したがって、euc-krでエンコードされた文書が、何らかの理由で破損したり誤って解釈されたりすると、例の置換文字()や、意味不明なハングルの羅列といった文字化けが発生するのです。

HTMLにおけるeuc-kr

euc-krで作成されたページは、通常cp949で表現されます。ただし、実際のメタタグには以下のようにeuc-krで保存されます。ここでまた一つ疑問が生じるかもしれません。euc-krページのDOMのtextContentを読み取ると、実際にeuc-krで処理されるのでしょうか?

この質問に答える前に、まずUTF-8について見ていきましょう。

UTF-8

UTF-8は1992年に考案されました。Unicodeが91年、ハングル標準が87年だったことを考えると、比較的新しい技術と言えます。UTF-8は可変長でテキストを表現し、ASCIIコード領域である1バイト領域から、通常は3バイト、BMP領域まで表現します。なので、ハングルをUTF-8で表現すると、通常は3バイト以下で表現されます。絵文字は4バイトで表現されます。

補足すると、UTF-8の仕様上は5バイトや6バイトのシーケンスも定義されていましたが、2003年のRFC 3629で4バイトを超えるシーケンスは不正なものとして扱われるようになり、実質的に使用されなくなりました。

JavaScriptにおける文字列

では、JavaScriptでは文字列はどのように表現されるのでしょうか?JavaScriptは、UCS-2とUTF-16を主に使用して文字列を処理します。UCS-2とは、2バイトで一つのUnicodeを表現するという意味です。ただし、UCS-2はBMP領域までしか表現できません。そこで、より広い領域を表現する方法としてUTF-16を使用します。UTF-16はUCS-2の表記をそのまま準用しつつ、絵文字のようなより長い領域を4バイトに拡張して表現する方法です。

UTF-8とJavaScriptでの文字列処理方法についての理解を基に、先ほどの疑問を解決しましょう。

euc-kr文書を読み込んでJavaScriptの変数に保存するとどうなるでしょうか?JavaScriptではUCS-2に変換されて保存されます。つまり、すべてのブラウザは様々な方法でエンコードされた文書を読み取りますが、これをJavaScriptで処理する際にはUTF-16に変換して保存・処理します。

では、多くのウェブ文書がUTF-8でエンコードされているのに、なぜJavaScriptはUTF-16で実装されたのでしょうか?UTF-8はアルファベットや記号、ASCIIコードなどを1バイトで表記できるため、圧縮して表現でき、ハングルや他の記号は2バイト、3バイトと続けて表記できます。したがって、UTF-8は転送に有利な表記法です。

しかし、その反面、文字数を数えたり特定の位置の文字にアクセスしたりする処理が複雑になるという欠点があります。JavaScriptがUTF-16(またはUCS-2)を内部表現に採用したのは、すべての文字を固定長(2バイトまたは4バイト)で扱うことで、文字列のインデックスアクセスなどを高速に行うためです。

では、実際のJavaScriptではどのように長さを表現するのでしょうか?下の図の最初の例を見ると、横棒3本で構成された特殊文字はUTF-16文字2つで表現されます。アヒルの絵文字のような場合は、1F986領域、つまりBMP領域を超えているため、長さは2になります。最後に、家族の絵文字は4人の人物絵文字と連結文字を含めて長さが11になります。

先ほどのUCS-2の説明で、JavaScriptはすべての文字列を2バイト単位で処理すると述べました。そのため、文字列から特定の文字を確認するcharAtは、必ず2バイトベースで値を取得します。

アヒルの絵文字にcharAt(0)を適用すると、「D83E」という文字を返します。一方、Unicodeを取得するためにcodePointAtを使用すると、「1F986」という値を返します。

では、 とは何か?

ここまで、euc-krとUTF、JavaScriptでの文字列表現について見てきました。実はこの問題は、Windowsでeuc-krを使ったから生じる問題だけではありません。macOSでも同様の問題が発生するためです。

U+FFFD

再び置換文字()そのものについて見てみる必要があります。Unicodeの仕様書でこの文字を調べてみると、「U+FFFD」というBMP領域の最後に記録された文字であることがわかりました。Unicodeの仕様書では、この文字はUnicodeで表現不可能な文字であると説明されています。さらに詳しく、Unicode仕様書3.0を見てみると、以下のような説明があります。「U+FFFD」という文字、つまりUTFで解釈できない値に対して推奨される3つの処理方法です。

エラーを返すか、該当コードを削除するか、U+FFFDマーカーを挿入してUTF文字が壊れたことを表記せよ、ということです。

ついに、あの置換文字()が生まれる原因が判明しました。「UTFエンコーディングのデータが破損したため」です。この手がかりを元に、自社のサービスコードを改めて調査してみました。

問題解決

エディタで発生する問題かと思い、httpの応答を確認しましたが、問題ありませんでした。DBにもU+FFFDという表現が保存されているので、問題ないことも確認しました。最後に、バックエンドのコードに問題があるか調べてみました。すると、UTF-8の文字を1000バイト単位のチャンクに分割して処理するコードを発見しました。

3バイトで構成されるハングル1文字が、この1000バイト単位のチャンク境界で分断されてしまうケースがあったのです。そして、分断され不完全になったバイト列をtoString()で無理やり文字列に変換しようとした結果、不正なシーケンスとみなされ、U+FFFDに置換されていたのでした。

問題を報告してくれたマネージャーが「常にハングル部分でのみ文字化けが起こる」と話していたことも、この仮説を裏付ける強い証拠となりました。

上記のような問題点を確認し、チャンクを分割する過程で文字が壊れないようにバックエンドのロジックを修正し、問題を解決しました。

おわりに

これまで、置換文字()がなぜ現れるのかを知るために、Unicodeについて学び、ハングルの文字構成も確認し、euc-krと共にUTFまで見てきました。この過程を経て、UnicodeのU+FFFDという仕様まで突き止め、問題を解決することができました。

今回の記事で、ハングルがどのように保存され、ブラウザでどのように表現されるかを覚えていただけると嬉しいです。また、私が問題を解決した過程をヒントに、バックエンドのエンコーディング過程で問題が生じていないか調べてみると、同様の問題を解決するのに役立つと思います。

Discussion