1. 文字に付ける番号と保存するバイトは別のもの
韓国語の文書を書き出して開き直すと、見出しだけが見慣れない文字になる場面を考えてみましょう。入力した人の言語が変わったのではなく、保存された数値を読む規則が食い違ったのかもしれません。Unicodeは文字や記号にコードポイントという番号を割り当てる標準であり、UTF-8はその値をバイト列として表す符号化方式です。例えば韓国語の音節「가」のコードポイントはU+AC00です。U+の後ろは16進数表記であり、ファイル内の位置やメモリアドレスを示してはいません。この番号だけでフォントの形や画面上のピクセル位置が決まるわけでもありません。同じコードポイントでも、フォントや組版環境によって描かれ方が変わります。Unicodeへの対応、UTF-8での保存、韓国語フォントの導入は関連していますが、それぞれ確認対象が異なります。UnicodeのUTFに関する説明は、文字情報と保存形式を分けて理解する出発点になります。
UTF-8では「가」は16進数でEA B0 80という3バイトになります。一方、英字AはU+0041で、UTF-8では41という1バイトです。バイトは8ビットの単位であり、このように同じ文書の中でも文字によって必要なバイト数が違います。.txtや.csvという拡張子だけで実際の文字コードを特定することもできません。UTF-8は有効なUnicodeスカラー値を1〜4バイトで表すため、画面に見える文字数に一定値を掛けても正確なファイルサイズは求められません。韓国語の音節の例を、韓国語に関係するすべての記号や、あらゆる言語にそのまま当てはめるのも誤りです。符号化は文字情報をバイトへ移す規則であって、圧縮や暗号化ではありません。同じ文章でも、ファイル形式の付加情報、改行、通信時の圧縮によって全体のサイズは変わります。WHATWG Encoding Standardが定義するバイトと文字の変換を、ファイル全体の容量や秘密を守る機能と区別すると、原因を絞り込みやすくなります。
2. バイト数・コード単位・見た目の文字数
JavaScriptの文字列のlengthは、UTF-16コード単位の個数です。UTF-8ファイルのバイト数や、人が感じる文字数ではありません。例えば笑顔の「😀」はコードポイントU+1F600の1個ですが、UTF-16コード単位では2個になるので、'😀'.lengthは2です。UTF-8に符号化するとF0 9F 98 80という4バイトになります。codePointAt(0)が返す10進数の128512はU+1F600と同じ番号であり、文字数でもファイルサイズでもありません。「가」はそれぞれ1コードポイント、1 UTF-16コード単位、3 UTF-8バイトです。同じ文字列で数値が異なるのは、測っている単位が違うためです。MDNの文字列の長さの説明にも、lengthの単位が明記されています。この数値だけから、JavaScriptエンジンが実際に何バイトのメモリを使って保存しているかまで断定することはできません。
コードポイントを1個ずつ数えても、利用者に見える文字数と常に一致するわけではありません。基底文字に結合記号が付く場合や、複数のコードポイントが1つの絵文字を構成する場合があるためです。Unicodeの文字分割規則では、そのようなまとまりを扱う拡張書記素クラスタの境界を説明しています。Array.from(text)のようにコードポイント単位で文字列を展開しても、複数のコードポイントからなるまとまりまで自動的に数えられるわけではありません。ブラウザで数える場合は、対象環境の対応状況を確認したうえで、Intl.Segmenterのgranularityに'grapheme'を指定する方法を検討できます。ただし書記素クラスタとフォントが描く1つの字形との一対一対応は保証されず、言語ごとの編集上の期待も異なり得ます。自己紹介の入力欄には利用者が理解できる文字数を、保存容量や通信の制限にはバイト数を別々に適用する必要があります。画面には「20文字」と表示しながら内部でUTF-16コード単位だけを切り詰めると、絵文字や結合文字を途中で分断することがあります。
3. 韓国語の文字化けは保存・受信・表示に分けて調べる
文字化けは、バイトを書いたときの符号化と、読んだときの復号が異なる場合に起こり得ます。UTF-8で保存したファイルを別の文字コードで解釈すれば、ファイル自体が変わっていなくても画面に無関係な文字が出ることがあります。HTMLでは実際の保存形式、HTTP応答のContent-Typeに含まれるcharset、文書の先頭付近に置いたmeta charset宣言を一緒に確認します。HTMLのlang='ko'は内容の言語を示す属性であり、バイトの読み方を指定するcharsetとは役割が違います。W3Cの文字コード宣言ガイドでも、宣言だけをUTF-8に変えるのではなく、実際のファイルをUTF-8で保存し、サーバーと文書の宣言を一致させるよう説明しています。文字コードの署名があると判定の優先順位にも影響するため、meta要素1つが常に他の情報を上書きするとは考えないでください。ファイルを直接開いた場合とサーバーから受け取った場合で結果が違うなら、受信までの設定が重要な手掛かりになります。
必要な文字がフォントになく四角い箱のように表示される問題は、誤った復号とは原因が異なる場合があります。文字データが正しくても描く形を見つけられないなら、保存形式を変えても解決しません。反対に、すでに別の文字として解釈された内容を新しいフォントで表示しても元の文字には戻りません。調査では問題のファイルのコピーを保管し、エディタが選んだ文字コード、サーバー応答の宣言、ブラウザが解釈した文字、使用フォントを順に比較します。見た目だけで原因を決めず、該当部分をコピーしたときにどのコードポイントが得られるかも確認します。特定のブラウザだけで違うのか、別のエディタでも同じファイルを誤読するのかを記録すると、表示の問題とデータの変化を区別しやすくなります。正常に見えるまで保存を繰り返す方法は、元のバイトを失わせるおそれがあります。宣言が整っていても特定の記事だけがおかしい場合は、データベースに入る前のどの段階で内容が変わったかも調べる必要があります。
4. TextEncoderとTextDecoderで同じ値を確かめる
簡単な実験は自分で作った短い文字列から始めます。TextEncoderはJavaScriptの文字列をUTF-8のバイト配列へ変換します。new TextEncoder().encode('가')の結果を16進数で読むとEA B0 80で、配列のlengthは3です。開発者ツールが同じ値を10進数の234、176、128として表示することもありますが、別のデータではなく数値を表す進数の違いです。同じ文字列の通常のlengthが1になることと比べると、単位の違いが分かります。この配列をnew TextDecoder('utf-8').decode(bytes)で読むと「가」になります。ここでbytesは直前に作ったバイト配列です。このエンコーダーは任意の古い文字コードを選んで保存する汎用変換器ではなく、UTF-8を生成する道具です。実際のファイルの調査では、まず保管しておいた元のバイトを使う必要があります。すでに誤読された画面の文章を再びUTF-8へ符号化する実験では、最初のファイルにどんなバイトが入っていたかを証明できません。
TextDecoderのコンストラクターに'utf-8'と{ fatal: true }を指定すると、規則に合わない入力に対して黙って置き換えるのではなくエラーにできます。例えば単独のFFというバイトは有効なUTF-8入力ではないため、このモードでの復号は失敗します。既定の置換モードでは、不正な部分にU+FFFDという置換文字が現れることがあります。しかしエラーがないからといって、元の文字コードを突き止めた、あるいは内容の正しさまで検証したことにはなりません。複数の文字コードで共通するバイト列もあり、すでに別の文字に変わった結果を有効なUTF-8で保存し直している場合もあるためです。この検査は選んだデコーダーが入力を受け入れられるかを見る手順であり、文書の出所、原文との一致、途中での改変の有無まで自動で確認する機能ではありません。
5. 正規化は同じ文字の表現をそろえる別の処理
正しく読めた2つの文字列でも、内部のコードポイント列が異なることがあります。「가」はU+AC00の1個でも、結合する初声U+1100と中声U+1161の並びでも表せます。後者の2つは、日常的な互換字母「ㄱ」と「ㅏ」をそのまま並べる例とは違うので、番号も一緒に確認してください。通常は同じ音節に描かれますが、JavaScriptの生の文字列比較では等しくならない場合があります。Unicodeの正規化規則に従い両方にNFCを適用すると、この正準等価な例を同じ表現で比較できます。NFDは正準分解を使います。この例ではNFCのUTF-8表現は3バイト、NFDの2字母の表現は6バイトとなり、画面が同じでも保存サイズが違うことが分かります。検索語と保存済みの名前が見た目では同じなのに一致しないときには、このような表現差を調べられます。これは、バイトを誤って解釈した文字化けとは区別する必要があります。
正規化形式の選択も目的によって変わります。NFCは正準等価を扱い、NFKCは互換分解を含むため、全角数字や丸で囲んだ数字のように区別して入力された表現をより広く変えることがあります。検索の比較用の値を作るときには役立つ場合がありますが、数学の記法や原資料ではその違いに意味があるかもしれません。そこで原文を保管し、別の比較用コピーにどの形式を適用するかを決める考え方が有用です。正規化は大文字小文字の比較、言語ごとの並べ替え、同じ発音の単語の検索まで代行するものでもありません。既存のファイル名やアカウント識別子を一括変換すると、別の項目が同じ比較値にまとまることがあるので、サービスの命名規則と衝突時の処理を先に検討すべきです。見た形が似ている文字をすべて同じ値で保存することは、正規化の一般的な目的ではありません。
6. 原文を守り、変換の境界を記録する
損傷の原因を絞るときには、最初のファイルを上書きする前に元のバイトと入手経路を残します。コピーを想定する文字コードで読み、正しく見える内容を保存し直しても同じ内容が保たれるか確認します。W3CのBOMの説明にあるUTF-8の先頭の署名はEF BB BFであり、必須のバイトではありません。UTF-8ではバイト順を選ぶ必要がないため、この署名をUTF-16におけるバイト順の選択と同じ役割だと理解してはいけません。BOMを付け外しすることと、本文のバイトを別の文字コードへ変換することも別です。読めない値がすでに置換文字や疑問符に変えられて保存されていれば、複数の元の値が1つにまとまっている可能性があり、自動復元は保証できません。元の文字コードと変換履歴が分かるときに限って戻せる可能性を検討すべきであり、正規化は失われたバイトを復元する道具ではありません。
多言語機能を作るときには、英語1行だけで確認を終えないようにします。同じ短いテスト文の組に、韓国語の音節、結合記号を持つラテン文字、補助平面の絵文字、アラビア語やヘブライ語のような右から左へ書く文字を含め、入力・保存・再取得・書き出しを比較します。各段階で確かめるのは画面の見た目、コードポイント、UTF-8のバイト数、製品が定めた文字数の基準です。読む方向はUTF-8を別の文字コードに変えれば解決する属性ではなく、画面の言語と方向の設定も別々に検討します。入力制限や検索比較を修正する際も、既存の原文を保管し、変わった基準を説明する必要があります。ブラウザの案内に出す数とサーバーが受け付ける条件が同じ単位か確認し、別の制限が必要ならそれぞれ表示します。この記事の数値とコードは短い例示用の文字列の変換を示すもので、特定のサービスの障害を測定した結果ではありません。文字番号、保存バイト、文字列の単位、表示と比較の規則を順に確かめることで、どの境界で何が変わったかを再現できます。