一覧CS-02-2 · 文字・バイト・データ量

文字コード・改行・BOMとファイルの読み込み

文字化けや見えない文字が生じる原因を、ファイルのバイト列から学びます。

説明

テキストファイルは文字を並べたものに見えますが、実際にはバイト列として保存されています。1バイトは8ビットのデータです。ソフトウェアは、UTF-8などの文字コードの規則に従い、そのバイト列を文字列へ変換して表示します。

そのため、同じファイルでも読み込み方によって表示が変わります。このモジュールでは、改行コード、文字コードの指定、ファイル先頭のBOMという三つの例を扱います。「表示が同じならファイルも同じか」「文字化けした内容を保存すると何が起こるか」を、実行結果とともに説明します。

まず、改行だけが異なる二つのファイルを作ります。次に日本語をUTF-8と別の文字コードで読み込み、最後にBOMが読み込み結果へ与える影響を調べます。バイト列は、1バイトを2桁の16進数で表示します。16進数は0〜9とA〜Fを使う表記で、Aは10、Fは15に当たります。

1LF改行のファイル

改行コードは、行の終わりを表す文字です。LFは16進数0Aで、Pythonでは\nと書きます。例として「A」「B」を別々の行にしたファイルを作ります。

from pathlib import Pathファイル操作に使うPathを読み込む; Path('lf.txt').write_bytes(b'A\nB\n')LF改行の2行を書き込む

4は、書き込んだバイト数です。「A」と「B」が各1バイト、二つの\nが各1バイトなので、合計4バイトです。

2CRLF改行では2バイト増える

改行にはCRとLFを続けて使うCRLFという形式もあります。CRは16進数0Dで、Pythonでは\rと書きます。同じ二行を、今度はCRLFで区切って保存します。

Path('crlf.txt')書込み先.write_bytes(b'A\r\nB\r\n')CRLF改行の2行を書き込む

書き込んだ量は6バイトになりました。LFだけの場合より、改行一つにつきCRの1バイトが増えています。

3改行コードの保存位置

二つのファイルを16進数で表示し、文字の間と末尾にある改行コードを確認します。

[(name, Path(name).read_bytes().hex(' ')ファイルをバイト列として読み16進表示) for name in ('lf.txt', 'crlf.txt')二つのファイルを順に処理]

lf.txtは41 0A 42 0A、crlf.txtは41 0D 0A 42 0D 0Aです。41が「A」、42が「B」で、その直後に改行コードが入っています。見た目が同じ二行でも、保存内容は異なります。

4読み込み時の改行変換

テキストとして読み込む場合、既定の改行設定newline=NoneではCRLFなどの改行が\nへ変換されます。二つのファイルをUTF-8で読み込んだ結果を比較します。

lf = Path('lf.txt').read_text(encoding='utf-8')LFのファイルをUTF-8で読み込む; crlf = Path('crlf.txt').read_text(encoding='utf-8')CRLFのファイルをUTF-8で読み込む; lf, crlf, lf == crlf両方の文字列と比較結果を表示

どちらも「A\nB\n」となり、==による比較はTrueになりました。ファイルにあったCRLFが、読み込み時にLFへ変換されたためです。読み込み後の文字列が同じでも、元ファイルのバイト列まで同じとは限りません。

5日本語のUTF-8ファイル

次は「あ」だけを含むUTF-8のファイルを作ります。U+3042はUnicodeで「あ」に割り当てられたコードポイントです。

raw = '\u3042'.encode('utf-8')「あ」をUTF-8へ符号化; Path('message.txt').write_bytes(raw)バイト列を書き込む

「あ」のUTF-8はE3 81 82です。今回は改行を付けていないので、書き込んだ量はこの3バイトだけです。

6UTF-8を指定した読み込み

保存したバイト列を文字列へ戻す処理を復号といいます。保存時と同じUTF-8を指定して読み込みます。

Path('message.txt')読込み元.read_text(encoding='utf-8')UTF-8で文字列として読み込む

「あ」と表示されました。E3 81 82という3バイトが、UTF-8の規則に従って一つの文字に変換されています。

7別の文字コードで起こる読み違い

同じバイト列をLatin-1で復号します。Latin-1では各バイトを、その値と同じコードポイントへ対応させます。

wrong = raw.decode('latin-1')Latin-1で復号; [f'U+{ord(c):04X}' for c in wrong]変換後の各コードポイントを表示

U+00E3・U+0081・U+0082という三つのコードポイントになりました。「あ」のU+3042とは異なります。復号時にエラーが出なくても、文字コードの指定が間違っていれば、このように別の文字列になります。

8文字化けした内容を再保存した結果

読み違えた文字列をUTF-8へ変換します。これは、その文字列をUTF-8で再保存する場合に書き込まれるバイト列です。

changed = wrong.encode('utf-8')読み違えた文字列をUTF-8へ符号化; raw.hex(' '), changed.hex(' ')元のバイト列と変換後を表示

元のE3 81 82に対し、再保存する内容はC3 A3 C2 81 C2 82となりました。表示が文字化けしているだけの状態なら元のファイルは変わりませんが、その内容で上書き保存するとファイルのバイト列まで変わります。

