Die Rache des "Ö"

Immer wenn ich glaube, ich habe Unicode verstanden, kommt das Ding ums Eck und tritt mir gegen das Schienbein. So auch bei einem Projekt zur Konvertierung von Auftragsdaten. Die Abfrage war banal und natürlich wurden alle Strings brav als Unicode-Strings abgelegt. Vereinfacht sah der Code so aus:

# spezialfall fuer Daten aus AT
if country == u'Österreich':
 machwas()

Der Test für den Code schlug fehl, weil der Inhalt der Variablen country offenbar nicht den Inhalt «Österreich» hatte. Die Datei im Editor geöffnet und was steht da: Österreich! WAT?

Beide Dateien (mein Sourcecode und die Auftragsdatei) waren UTF-8 kodiert, daran kann es also nicht liegen. Somit bleibt fürs Erste nur der Griff zum Hex-Editor, um die Auftragsdatei genauer anzusehen.

Sourcecode Auftragsdatei
c3 96 73 74 65 72 72 65 69 63 68 4F CC 88 73 74 65 72 72 65 69 63 68
Na immerhin wusste ich nun, warum der Vergleich fehl schlug. Jetzt bliebt nur noch die Frage, wieso ein «Ö» in UTF-8 nicht ein «Ö» ist. Der Grund nennt sich «Unicode Normalform» und zeigt, dass Stringvergleiche seit den alten ASCII-Zeiten deutlich anspruchsvoller geworden sind.

Ein «Ö» ist in Unicode entweder ein normales «Ö» (mit dem Namen LATIN CAPITAL LETTER O WITH DIAERESIS) oder aber ein großes «O», kombiniert mit den beiden Punkten (also der den beiden Codepoints LATIN CAPITAL LETTER O und COMBINING DIAERESIS). Klar, dass das zwei verschiedene Bytefolgen ergibt.

"Wenn Du Strings vergleichen willst, vergleiche Strings und keine Bytefolgen" 

Nun gut, wie komme ich nun dazu, heraus zu finden, dass ein «Ö» ein «Ö» ist? Dazu gibt es in den Unicode-Bibliotheken der einzelnen Programmiersprachen entsprechende Methoden, die ich hier am Beispiel von Python kurz zeigen werde. Zuerst holen wir uns ein ‘normales’ «Ö» und lassen uns den Namen des Codepoints dafür anzeigen.

# -*- coding: utf-8 -*-
import unicodedata

composed_oeh = unicodedata.lookup(u'LATIN CAPITAL LETTER O WITH DIAERESIS')

# wie heisst der codepoint? Na wie wohl ...
print(unicodedata.name(composed_oeh))

# wuerde es auch ein " composed_oeh = 'Ö' " tun?
print(unicodedata.name(u'Ö'))

Jetzt bauen wir das «Ö» aus den Einzelteilen zusammen:

decomposed_oeh = unicodedata.lookup(u'LATIN CAPITAL LETTER O') + \
unicodedata.lookup(u'COMBINING DIAERESIS')

print(decomposed_oeh)

Was hier ausgegeben wird, sieht aus wie ein «Ö» und ist vom «Ö» oben visuell nicht zu unterscheiden (zumindest, wenn Betriebssystem und Font unicode-tauglich sind).

Wie kann ich die einzelnen Formen nun ineinander umwandeln? Hierzu existiert im Modul unicodedata die Methode normalize().  Der erste Parameter gibt die Normalform an, in die gewandelt werden soll, der zweite Parameter ist der zu wandelnde Unicode-String. Für die Normalform ist einer der folgenden Strings möglich:  ‘NFC’, ‘NFKC’, ‘NFD’ oder ‘NFKD’.

# das hier ergibt wieder LATIN CAPITAL LETTER O WITH DIAERESIS
norm_oeh = unicodedata.normalize('NFC', decomposed_oeh)
print(unicodedata.name(norm_oeh))

NFC steht für «normal form C» (für composition) und bewirkt eine zweistufige Verarbeitung: zuerst werden alle Zeichen zerlegt (decomposition, was der Angabe NFD entspricht), anschließend wird für alle Zeichen die Zusammenstellunf (composition) durchgeführt. Das Ergebnis ist dann wieder das «normale Ö». Weitere Details zu den einzelnen Normalformen finden sich in der Wikipedia.

Die Normalform C ist die am weitesten verbreitete Normalform und wird vom W3C auch für HTML, XML und JavaScript definiert. Technisch gesehen sind Kodierung z.B. in Latin1 (oder Windows Codepage 1252) in der Normalform C, da hier ja ein «Ö» als ein Zeichen vorliegt und nicht aus kombinierenden Zeichen zusammengesetzt wird. Auch Windows und das .Net-Framework legt Unicode-Strings in der Normalform C ab.

Dies bedeutet nun nicht, dass sich NFD ignorieren lässt. Denn beispielsweise arbeitet das Mac OSX Dateisystem mit einer Variante von NFD-Daten, da der Unicode-Standard erst nach dem Design von OSX fertig gestellt wurde.

Über die Methode unicodedata.decomposition(unichr) lässt sich das «decomposition mapping», also die Liste der Codepoints, in die sich das Zeichen in unichr zerlegen lässt, ausgeben. Falls dies nicht möglich ist, wird ein leerer String zurück gegeben.

Zum Schluss des Ausflugs  ein kurzes Listing, das zeigt, dass der Vergleich nach der Normalisierung für beide Formen von «Ö» wieder wahr ist.

# -*- coding: utf-8 -*-
import unicodedata

composed_oeh = unicodedata.lookup(u'LATIN CAPITAL LETTER O WITH DIAERESIS')

decomposed_oeh = unicodedata.lookup(u'LATIN CAPITAL LETTER O') + \
                 unicodedata.lookup(u'COMBINING DIAERESIS')

s1 = composed_oeh + u'sterreich'
s2 = decomposed_oeh + u'sterreich'
print("Nicht normalisiert:", s1 == s2)

s1 = unicodedata.normalize('NFC', s1)
s2 = unicodedata.normalize('NFC', s2)
print("Normalisiert:", s1 == s2)

Österreich ist gerettet ;-) und ich habe wieder gelernt, dass eine eingehende Beschäftigung mit Unicode mittlerweile für jeden Entwickler unerlässlich ist.

Share Kommentieren
X

Ich habe einen Kommentar zum Artikel

Sie können die Kommentarfunktion ohne die Speicherung personenbezogener Daten nutzen. Schreiben Sie Ihren Kommentar und klicken Sie auf "Abschicken", der Versand erfolgt per Mail von meinem Auftritt aus an mich zur Prüfung. Dieser Versand und die Übertragung Ihres Kommentars ist zur Erfüllung der von Ihnen mit dem Klick auf "Abschicken" ersichtlichen Absicht technisch notwendig und bedarf keiner weiteren Einwilligung.

Wichtiger Hinweis: Sie haben keinen Anspruch auf die Veröffentlichung Ihres Kommentars. Jeder hier eingegebene Kommentar wird zuerst geprüft. Ich behalte mir die Entscheidung vor, welche Kommentare ich als Ergänzung an den Artikel anfüge.