Grundlagenwissen Zeichenkodierung - wie aus Bits und Bytes lesbarer Test wird

- Autor: PGD
Computer arbeiten auf Basis des Binärsystems und daher werden Daten in Form von langen Ketten von Nullen und Einsen gespeichert. Da alle Daten auf diese Weise gespeichert werden ist es essentiell wie diese binären Daten interpretiert werden. Sehen wir uns dazu ein Beispiel in der Python-Shell an:

>>> binary = "01010101"
>>> int(binary, 2)
85
>>> hex(int(binary, 2))
'0x55'
>>> chr(int(binary, 2))
'U'

Zuerst definieren wir den String binary mit dem Wert 01010101. Hierbei ergeben 8 sogenannte Bits (eine Null oder eine Eins) ein Byte. Ein Byte kann also 28 Werte annehmen - das entspeicht dem Bereich von 0 (00000000) bis 255 (11111111). Mit int(binary, 2) rechnen wir den String mit der Binärzahl 01010101 in eine Dezimalzahl (Basis 10) um und erhalten 85 als Ergebnis.

Interpretieren wir dies nun als Hexadezimalzahl mit hex(int(binary, 2)) dann erhalten wir 0x55. Hierbei weißt das 0x vor der Zahl darauf hin, dass es sich um die hexadezimale Schreibweise handelt. Das Hexadezimalsystem basiert auf 16 und nutzt die Zahlen 0 - 9 sowie A - F um die Dezimalzahlen 10 - 15 darzustellen. Somit steht 0x55 für 5 x 16 + 5 also die 85 im Dezimalsystem. Das Hexadezimalsystem wird gern genutzt um binäre Daten kompakter darzustellen da sich damit die Zahlen 0 - 255 als 0x00 - 0xFF darstellen lassen was deutlich kompater und einfache zu lesen ist als 0b00000000 - 0b11111111! Mit dem 0b vor der Zahl kann zB dem Python-Interpreter mitgeteilt werden, dass es sich um eine Binärzahl handelt.

Mit chr(int(binary, 2)) erhalten wir dann das Zeichen, dass sich hinter der Dezimalzahl 85 verbirgt und genau hier kommen Zeichenkodierungen zum tragen...

Das Problem mit den verschiedenen Zeichenkodierungen:


Python kennt dutzende Zeichenkodierungen anhand derer Text interpretiert wird. Eine Liste der verschiednen Kodierungen findet sich unter https://docs.python.org/3/library/codecs.html#standard-encodings. Sehen wir uns dazu ein einfaches praktisches Beispiel an:

#!/usr/bin/env python3
with open("text", "w", encoding="iso-8859-1") as f:
    f.write("Test! äöü")

for i in range(1, 17):
    if i == 12:
        continue
    
    try:
        with open("text", "r", encoding="iso-8859-" + str(i)) as f:
            print(f"ISO-8859-{i:<2} == {f.read()}")
    except UnicodeDecodeError:
        print(f"ISO-8859-{i:<2} == UnicodeDecodeError")

Zuerst öffnen wir die Datei text zum schreiben und verwenden dazu die Zeichenkodierung iso-8859-1. Mit f.write(...) schreiben wir dann den Text Test! äöü in diese Datei. Da wir in Python3-Dateien UFT-8 als Zeichenkodierung verwenden kümmert sich der Interpreter hierbei darum, dass der Text "Test! äöü" von UFT-8 zu ISO-8859-1 kodiert wird und dann die entsprechenden binären Daten in dieser Datei landen.

Danach versuchen wir die Datei in allen ISO-Kodierungen zu lesen und geben uns das Ergebnis aus:

ISO-8859-1  == Test! äöü
ISO-8859-2  == Test! äöü
ISO-8859-3  == Test! äöü
ISO-8859-4  == Test! äöü
ISO-8859-5  == Test! фіќ
ISO-8859-6  == UnicodeDecodeError
ISO-8859-7  == Test! δφό
ISO-8859-8  == UnicodeDecodeError
ISO-8859-9  == Test! äöü
ISO-8859-10 == Test! äöü
ISO-8859-11 == UnicodeDecodeError
ISO-8859-13 == Test! äöü
ISO-8859-14 == Test! äöü
ISO-8859-15 == Test! äöü
ISO-8859-16 == Test! äöü

