文字コードとUnicode
目次
- 概要
- 文字とバイトは違う
- Unicodeとcode point
- code unitとgrapheme cluster
- UTF-8
- BOMとUTF-16
- 正規化
- 改行コード
- 絵文字とZWJ
- 文字化けの調べ方
- 検索・比較・ソート
- 識別子とセキュリティ
- Web・URL・HTTPでの文字
- DBとファイル名
- 言語別の注意点
- 実務チェックリスト
- Unicode の字形変化と表示の問題
- Unicode バージョンの差異
- 正規化形式の詳細選択基準
- 開発言語の Unicode サポート状況
- Git での文字コード処理
- 文字エンコード の自動検出と問題
- HTTP での文字エンコード指定
- Unicode と正規表現の相互作用
- WHATWG Encoding Standardの詳細仕様
- まとめ
- 参考文献
概要
文字コードは、「文字」をコンピュータで保存・通信するための約束です。文字列の見た目、バイト列、比較、検索、正規化、ファイル名、HTTP headerなど、多くの場所に関わります。
文字はそのまま保存されるのではなく、code pointとencodingを通じてバイト列になります。UTF-8、Unicode正規化、改行コードを理解すると、文字化けや比較の失敗を調べやすくなります。
文字とバイトは違う
同じ文字列でも、encodingによってバイト列は変わります。
文字列 -> code point -> encoding -> bytes
「文字数」と「バイト数」は一致しません。
text = "日本語"
print(len(text))
print(len(text.encode("utf-8")))
この例では、Pythonの len(text) はcode point数に近い値を返します。一方、len(text.encode("utf-8")) はUTF-8にしたときのbyte数です。見た目の文字数、code point数、byte数は別物です。
実務では、次のように目的で単位を変えます。
| 目的 | 見る単位 |
|---|---|
| DBの容量制限 | byte数 |
| UIの文字数制限 | grapheme cluster |
| protocolの長さ制限 | byte数 |
| parser内部 | code point / code unit |
| 表示幅 | East Asian Widthやフォント依存 |
Unicodeとcode point
Unicodeは、世界中の文字に番号を割り当てる標準です。この番号をcode pointと呼びます。
A U+0041
あU+3042
😀 U+1F600
code pointは文字の番号であり、実際にファイルへ保存するバイト列ではありません。保存や通信にはencodingが必要です。
Unicodeを理解するときは、次の3つを分けます。
| 用語 | 意味 | 例 |
|---|---|---|
| character | 抽象的な文字 | A, あ, 😀 |
| code point | Unicode上の番号 | U+0041, U+3042 |
| encoding | code pointをbyte列にする方式 | UTF-8, UTF-16 |
「文字コード」という言葉は曖昧に使われがちです。会話では、Unicodeの文字集合を指しているのか、UTF-8のようなencodingを指しているのかを確認すると混乱が減ります。
code unitとgrapheme cluster
文字列APIでは、code pointではなくcode unitを数える言語があります。JavaScriptの文字列はUTF-16 code unitの列として扱われるため、絵文字のような文字で直感とずれることがあります。
"A".length // 1
"あ".length // 1
"😀".length // 2
😀 はcode pointとしてはU+1F600ですが、UTF-16ではsurrogate pairになり、code unitが2つになります。
さらに、ユーザーが「1文字」と感じる単位はgrapheme clusterです。
| 文字列 | 見た目 | 内部 |
|---|---|---|
é |
1文字 | U+00E9の場合がある |
é |
1文字 | e + combining acute accentの場合がある |
👨👩👧👦 |
1絵文字 | 複数emoji + ZWJ |
文字列を途中で切るときは、code unitやbyteの途中で切らないだけでは足りません。grapheme clusterを壊すと、濁点だけ残る、絵文字が分解される、表示が崩れる、といった問題が起きます。
JavaScriptでは Intl.Segmenter を使うと、grapheme cluster単位に近い分割ができます。
const segmenter = new Intl.Segmenter("ja", { granularity: "grapheme" });
const chars = [...segmenter.segment("👨👩👧👦abc")].map(s => s.segment);
UTF-8
UTF-8は、Unicodeのcode pointをバイト列へ変換するencodingです。ASCII文字は1 byte、日本語や絵文字は複数byteになります。
| 文字 | code point | UTF-8 byte数 |
|---|---|---|
A |
U+0041 | 1 |
あ |
U+3042 | 3 |
😀 |
U+1F600 | 4 |
UTF-8はWebや現代のソフトウェアで広く使われます。迷ったらUTF-8を基本にします。
UTF-8の重要な性質です。
| 性質 | 意味 |
|---|---|
| ASCII互換 | U+0000からU+007FはASCIIと同じ1byte |
| 可変長 | code pointにより1から4byte |
| byte列として保存 | file、network、DBではbyteになる |
| 現代Webの基本 | HTML、JSON、APIで広く使われる |
UTF-8のbyte数はだいたい次のように見ます。
| 範囲 | byte数 |
|---|---|
| U+0000…U+007F | 1 |
| U+0080…U+07FF | 2 |
| U+0800…U+FFFF | 3 |
| U+10000…U+10FFFF | 4 |
文字化けの多くは、UTF-8のbyte列をShift_JISやWindows-1252として読んだり、その逆をしたりすることで起きます。保存時のencodingと読み込み時のencodingを一致させるのが基本です。
BOMとUTF-16
BOM(Byte Order Mark)は、テキスト先頭に置かれることがある印です。UTF-8では必須ではありませんが、Windows系ツールではUTF-8 BOM付きファイルが出ることがあります。
| 表現 | 先頭byte |
|---|---|
| UTF-8 BOM | EF BB BF |
| UTF-16LE BOM | FF FE |
| UTF-16BE BOM | FE FF |
BOMがあると、parserが先頭の不可視文字を含む文字列として扱ってしまうことがあります。
name,age
Alice,20
見た目では分かりませんが、最初の列名が name ではなく \ufeffname になることがあります。
調べる例です。
xxd sample.csv | head
python - <<'PY'
from pathlib import Path
print(Path("sample.csv").read_bytes()[:4])
PY
UTF-16は、JavaScriptやWindows APIなどで内部表現として関わることがあります。ファイル交換やWeb APIでは、特別な理由がなければUTF-8に寄せる方が扱いやすいです。
正規化
Unicodeでは、見た目が同じでも内部表現が違う文字列があります。
é
e + combining acute accent
比較や検索では、正規化が必要になることがあります。
| 形式 | 意味 |
|---|---|
| NFC | 合成済みに寄せる |
| NFD | 分解済みに寄せる |
| NFKC | 互換文字も含めて寄せる |
| NFKD | 互換分解 |
Pythonでは unicodedata.normalize を使います。
import unicodedata
text = unicodedata.normalize("NFC", text)
JavaScriptでは String.prototype.normalize() を使います。
const a = "\u00e9";
const b = "e\u0301";
console.log(a === b); // false
console.log(a.normalize("NFC") === b.normalize("NFC")); // true
NFCとNFKCの違いは重要です。NFCはcanonical equivalenceをそろえる用途に向きます。NFKCは互換文字も寄せるため、見た目や意味が近い文字をより強くまとめます。
| 目的 | 候補 |
|---|---|
| 保存前に表現揺れを減らす | NFC |
| 検索用の別カラムを作る | NFKC + case folding |
| password | 安易に正規化しない。仕様を明確にする |
| 識別子 | UAX #31/UTS #39の考え方を参考に制限する |
| 表示 | 元の入力を保持することもある |
NFKCは便利ですが、元の文字の区別を失うことがあります。ユーザー表示用の原文と、検索・照合用の正規化済み文字列を分けて保存する設計が安全なことがあります。
改行コード
改行にも種類があります。
| 表記 | 主な環境 |
|---|---|
LF \n |
Unix, Linux, macOS |
CRLF \r\n |
Windows |
CR \r |
古いMac |
Gitでは改行コードの差分が大量に出ることがあります。.gitattributes で方針を決めると安定します。
* text=auto
*.sh text eol=lf
*.bat text eol=crlf
改行コードは、文字コードとは別の問題ですが、テキスト処理では一緒にハマりがちです。
| 症状 | 原因 |
|---|---|
| shell scriptが動かない | CRLFの \r が混ざっている |
| diffが大量に出る | LF/CRLFの変換 |
| CSVの行数が合わない | 改行を含むquoted field |
| Windowsで表示が崩れる | LFだけを想定しない古いtool |
確認例です。
file sample.sh
python - <<'PY'
from pathlib import Path
print(Path("sample.sh").read_bytes()[:80])
PY
絵文字とZWJ
絵文字は、1つの見た目が複数code pointからできていることがあります。ZWJ(Zero Width Joiner)は、複数の絵文字を結合して1つの見た目にするために使われます。
👨👩👧👦
このような文字列では、見た目の文字数、code point数、byte数が一致しません。カーソル移動、文字数制限、切り詰め処理では注意が必要です。
絵文字には、skin tone、variation selector、regional indicator、tag sequenceなども関わります。
| 仕組み | 例 | 意味 |
|---|---|---|
| ZWJ | family emoji | 複数絵文字を結合 |
| variation selector | text/emoji presentation | 表示スタイル指定 |
| skin tone modifier | 👍🏽 | 肌色修飾 |
| regional indicators | 🇯🇵 | 国旗 |
「最大10文字」のようなUI制限で、絵文字を途中で切ると壊れます。SNS、チャット、プロフィール名、通知タイトルではgrapheme cluster単位で切る必要があります。
const text = "👍🏽👍🏽👍🏽";
console.log(text.length); // 見た目の数とは一致しない
文字化けの調べ方
文字化けは、encodingの解釈がずれたときに起きます。
調べる順序です。
- 元のencodingは何か
- 読み込み時のencodingは何か
- 保存時に変換していないか
- HTTP headerやHTML metaは正しいか
- terminalのlocaleは何か
file -I sample.txt
locale
Webでは、Content-Type にcharsetを含めることがあります。
Content-Type: text/html; charset=utf-8
もう少し実践的には、byte列を見ます。
xxd sample.txt | head
iconv -f shift_jis -t utf-8 sample.txt > sample.utf8.txt
調査表です。
| 症状 | 可能性 |
|---|---|
� が出る |
不正byte列をreplacement characterで置換 |
ã‚ のように見える |
UTF-8をLatin-1/Windows-1252として読んだ可能性 |
縺ゅ のように見える |
UTF-8とShift_JIS/CP932の取り違え |
| CSVの先頭列名だけ変 | BOMが列名に入った可能性 |
| terminalだけ崩れる | locale、font、terminal設定 |
文字化け調査では、変換を繰り返す前に元ファイルを保存します。誤った変換を上書きすると、元のbyte列が失われて復元が難しくなります。
検索・比較・ソート
文字列処理では、見た目が同じか、バイト列が同じか、意味として同じかを分けます。
たとえば、検索では次のような前処理が必要になることがあります。
- Unicode正規化
- 大文字小文字の統一
- 全角半角の扱い
- 濁点・結合文字の扱い
- localeに応じたソート
Pythonでは単純な比較は簡単ですが、言語ごとの自然な並び順には追加の処理が必要です。
「一致」と「並び順」は別です。
| 目的 | 例 | 必要な処理 |
|---|---|---|
| byte完全一致 | cache key | encoding後のbytes比較 |
| 正規化後一致 | 重複ユーザー名 | normalization |
| 大文字小文字無視 | email local policyなど | case folding |
| 検索 | 表記揺れを吸収 | normalization + tokenizer |
| ソート | 辞書順、言語別順 | collation |
Unicode Collation Algorithmは、文字列を言語や用途に応じて比較・整列するための考え方を提供します。ただし、実際の並び順はlocaleによって変わります。
const words = ["ä", "z", "a"];
console.log(words.sort((a, b) => a.localeCompare(b, "de")));
console.log(words.sort((a, b) => a.localeCompare(b, "sv")));
ドイツ語とスウェーデン語では、ä の扱いが違います。ユーザーに見せるソートでは、単純なUnicode code point順ではなくlocale awareな比較を使うのが自然です。
識別子とセキュリティ
Unicodeには見た目が似ている文字があります。これは国際化には必要ですが、識別子やURLではspoofingの入口になります。
paypal.com
раураl.com
後者は一部にキリル文字が混ざっていても、見た目では気づきにくい場合があります。
セキュリティ上の観点です。
| 問題 | 例 | 対策 |
|---|---|---|
| confusable | Latin a とCyrillic а |
mixed script detection |
| mixed script | 複数scriptの混在 | 許可scriptを制限 |
| invisible文字 | zero width系 | 識別子では制限 |
| 正規化差 | 同じ見た目で別表現 | 保存前にnormalize |
| 全角半角 | A と A |
検索用にはNFKC |
ユーザー名、organization名、domain風の識別子、API key名、repository名では、自由なUnicodeを許すか、制限するかを仕様として決めます。一般文章の入力欄と、識別子の入力欄は同じ扱いにしない方が安全です。
Web・URL・HTTPでの文字
Webでは、HTML、URL、HTTP header、JSON bodyで文字の扱いが変わります。
| 場所 | 観点 |
|---|---|
| HTML | <meta charset="utf-8">、HTTP header |
| JSON | UTF-8が基本。escape表記もあり得る |
| URL path/query | percent-encoding |
| domain | IDNA / punycode |
| header | headerごとに仕様が違う |
| form | browserのencodingとserverのdecode |
URLでは、見た目の文字列がそのままbyte列として流れるわけではありません。
検索?q=日本語
%E6%A4%9C%E7%B4%A2?q=%E6%97%A5%E6%9C%AC%E8%AA%9E
domain名に非ASCII文字を含める場合は、IDNA/punycodeが関わります。
例え.テスト
xn--r8jz45g.xn--zckzah
Webアプリでは、入力、保存、表示、URL生成、ログ出力のどこでencode/decodeするかを明確にします。二重decodeや二重escapeは、文字化けだけでなくsecurity bugにもつながります。
DBとファイル名
DBでは、encodingとcollationの両方が重要です。
| 項目 | 意味 |
|---|---|
| encoding / character set | 保存できる文字とbyte表現 |
| collation | 比較・ソート・大文字小文字などの規則 |
| index length | byte長制限に影響 |
| normalization | DBが自動で正規化するとは限らない |
同じ文字列でも、DBのcollationにより一致判定や並び順が変わります。ユーザー名の一意制約、検索、sort orderでは、アプリ側の正規化方針とDBのcollationを合わせます。
ファイル名も注意が必要です。OSやファイルシステムにより、Unicode正規化や大文字小文字の扱いが異なる場合があります。macOS、Linux、Windowsをまたぐプロジェクトでは、見た目が同じファイル名が別物として扱われたり、逆に衝突したりすることがあります。
python - <<'PY'
import unicodedata
names = ["é.txt", "e\u0301.txt"]
for name in names:
print(name, [hex(ord(c)) for c in name], unicodedata.normalize("NFC", name))
PY
repositoryでは、ファイル名をASCII寄りにする、正規化方針を決める、caseだけ違うファイル名を避ける、といった運用が安全です。
言語別の注意点
言語やruntimeごとに文字列の内部表現とAPIの癖があります。
| 言語 | 注意点 |
|---|---|
| JavaScript | length はUTF-16 code unit数。Intl.Segmenter や normalize を使う |
| Python | str はUnicode文字列。file I/Oでは encoding を明示する |
| Go | string はbyte列。range はrune(code point)単位 |
| Rust | String はUTF-8。byte indexで文字境界を壊さない |
| Java | String はUTF-16。code point APIが必要な場面がある |
| SQL | collationとcharsetを明示的に見る |
Goの例です。
s := "日本語"
fmt.Println(len(s)) // bytes
for _, r := range s {
fmt.Printf("%U\n", r)
}
Rustの例です。
let s = "日本語";
println!("{}", s.len()); // bytes
println!("{}", s.chars().count()); // scalar values
どの言語でも「標準の length が何を数えるのか」を確認するのが第一歩です。
実務チェックリスト
文字列まわりで確認することです。
| 場面 | 確認 |
|---|---|
| ファイル読み書き | encodingを明示しているか |
| HTTP | charset=utf-8 が必要か |
| DB保存 | collationとencodingは何か |
| 検索 | 正規化して比較するか |
| 入力制限 | 文字数かbyte数か |
| 切り詰め | grapheme clusterを壊さないか |
| Git | 改行コード方針はあるか |
| URL | percent-encodingとdecode回数は適切か |
| DB制約 | collationと一意制約は仕様通りか |
| ファイル名 | OS差分、正規化差、case差を考慮しているか |
| セキュリティ | confusable、mixed script、不可視文字を制限するか |
特に「最大20文字」のような仕様では、何を1文字と数えるのかを決める必要があります。絵文字や結合文字を含むと、見た目の文字数とcode point数がずれるためです。
設計時に決めることです。
| 決めること | 例 |
|---|---|
| 保存encoding | UTF-8 |
| 入力正規化 | 保存前NFC、検索用NFKC |
| 表示 | 原文を保持するか |
| 文字数制限 | grapheme cluster単位か、byte単位か |
| URL生成 | encode/decodeの責務 |
| 識別子 | 許可script、長さ、正規化 |
| ログ | 不可視文字や制御文字の扱い |
zation Forms](https://unicode.org/reports/tr15/)
- Unicode Text Segmentation
- Unicode Collation Algorithm
- Unicode Security Mechanisms
- Unicode Emoji
- Unicode Identifiers and Syntax
- RFC 3629: UTF-8
- WHATWG Encoding Standard
- W3C: Character encodings for beginners
- MDN: UTF-8
- MDN: String.prototype.normalize()
- Python Documentation: unicodedata
- Git Documentation: gitattributes
Unicode の字形変化と表示の問題
同じ文字でも、環境やフォントによって異なる見た目を持つことがあります。
字形の変化(Variants)
同じ文字コード、異なる表現
- 数字の "0"(ゼロ)vs "O"(大文字オー)
- ラテン小文字 "l" vs 数字 "1"
- CJK の字形バリエーション(中日韓の異なる字形)
例: U+2F00 - CJK Compatibility Ideograph(互換用文字)
RFC 3454 (STRINGPREP) では、ある文字をその正規形に変換してから処理することで、視覚的な混同を防ぐ方法を推奨しています。
組み合わせ文字による視覚的攻撃
# 例: ドメイン名の偽装
"раypal.com" # Cyrillic の "а" を Latin の "a" の代わりに使用
# 表示上は同じ見える可能性がある
import unicodedata
def detect_mixed_scripts(text):
scripts = set()
for char in text:
name = unicodedata.name(char, 'UNKNOWN')
# Script を抽出("LATIN SMALL LETTER A" から "LATIN")
script = name.split()[0]
scripts.add(script)
return scripts
# 複数のスクリプトが混在していないか検査
if len(detect_mixed_scripts(domain)) > 1:
raise ValueError("Mixed scripts detected")
W3C と WHATWG の仕様でも、ドメイン名の正規化と検査が定められています。
Unicode バージョンの差異
Unicode は定期的に新しいバージョンがリリースされ、新しい文字が追加されます。
バージョン履歴
| バージョン | リリース | 新文字数 | 主な追加 |
|---|---|---|---|
| 1.0 | 1991 | 7,129 | 基本的な Latin, Greek, Cyrillic, CJK |
| 3.0 | 2000 | +10,176 | Arabic, Thai, Lao, Khmer |
| 6.0 | 2010 | +2,088 | 絵文字の初期化 |
| 8.0 | 2015 | +7,716 | 絵文字大幅拡充 |
| 14.0 | 2021 | +838 | モダン言語サポート |
| 15.0 | 2022 | +4,489 | 絵文字など |
アプリケーションが対応する Unicode バージョンに不一致があると、文字が表示されないか、異なる見た目で表示される問題が発生します。
import sys
print(f"Python supports Unicode {sys.version}") # 使用可能な範囲
# ファイルの文字を確認
with open('file.txt', 'r', encoding='utf-8') as f:
text = f.read()
for char in text:
code_point = ord(char)
print(f"{char} -> U+{code_point:04X}")
正規化形式の詳細選択基準
4つの正規化形式を使い分ける基準です。
どの正規化形式を選ぶか
NFC(合成形): 多くのコンテキストで推奨
- ファイルシステム(HFS+ は強制)
- Web(HTML5 は推奨)
- JSONペイロード
NFD(分解形): ほとんど避けるべき
- ただし、文字ごとの分析が必要な場合のみ
NFKC(互換合成形): テキスト検索・マッチング
- ユーザー入力の正規化
- セキュリティチェック
NFKD(互換分解形): 分析・レポート用
- テキスト統計
- 文字分析
Python での実装例
import unicodedata
text = "Café" # U+00E9(Cyrillic)
# NFC: 現代的、Web推奨
nfc = unicodedata.normalize('NFC', text)
# NFD: 分解形
nfd = unicodedata.normalize('NFD', text)
# NFKC: 互換性統一
nfkc = unicodedata.normalize('NFKC', text)
# バリデーション: 入力を NFKC 正規化してから検査
def validate_email(email):
normalized = unicodedata.normalize('NFKC', email)
# パターンマッチ
return re.match(r'^[a-z0-9+\-_.]+@[a-z0-9+\-_.]+{{CONTENT}}#x27;, normalized, re.I)
開発言語の Unicode サポート状況
各言語の Unicode 対応度は異なります。
言語別対応状況
| 言語 | バージョン | Unicode | 正規化 | 注記 |
|---|---|---|---|---|
| Python | 3.x | 完全(UCS4) | unicodedata.normalize | 文字列は Unicode |
| JavaScript | ES6+ | 制限(UTF-16) | Intl.Collator | サロゲートペア対応 |
| Java | 8+ | 制限(UTF-16) | java.text.Normalizer | String.length は不正確 |
| C# / .NET | 4.x+ | 限定(UTF-16) | StringInfo | 結合文字対応 |
| Rust | 1.0+ | 完全 | unicode-normalization crate | 最適化されている |
| Go | 1.x | 完全(UTF-8) | unicode/norm | UTF-8 native |
文字列長の計算例
# Python: 正しく計算
text = "Café"
len(text) # 4
# JavaScript: 注意が必要(絵文字)
const text = "👨👩👧👦"; // Family emoji(複数の基本文字 + Zero-Width Joiner)
text.length # 25(サロゲートペアと結合順序)
[...text].length # 4(スプレッド演算子で正確に)
Git での文字コード処理
Git は UTF-8 を標準としていますが、古い設定では問題が起きます。
Git 設定チェックリスト
# グローバル設定
git config --global core.safecrlf warn
git config --global core.precomposedUnicode true # macOS 向け
# リポジトリ設定
git config core.quotePath false # 日本語ファイル名をそのまま表示
.gitattributes での統一
# すべてのテキストファイル
* text eol=lf
# ファイル形式の自動検出を無効化
*.json text
*.csv text
*.txt text
# バイナリ
*.jpg binary
*.png binary
文字エンコード の自動検出と問題
ファイルの文字エンコードを自動検出するのは困難です。
自動検出ツール
import chardet
with open('file.txt', 'rb') as f:
raw_data = f.read()
result = chardet.detect(raw_data)
print(result) # {'encoding': 'utf-8', 'confidence': 0.99}
chardet の精度は、サンプルサイズに依存し、短いテキストでは失敗することもあります。
ベストプラクティス
-
明示的な指定を常とする
open('file.txt', encoding='utf-8') # デフォルトを上書き -
BOM をチェック
if raw_bytes.startswith(b''): encoding = 'utf-8-sig' -
フォールバック
for encoding in ['utf-8', 'utf-8-sig', 'cp1252', 'iso-8859-1']: try: return text.decode(encoding) except UnicodeDecodeError: continue
HTTP での文字エンコード指定
HTTP Content-Type ヘッダで指定されるエンコードです。
Content-Type の例
Content-Type: text/html; charset=UTF-8
Content-Type: application/json; charset=UTF-8
Content-Type: text/plain; charset=ISO-8859-1
HTML5 では、<meta charset="UTF-8"> で明示します。
Python での送受信
import requests
# リクエスト送信時
response = requests.get('http://example.com')
response.encoding # Content-Type から自動検出
response.text # 正しくデコード
# レスポンス返却時(Flask)
from flask import Response
return Response("テキスト", mimetype='text/html; charset=utf-8')
Unicode と正規表現の相互作用
正規表現エンジンが Unicode を理解するかどうかで、パターンの動作が変わります。
パターン例
# ASCII-only
[a-z]+
# Unicode 対応(Python の \w)
\w+
# 特定スクリプト
\p{Latin}+ # PCRE
[\u0041-\u005A]+ # 手動での Latin 大文字指定
Python での Unicode フラグ
import re
text = "Café"
# ASCII のみ(デフォルト)
re.findall(r'\w+', text) # ['Caf']
# Unicode 対応
re.findall(r'\w+', text, re.UNICODE) # ['Café']
RFC 仕様(WHATWG Encoding Standard など)では、エンコード識別の規範的プロセスが定義されています。
WHATWG Encoding Standardの詳細仕様
WHATWG Encoding Standardは、Webブラウザとサーバーの文字エンコード処理を統一する仕様です。以下は実装レベルの詳細です。
エンコーダ・デコーダの構造
エンコーディング処理は、I/O queueという抽象化された入出力バッファを用いて定義されます。
- 即座モード(Immediate mode): メモリ内にすべてのデータを保持。end-of-queue が最後のアイテム
- ストリーミングモード(Streaming mode): ネットワークから順次データが流入。event loop内では使用不可
# I/O queue の概念実装イメージ
class IOQueue:
def __init__(self):
self.items = []
self.end_of_queue = False
def read_item(self):
if not self.items:
# ストリーミング: ここでブロック
return None
return self.items.pop(0)
def read_number(self, n):
# n個のアイテムを読む
result = []
for _ in range(n):
item = self.read_item()
if item is not None:
result.append(item)
return result
セキュリティ課題と対策
WHATWG仕様では複数バイト文字の脆弱性に対応しています。
例: Shift_JISを使った2011年の攻撃
攻撃者は0x82(先行バイト) + 0x22(末尾バイト)の非正規組み合わせを利用して、JSON処理を操作しました。
- 生産側(サーバー): 0x22(ダブルクォート)として解釈
- 消費側(ブラウザ): U+FFFD(置換文字)として解釈
- 結果: JSON文法が破壊され、セキュリティが侵害される可能性
WHATWG対策: 複数バイトエンコーディングでは、非正規なバイト組み合わせ時に以下を適用
- U+0000〜U+007F の ASCII文字は「マスク」されない
- 非正規な組み合わせは複数の置換文字に分離出力(例: U+FFFD U+0022)
# エンコーディング検証の疑似コード
def validate_sjis_sequence(byte1, byte2):
if is_invalid_combination(byte1, byte2):
# 非正規組み合わせ -> 分離出力
return [U_REPLACEMENT, byte_to_codepoint(byte2)]
# 正規な組み合わせの処理...
return [decode_properly(byte1, byte2)]
ASCII互換性の要件
ASCII互換エンコーディング: バイト0x00-0x7F が U+0000-U+007F にマップされる
ASCII非互換エンコーディング: この対応が成立しない(例: UTF-16BE/LE, ISO-2022-JP)
- WHATWG仕様では、後方互換性が必要な場合以外、新たなASCII非互換エンコーディングの追加は認めない
- 対応ラベルを置換エンコーディングにマップするか、unknown encodingとして扱う
エンコーディング喪失と情報保護
例: windows-1252でのフォーム送信
ユーザー入力: 💩 (U+1F4A9)
エンコード後: エラーまたは代替文字
サーバー解釈: "💩" と代替文字の区別ができない
HTMLフォームのエンコーディングは、特別な場合を除きUTF-8を使用すべきです。
サポート対象エンコーディング
WHATWG仕様でサポートされるエンコーディング(主要なもの):
| エンコーディング | ラベル例 | マルチバイト | 用途 |
|---|---|---|---|
| UTF-8 | utf-8 | ○ | 標準・推奨 |
| UTF-16BE | utf-16be | ○ | ASCII非互換 |
| UTF-16LE | utf-16le | ○ | ASCII非互換 |
| ISO-2022-JP | iso-2022-jp | ○ | レガシー日本語 |
| Shift_JIS | shift_jis, sjis | ○ | レガシー日本語 |
| GBK | gbk | ○ | レガシー中国語 |
| GB18030 | gb18030 | ○ | 中国語標準 |
| windows-1252 | windows-1252 | ✗ | Webレガシー |
| ISO-8859-1 | iso-8859-1 | ✗ | Webレガシー |
TextDecoder/TextEncoderの実装
JavaScript環境でのエンコーディング操作:
// TextDecoder: バイト列 -> 文字列
const decoder = new TextDecoder('utf-8', { fatal: false });
const bytes = new Uint8Array([0xE3, 0x81, 0x82]); // 'あ' in UTF-8
const text = decoder.decode(bytes); // 'あ'
// fatal: true なら不正なバイト列で例外発生
try {
const strictDecoder = new TextDecoder('utf-8', { fatal: true });
strictDecoder.decode(new Uint8Array([0xFF, 0xFF]));
} catch (e) {
console.log('Invalid UTF-8 sequence');
}
// TextEncoder: 文字列 -> UTF-8バイト列(常にUTF-8)
const encoder = new TextEncoder();
const utf8Bytes = encoder.encode('こんにちは');
console.log(utf8Bytes.length); // 15 bytes
エンコーディング自動検出の優先順位
ブラウザはHTTPレスポンスやHTMLメタデータからエンコーディングを決定します(WHATWG仕様順序):
- HTTP Content-Type ヘッダ - 最優先
- HTML
<meta charset>- 同期的スキャン可能範囲内 - BOM(Byte Order Mark) - UTF-8/UTF-16のみ
- レガシー
<meta http-equiv>- 後方互換性 - 言語モデル - 統計的推定(フォールバック)
<!-- 推奨: Content-Type ヘッダ -->
<!-- Server: Content-Type: text/html; charset=utf-8 -->
<!-- HTML内での明示 -->
<head>
<meta charset="utf-8">
</head>
<!-- BOM(自動検出) -->
<!-- UTF-8 BOM: EF BB BF -->
<!-- UTF-16BE BOM: FE FF -->
<!-- UTF-16LE BOM: FF FE -->
Python実装例:
# 標準ライブラリ chardet での自動検出
from chardet.universaldetector import UniversalDetector
detector = UniversalDetector()
with open('file.txt', 'rb') as f:
for line in f:
detector.feed(line)
if detector.done:
break
detected = detector.result
# {'encoding': 'UTF-8', 'confidence': 0.99, ...}
# aiohttp での自動検出
import aiohttp
async with aiohttp.ClientSession() as session:
async with session.get(url) as resp:
# resp.encoding が自動検出された値
text = await resp.text() # 自動エンコーディング適用
ストリーミング型デコーディング
テキストのストリーミング受信を想定した処理:
# Python 3.9+ の TextDecoder(概念的な実装)
import codecs
decoder = codecs.getincrementaldecoder('utf-8')()
# チャンク単位でデータを受信して処理
chunks = [
b'ã', # 'あ'
b'ã', # 'い'
]
for chunk in chunks:
text = decoder.decode(chunk, False) # False: final=False(続きあり)
print(f"Decoded: {text}")
# 最後のチャンク
final_text = decoder.decode(b'', True) # True: final=True(終了)
まとめ
文字列は、見た目、grapheme cluster、code point、code unit、encoding、bytesの層に分けて考えると理解しやすくなります。UTF-8、正規化、改行コード、絵文字、照合、URL、DB、ファイル名の扱いを押さえると、文字化けや比較の失敗を落ち着いて調べられます。特に入力制限や識別子では、ユーザーが見る文字とシステムが数える単位を混同しないことが重要です。