この記事は Python Advent Calendar 2021 23日目の記事です。
はじめに
最近、文字コードを考慮したシステム設計をする機会がありました。
文字コードは今までなんとなくの知識でやり過ごしてきましたが、基礎知識から学び直す良い機会となりました。
この記事では、文字コードの知識についてPythonを使い理解を深めることを目的としています。
なお、Python固有の知識よりは文字コードの基礎知識にフォーカスしていますので、Pythonの知識が無い方にも参考になればと思います。
想定読者
- 文字コードについて、そもそも何かわかってない人
- 聞いたことはあるけどよくわかってない人、理解に自信が無い人
前提
- Python 3.8
- Mac OS Catalina
文字コードとは何か
「文字コード」という言葉を調べると、Unicode, UTF-8, SJISなどさまざまな単語が登場します。これらは文字コードに関する、代表的な規格を表す単語になります。
もう少し大きい話として、文字コード自体は「符号化文字集合」と「文字符号化方式」という2つの要素から構成されます。
符号化文字集合
日本語を例に取ると「ひらがな」や「カタカナ」という文字の集まり(文字集合)があります。
このような文字集合の各文字に対して一意な数字(コードポイント)を割り当てたものが符号化文字集合です。
例えば、Unicodeという符号化文字集合では、ひらがなの「あ」にはU+3042という16進数のコードポイントが割り当てられています。
文字符号化方式
実際にコンピュータが文字を扱うためには、2進数に解釈できるバイト列に変換する必要があります。
符号化文字集合のコードポイントをコンピュータが扱えるように変換する方式を文字符号化方式と呼びます。
また、この変換をエンコード(逆はデコード)と言います。
例えば、Unicodeの「あ(U+3042)」をUTF-8という方式でエンコードすると16進数表記でE3 81 81になります。
よく使われる規格
例にあげた通り、Unicodeは「符号化文字集合」、UTF-8は「文字符号化方式」の規格を意味する用語です。
上記以外にもUTF-16など様々な規格がありますが、Unicode, UTF-8, Shift-JIS(SJIS)などよく使われる規格です。この記事ではこれらの規格を対象とします。
符号化文字集合 | 文字符号化方式(例) |
---|---|
Unicode | UTF-8、UTF-16、UTF-32 |
JIS X 0208 | Shift-JIS, Windows-31J |
符号化文字集合の特徴: Unicode
Unicodeは、全世界でスタンダードになっている符号化文字集合です。全世界の文字をコードポイントで表現しています。
※Unicodeは、正確には文字集合と符号化方式のセットを意味しますが、ここでは文字集合をUnicodeと記載しています。
コードポイントは16進数で「U+」や「0x」を頭に付けて表記されることが一般的です。
文字の例 | コードポイント(16進数) |
---|---|
あ | U+3042, 0x3042 |
a | U+0061, 0x0061 |
💯 | U+1F4AF, 0x1F4AF |
🙂 | U+1F642, 0x1F642 |
表のとおり、絵文字や顔文字なども含めて定義しています。各種SNS等でこれらの絵文字が使えるのはUnicodeがあるからと言えます。その他文字のコードポイントも、以下のようなサイトで調べることが可能です。
Unicodeはコンソーシアムにより運営されており、日々新しい文字が追加されています。2021/9にはUnicode 14.0が発表されました。その他の更新情報は、以下公式サイトで確認することができます。
文字符号化方式: UTF-8
先述の通り、文字符号化方式は符号化文字集合をコンピュータで取り扱えるよう変換する方式を意味します。
現在、世の中のウェブサイトやAPIなど様々な分野で、UTF-8の利用が主流となっています。
UTF-8では、コードポイントを8bit単位で符号化する仕組みであり、アルファベット等の主要な文字を少ないバイト数で扱えるメリットがあります。要するに効率が良いのです。
UTFを詳細に見るとBOMやエンディアンの概念があるのですがここでは割愛します。
Pythonのコードサンプル(UTF-8)
さて、ここまでの話をPythonで理解してみます。
文字からコードポイントを調べたりその逆を行うには、Pythonでは以下のようなコードになります。
# ord: 文字 → コードポイント (※ordは10進数で出力されるため、hexで16進数表記にしている)
>>> hex(ord('あ'))
'0x3042'
# chr: コードポイント → 文字
>>> chr(0x3042)
'あ'
また、文字からUTF-8の値を調べる場合やその逆は以下のようなコードになります。加えて、コードポイントとUTF-8間での変換も可能です。
# 文字からUTF-8に変換(エンコード)
>>> 'あ'.encode('utf-8').hex()
'e38182'
# UTF-8から文字に変換(デコード ※bytes.fromhexで16進数値をバイト列に変換して、その後デコードしている)
>>> bytes.fromhex('e38182').decode()
'あ'
# コードポイントからUTF-8
>>> chr(0x3042).encode('utf-8').hex()
'e38182'
# UTF-8からコードポイント(UTF-8から一度文字にしてコードポイントを調べている)
>>> hex(ord(bytes.fromhex('e38182').decode()))
'0x3042'
符号化文字集合の特徴: JIS X 0208
過去にASCIIと呼ばれる文字集合がアメリカで誕生しました。ASCIIはアルファベットを中心としたラテン文字だったため、各国で自分の国用に改変したものが作られました。
日本語に沿って作られたのがJIS X 0208です。JIS X 0208では2バイトで文字を表すのが特徴です。
ただ、JIS X 0208でカバーされない日本語も一部存在したため、後に文字数を増やしたJIS X 0213が作られました。
JIS X 0208では区点番号で文字を一意に特定します。例として「あ」は4区2点です。
以下のような文字コード表のサイトを見るとわかりやすいです。
JIS X 0213では、更に面も追加され面区点番号で表すことになりました。
対応する文字符号化方式としては、Shift-JIS(SJIS) があります。ただ、このSJISがやや曲者で多くの人を悩ませています。
外字とは何か
JIS X 0208には、利用者や各ベンダーが自由に文字を割り当てられる領域が設けられていました。結果、NECやIBMといったPCベンダーによって、各社の製品には独自の文字が追加されました。
追加された独自の文字は外字と呼ばれます。また、ベンダーの機種に依存するため機種依存文字とも呼ばれます。こちらの呼び方のほうが馴染みの方も多いかもしれません。細分化するとNEC特殊文字、NEC選定IBM拡張文字、IBM拡張文字とも呼ばれています。
当然ながら、ベンダーによって文字が異なるのは不便が生じるため、マイクロソフトが後になってこれらを統合しました。SJISにはCP932という別名称が与えられており、統合されたSJIS(CP932)はWindows-31Jと呼ばれます。
つまり、CP932は発表後にベンダーごとに独自拡張され、拡張されたものがまた統合されてWindows-31Jになったわけです。
SJIS, CP932, Windows-31Jについては、以下のサイトでわかりやすく解説されています。
上記の通り複雑な経緯があり、SJISは拡張される前後を混同して表記されることがあります。そのため、システム設計時にSJISという単語と遭遇した際には、どちらを意味しているのか文脈などから慎重に確認する必要があります。
この記事では、以降SJIS=Windows-31Jを意味して記載します。ただ、後ほど拡張前のSJISも再登場します。
単語の整理
単語 | 概要 |
---|---|
SJIS | JIS X 0208に対応する文字符号化方式。JIS X 0213とも互換 拡張前のSJISとWindows-31Jで混同して表記されることがある |
CP932 | SJISに対する別名称。当初はSJISと同じだったが、後に拡張されて元のSJISと別物となった |
拡張されたCP932 | NECやIBMが独自の文字を追加したCP932のこと |
統合されたCP932 | 拡張されたCP932をマイクロソフトが統合したもの |
Windows-31J | 正式名称は、Microsoftコードページ932。統合されたCP932と同じ。 統合以降、マイクロソフトのドキュメントでCP932はWindows-31Jを意味する |
MS932 | JavaでのWindows-31Jの呼び方 |
Pythonのコードサンプル(SJIS)
Unicode同様にSJISのコード値取得を行うことができます。CP932=Windows-31Jを意味しています。
# 文字からSJISにエンコード
>>> 'あ'.encode('cp932').hex()
'82a0'
# SJISから文字にデコード
>>> bytes.fromhex('82a0').decode('cp932')
'あ'
ここで注意点ですが、Pythonではcp932
以外にもsjis
というエンコードがあります。
これはPython公式の「別名」列に記載がある通り、cp932
がms932(=Windows-31J)で、shift_jis
(sjis)は拡張前のSJISを意味します。
出典:Python公式
そのため、sjis
を指定してエンコードすると、「Windows-31Jには存在するが、拡張前のSJISには無い文字」はエラーとなります。
例としてIBM拡張文字である巐
をエンコードしてみると以下の通りの結果になります。
# sjisでエンコードしようとするとエラー
>>> '巐'.encode('sjis').hex()
Traceback (most recent call last):
File "<input>", line 1, in <module>
UnicodeEncodeError: 'shift_jis' codec can't encode character '\u5dd0' in position 0: illegal multibyte sequence'
# cp932を指定するとエンコードできる
>>> '巐'.encode('cp932').hex()
'ed9a'
変換結果についての補足
巐
という文字がED9A
というコードに変換されましたが、この文字は文字コード表によってはFAB6
と表記されることがあります。この理由として、NEC選定IBM拡張文字やIBM拡張文字は、Windows-31Jで互換性を保つため重複してコードが割り当てられている(=2つコードが割り当てられている)ためです。
上記の例では、NEC選定IBM拡張文字のコードであるED9A
が出力されているわけです。
実際に、文字コード表によっては2つのコードが掲載されていることもあります。
出典:https://seiai.ed.jp/sys/text/java/shiftjis_table.html
UTF-8とSJISの変換
文字コードで一番頭を悩ませるのは変換の問題です。
文字コードの変換とは、あるコード値を異なる文字符号化方式のコード値に変換することを意味します。変換した結果、別の文字を表示してしまったり、そもそも変換先に対応するコード値が存在しない場合は文字化けとなります。
UTF-8とSJISは、そもそも元となる符号化文字集合が異なるため変換表を用いて変換が行われます。
前提として、UTF-8はSJISよりもカバーする文字範囲が広いため、UTF-8にしか無い文字をSJISに変換しようとすればエラーになります。一方で、SJISの範囲内の文字であればコード値を変換できます。
# SJISに存在する文字
>>> bytes.fromhex('e38182').decode()
'あ'
# ひらがなの「あ」をUTF-8のコード値からSJISのコード値に変換
>>> bytes.fromhex('e38182').decode().encode("cp932").hex()
'82a0'
# SJISに無い文字
>>> bytes.fromhex('f09f9982').decode()
'🙂'
# 変換エラーになる
>>> bytes.fromhex('f09f9982').decode().encode("cp932").hex()
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
UnicodeEncodeError: 'cp932' codec can't encode character '\U0001f642' in position 0: illegal multibyte sequence
>>>
SJISとWindows-31Jの違い
UTF-8とSJISの変換を複雑にする問題があります。それが拡張前のSJISとWindows-31Jで扱いが異なる文字があることです。
Windows-31Jは古いSJISを拡張して出来たものですから、拡張した範囲(IBM拡張文字など)だけが異なるように思えます。しかしその範囲以外、すなわち拡張前のSJISにあった範囲にも扱いが異なる文字があるということです。
以下の通り、SJISのコード値に対して、拡張前のSJISとWindows-31Jで変換後のUnicode文字が異なります。これは全角チルダ問題とも呼ばれます。
※以下の表ではSJIS=拡張前のSJISとして読んでください。
対象文字のコード値 | Windows-31JでUnicode変換した場合 | SJISでUnicode変換した場合 |
---|---|---|
0x8160 | ~ (FULLWIDTH TILDE) U+FF5E |
〜 (WAVE DASH) U+301C |
0x8161 | ∥ (PARALLEL TO) U+2225 |
‖ (DOUBLE VERTICAL LINE) U+2016 |
0x817C | - (FULLWIDTH HYPHEN-MINUS) U+FF0D |
− (MINUS SIGN) U+2212 |
0x8191 | ¢ (FULLWIDTH CENT SIGN) U+FFE0 |
¢ (CENT SIGN) U+00A2 |
0x8192 | £(FULLWIDTH POUND SIGN) U+FFE1 |
£ (POUND SIGN) U+00A3 |
0x81CA | ¬(FULLWIDTH NOT SIGN) U+FFE2 |
¬ (NOT SIGN) U+00AC |
変換はPythonでも確認できます。
>>> hex(ord(bytes.fromhex('8160').decode(encoding="cp932")))
'0xff5e' # '~'(FULLWIDTH TILDE)
>>> hex(ord(bytes.fromhex('8160').decode(encoding="sjis")))
'0x301c' # '〜'(WAVE DASH)
例えば、U+FF5E「~ (FULLWIDTH TILDE)」を0x8160というコード値に変換したとして、その後sjis
で変換した場合にはU+301C「〜 (WAVE DASH)」という別の文字に置き換わってしまうことになります。
Windows-31Jは言い換えれば、Windowsに搭載されているSJISとUTF-8の変換表でもあります。
マイクロソフトがWindows-31J作成時に、これらの文字を拡張前のSJISと異なった文字を割り当てて変換表を作ったため、このような複雑な事象が生じていると言われています。
変換が複雑になるもう一つの問題
もう一つ問題があります。それはUnicodeコンソーシアムとJISで変換規則が異なることです。
Unicodeコンソーシアム、JISともにSJISのコード値とUnicodeコードポイントの変換表を提供していましたが、0x815Cについては以下の通り異なる文字を割り当てていました。
対象文字のコード値 | Unicodeコンソーシアム | JIS |
---|---|---|
0x815C | ― (HORIZONTAL BAR) U+2015 |
— (EM DASH) U+2014 |
但し、Pythonで変換した場合には以下の通り同じU+2015が出力されます。
>>> hex(ord(bytes.fromhex('815C').decode(encoding="cp932")))
'0x2015'
>>> hex(ord(bytes.fromhex('815C').decode(encoding="sjis")))
'0x2015'
これは、Unicodeに従った変換表で実装されているか、JISに従った変換表で実装されているかに依存すると考えられます。今回で言えば、cp932
でもsjis
でもUnicodeコンソーシアムが割り当てた0x2015に変換されています。
未検証ですが、JISに従った変換表で実装されている場合には、0x815CがU+2014に変換されるはずです。
上記の問題は、Wikipediaでは全角ダッシュのマッピング問題と呼ばれているようです。
また、以下のサイトなどでも解説されています。
実際に、JIS X 0213で提供される変換表は以下のサイトで確認できます。
変換表を見ると分かる通り、全角チルダや全角ダッシュの問題を意識して右の方に「Windows: U+2015」「Windows: U+2225」という記載があります。
SJIS変換を意識する場面
おそらくSJISの問題に頭を悩ませる方の多くは、エンタープライズ系のシステムと関わりがあるのではないかと思います。
モダンなアプリケーションの多くはUTF-8で開発されますが、従来から存在するシステムがSJISであるため連携する時に考慮が必要になった、というパターンが多いのではないのでしょうか。(私はそうでした)
もし、連携先のシステムの文字コードがSJISと言われた場合、以下のような点を確認するのが良いと思います。
- 「SJIS」が拡張される前後のどちらを意味しているのか
- 外字が使えるのか
- ~ (FULLWIDTH TILDE)(全角チルダ)などはどう扱われるのか
例えば、連携先のシステムがWindows-31Jを意味しており外字なども利用可能な場合には、文字がWindows-31Jの範囲に変換できるかチェックすることになります。そのため、UTF-8の文字に対して以下のようなコードでチェックできそうです。
# cp932でエンコードできればTrueを返却
def cp932_validation(char: str):
try:
char.encode('cp932')
return True
except ValueError:
return False
但し、このソースは全角チルダ問題などは考慮をしていません。
この関数にU+FF5EとU+301Cのどちらを渡してもTrueが返却されます。ただ、実際にはU+FF5EのみOKとすべきなので個別の考慮が必要です。
>>> cp932_validation(chr(0xFF5E))
True
>>> cp932_validation(chr(0x301C))
True
余談ですが、JavaはMS932を使った以下のようなチェックが可能です。
Javaは詳しくないのですが、このあたりのライブラリは充実しているのかと思います。
import java.util.*;
import java.nio.ByteBuffer;
import java.nio.CharBuffer;
import java.nio.charset.Charset;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.CodingErrorAction;
public class Main {
public static void main(String[] args) throws Exception {
var s = "〜"; // U+301C: NOT MS932 character
// var s = "~"; // U+FF5E: MS932 character
try {
ByteBuffer bb = Charset.forName("MS932").newEncoder()
.onUnmappableCharacter(CodingErrorAction.REPORT)
.encode(CharBuffer.wrap(s));
System.out.println("Succeeded! This character is MS932!");
} catch(CharacterCodingException e) {
System.out.println("Conversion Error! This character is NOT MS932!");
}
}
}
おわりに
簡単ではありますが、文字コードについての概念からそれを理解するためのPythonのサンプルコードを紹介しました。
記事を書く中で、やはりまだまだ理解不足な点があることがわかり、自分の頭の中が整理できた気がします。
基礎的な内容が中心でしたが、文字コードに悩む方の役に立てば幸いです。