Hierbei sehen wir beispielsweise, dass ISO-8859-1 bis ISO-8859-4 sowohl mit den Zeichen Test! als auch den Umlauten äöü klarkommen. Bei ISO-8859-5 und ISO-8859-7 werden die Umlaute als etwas anderes interpretiert (фіќ bzw. δφό) und bei ISO-8859-6 / 8 / 11 kommt es zu einer UnicodeDecodeError Exception und das Programm könnte die Daten gar nicht erst lesen.

Noch schlimmer wird es bei folgendem Beispiel:

#!/usr/bin/env python3
with open("text", "w", encoding="iso-8859-1") as f:
    f.write("Test! äöü")

with open("text", "r", encoding="cp273") as f:
    print(f"cp273 == {f.read()}")

Hier erhalten wir:

cp273 == èÁËȁ€U6]

Wir können also festhalten, dass wir im besten Fall falsche Daten bekommen und im schlechtesten Fall das Programm abstürzt wenn wir Daten mit einer anderen Zeichenkodierung einlesen als derjenigen mit der die Daten geschrieben wurden. Dieses Problem tritt sowohl bei Textdateien auf als auch bei Emails, Webseiten und vielen anderen Dingen. Da dies immer wieder zu Schwierigkeiten, Fehlern und Problemen führte wurden die Unicode-Zeichenkodierungen der UFT-Familie geschaffen. Heute ist UTF-8 die am weitensten verbreitete Zeichenkodierung.

UTF-8 der Retter in der Not:


Damit wird es nicht mehr nötig für die verschiedenen Sprachen unterschiedliche Zeichenkodierungen zu verwenden denn UTF-8 vereint die Zeichen verschiedener Sprachen in einer einzigen Zeichenkodierung. So können das lateinische Alphabet genau so wie das kyrillische oder griechische Alphabet und auch chinesische und viele weitere Schriftzeichen in der gleichen Zeichenkodierung enthalten sein. Das macht das "Wirrwarr" der verschiedenen Zeichenkodierungen für die verschiedensten Sprachen großteils obsolet. Möglich wird das durch einen einfachen "Trick":

#!/usr/bin/env python3
with open("text", "w", encoding="utf-8") as f:
    f.write("Test! äöü")

with open("text", "rb") as f:
    print(f.read())

Dieses Programm liefert:

b'Test! \xc3\xa4\xc3\xb6\xc3\xbc'

Hierbei deutet das b vor dem Sting an, dass es sich um ein Bytearray handelt. Dieses wird zwar von Python beinahe wie ein String dargestellt aber in Wahrheit ist es nur eine für den User aufbereitete Darstellung. Uns fällt allerdings auf, dass einige Werte beispielsweise als \xc3 dargestellt werden. Dies ist der hexadezimale Wert von Sonderzeichen. Weiters fällt uns auf, dass wir die Umlaute äöü in die Datei geschrieben haben aber wir erhalten die folgenden sechs Zeichen: \xc3\xa4\xc3\xb6\xc3\xbc

Das liegt daran, dass man mit "nur" 256 möglichen Werten (die Werte 0 - 255 die mit einem Byte darstellbar wären) nicht alle Zeichen aller Sprachen abbilden kann. Daher verwendet UTF-8 ein Zeichen für die latainischen Buchstaben und Satzzeichen und nutzt mehrere Bytes um Sonderzeichen und Buchstaben anderer Alphabete darzustellen. Somit entspricht zB \xc3\xa4 dem ä, usw.

Verändern wird das Programm ein Wenig um die hexadezimalen Werte der einzelnen Bytes zu sehen:

#!/usr/bin/env python3
with open("text", "w", encoding="utf-8") as f:
    f.write("Test! äöü")