9今回の文字化けを元に戻せる理由

今回はLatin-1で復号した直後の文字列なので、逆の手順で元のバイト列へ戻せます。それをUTF-8で復号します。

wrong.encode('latin-1')Latin-1のバイト列に戻す.decode('utf-8')UTF-8で文字列に戻す

「あ」に戻りました。今回は、どの変換を行ったかが分かり、元の情報も残っていたため復元できました。途中で代替文字へ置き換えたり文字を削除したりした場合は、同じ手順で戻せるとは限りません。

10UTF-8ファイルの先頭に付くBOM

BOMはByte Order Markの略です。UTF-8ではEF BB BFという3バイトで、文字コードを判別する手掛かりになることがあります。必須ではありません。BOMを付けた「あ」のファイルを作ります。

signed = '\u3042'.encode('utf-8-sig')BOM付きUTF-8へ符号化; Path('bom.txt').write_bytes(signed)ファイルへ書き込む

書き込んだ量は6バイトです。BOMの3バイトと、「あ」の3バイトを合わせた長さです。

11BOMと本文の位置

bom.txtのバイト列を表示して、先頭のBOMと、その後に続く本文を確認します。

Path('bom.txt').read_bytes()ファイルをバイト列として読む.hex(' ')16進数で表示

先頭がEF BB BF、続くE3 81 82が「あ」です。UTF-8ではバイト順を選ぶ必要がないため、BOMもバイト順の指定には使いません。UTF-16などでBOMがバイト順を示す場合とは役割が異なります。

12BOMが見えない文字として残る場合

UTF-8で復号するとBOMはU+FEFFという文字として残ります。先頭のBOMを取り除くutf-8-sigと、結果を比較します。

a = signed.decode('utf-8')BOMを文字として残す; b = signed.decode('utf-8-sig')先頭のBOMを取り除く; repr(a), repr(b), len(a), len(b)見えない文字と文字数を表示

文字数は2と1になりました。utf-8で復号した方には、「あ」の前にU+FEFFが残っています。画面では同じに見えても、文字列の一致判定や、CSVの列名などを検索する処理に影響します。

13ファイル内容と読み込み結果が一致しない例

最初に作ったLFとCRLFのファイルへ戻ります。元のバイト列と、読み込み後の文字列について、それぞれ一致するかを確認します。

Path('lf.txt').read_bytes() == Path('crlf.txt').read_bytes()元ファイルのバイト列を比較, lf == crlf読み込み後の文字列を比較

バイト列の比較はFalse、文字列の比較はTrueです。ファイル内容に違いがあっても、読み込み時の改行変換によって同じ文字列になることが分かります。文字化けなどを調べるときは、元ファイルを残し、文字コードや改行設定も確認します。

まとめ

改行コードやBOMは、画面で目立たなくてもファイル内に保存されています。読み込み時の処理によって、表示される文字列と元のバイト列に違いが生じます。

文字化けした内容を再保存すると、元のバイト列が変わることがあります。元ファイルを保管し、読み込み時の文字コードと変換処理を確認します。

操作履歴と解説を振り返れます。

Python 3

1
from pathlib import Path; Path('lf.txt').write_bytes(b'A\nB\n')
4
2
Path('crlf.txt').write_bytes(b'A\r\nB\r\n')
6
3
[(name, Path(name).read_bytes().hex(' ')) for name in ('lf.txt', 'crlf.txt')]
[('lf.txt', '41 0a 42 0a'), ('crlf.txt', '41 0d 0a 42 0d 0a')]
同じ二行の保存形式
ファイルバイト列長さ
lf.txt41 0A 42 0A4 B
crlf.txt41 0D 0A 42 0D 0A6 B
4
lf = Path('lf.txt').read_text(encoding='utf-8'); crlf = Path('crlf.txt').read_text(encoding='utf-8'); lf, crlf, lf == crlf
('A\nB\n', 'A\nB\n', True)
5
raw = '\u3042'.encode('utf-8'); Path('message.txt').write_bytes(raw)
3
6
Path('message.txt').read_text(encoding='utf-8')
'あ'
7
wrong = raw.decode('latin-1'); [f'U+{ord(c):04X}' for c in wrong]
['U+00E3', 'U+0081', 'U+0082']
8
changed = wrong.encode('utf-8'); raw.hex(' '), changed.hex(' ')
('e3 81 82', 'c3 a3 c2 81 c2 82')
9
wrong.encode('latin-1').decode('utf-8')
'あ'
10
signed = '\u3042'.encode('utf-8-sig'); Path('bom.txt').write_bytes(signed)
6
11
Path('bom.txt').read_bytes().hex(' ')
'ef bb bf e3 81 82'
BOM付きUTF-8
位置バイト役割
0〜2EF BB BFBOM
3〜5E3 81 82本文「あ」
12
a = signed.decode('utf-8'); b = signed.decode('utf-8-sig'); repr(a), repr(b), len(a), len(b)
("'\\ufeffあ'", "'あ'", 2, 1)
13
Path('lf.txt').read_bytes() == Path('crlf.txt').read_bytes(), lf == crlf
(False, True)

演習完了