with open("text", "rb") as f:
    data = f.read()

for byte in data:
    print(hex(byte))

Dann erhalten wir:

0x54
0x65
0x73
0x74
0x21
0x20
0xc3
0xa4
0xc3
0xb6
0xc3
0xbc

Lesen wir nun wieder die Datei in jeder ISO-Kodierung ein:

#!/usr/bin/env python3
with open("text", "w", encoding="utf-8") as f:
    f.write("Test! äöü")

for i in range(1, 17):
    if i == 12:
        continue
    
    try:
        with open("text", "r", encoding="iso-8859-" + str(i)) as f:
            print(f"ISO-8859-{i:<2} == {f.read()}")
    except UnicodeDecodeError:
        print(f"ISO-8859-{i:<2} == UnicodeDecodeError")

Und wir erhalten:

ISO-8859-1  == Test! äöü
ISO-8859-2  == Test! äÜß
ISO-8859-3  == UnicodeDecodeError
ISO-8859-4  == Test! äÃļÃŧ
ISO-8859-5  == Test! УЄУЖУМ
ISO-8859-6  == UnicodeDecodeError
ISO-8859-7  == Test! Àâü
ISO-8859-8  == UnicodeDecodeError
ISO-8859-9  == Test! äöü
ISO-8859-10 == Test! ÃĪÃķÞ
ISO-8859-11 == Test! รครถรผ
ISO-8859-13 == Test! Ć¤Ć¶Ć¼
ISO-8859-14 == Test! ÃĊöÃỳ
ISO-8859-15 == Test! ÀöÌ
ISO-8859-16 == Test! Ă€Ă¶ĂŒ

Sehen wir uns nun die Ausgabe von ISO-8859-1 genauer an und dekodieren diese von Hand mit der folgenden Tabelle:

  …0 …1 …2 …3 …4 …5 …6 …7 …8 …9 …A …B …C …D …E …F
0… nicht belegt
1…
2… SP ! " # $ % & ' ( ) * + , - . /
3… 0 1 2 3 4 5 6 7 8 9 : ; < = > ?
4… @ A B C D E F G H I J K L M N O
5… P Q R S T U V W X Y Z [ \ ] ^ _
6… ` a b c d e f g h i j k l m n o
7… p q r s t u v w x y z { | } ~  
8… nicht belegt
9…
A… NBSP ¡ ¢ £ ¤ ¥ ¦ § ¨ © ª « ¬ SHY ® ¯
B… ° ± ² ³ ´ µ · ¸ ¹ º » ¼ ½ ¾ ¿
C… À Á Â Ã Ä Å Æ Ç È É Ê Ë Ì Í Î Ï
D… Ð Ñ Ò Ó Ô Õ Ö × Ø Ù Ú Û Ü Ý Þ ß
E… à á â ã ä å æ ç è é ê ë ì í î ï
F… ð ñ ò ó ô õ ö ÷ ø ù ú û ü ý þ ÿ

Dann sehen wir, dass zB 0x54 für das T steht oder 0xc3 für das à (in Gelb hervorgehoben)! Eine Zeichenkodierung ist also nichts weiter als eine Tabelle mit der die einzelnen Werte der binären Bytes einem Buchstaben oder Schriftzeichen zugeordnet werden.

Betrachten wird die UnicodeDecodeError Exception bei ISO-8859-3 und die dazugehörige Tablle unter https://en.wikipedia.org/wiki/ISO/IEC_8859-3 dann wird uns gleich auffallen, dass 0xc3 in dieser Zeichenkodierung nicht genutzt wird und daher auch nicht zu einem Zeichen aufgelöst werden kann. Darum kommt es zu der UnicodeDecodeError Exception!

Die nicht belegten Teile in den gezeigten Tabellen sind mit Steuerzeichen und sogenannten Whitespaces (Zeilenschaltung, Tabulator, Leerzeichen, ...) belegt die aus der ASCII-Tabelle stammen.