Python で Unicode 正規化 NFC/NFD の文字列を扱う
先日、ビジネスパーソン向けの Python 本を執筆したことを書きました。
本稿では本書のことを「できるPy」と呼びます。
Amazon でいくつかカスタマーレビューもいただいて次のコメントをみつけました。
python3.7 対応ということで、pathlib を使ってる点が(古いpython は切り捨てる!的なところは)潔いと言えば潔いし、日本語のファイル名にも気を配っている記述はオライリーに期待するのは酷なところもある。でもこの本でもNFD問題は全くの記述無し。だめだろ、それじゃ。
まさに仰る通りです。執筆時にそのことに気づかずご指摘いただいてありがとうございます。
ここでご指摘されている NFD 問題というのは、ファイル名のみに限った問題ではなく、Unicode の文字集合を扱ってエンコード/デコードするときに発生する問題です。ASCII 文字しか扱わない開発者が書いたコンテンツとの差別化を図るという意味でも付加価値になり得るコンテンツだと私も思います。
本稿では「できるPy」に掲載できなかった NFC/NFD 問題について説明しようと思います。私の一存では決められませんが、本書を改訂できるタイミングがあれば、出版社と相談して本稿の内容も追加してもらうように働きかけようと考えています。
本稿で紹介するサンプルデータならびにサンプルコードは次の URL からダウンロードできます。
NFC/NFD 問題とは
Unicode は世界中のすべての言語で使われる文字を扱う文字集合です。Python は内部的に Unicode で文字列を扱います。例えば、UTF-8 でエンコードされたファイルの内容を読み込むコードが次になります。
import sys filename = sys.argv[1] with open(filename, encoding='utf-8') as f: for line in f: print(line.strip())
Unicode では1つの文字を表すのに複数の文字を組み合わせることができます。そして、同じ文字が別々の表現だと検索や置換などに不都合があるのでどれか1つの表現に統一することを 正規化 と呼びます。その正規化形式の名前の頭文字をとって NFC、NFD と呼びます。
- NFC: Normalization Form Canonical Composition (合成済みの文字)
- NFD: Normalization Form Canonical Decomposition (複数文字を結合した文字列)
言葉だけではわかりにくいので実際にその違いをサンプルデータとともに説明します。いま NFC/NFD で正規化されたテキストファイルを前述したプログラムを使って中身を表示してみます。
実行結果。
$ python3 read_file_and_print.py NFC_sample1.txt プログラミング $ python3 read_file_and_print.py NFD_sample1.txt プログラミング
どちらも「プログラミング」と表示されました。OS 環境や OS バージョンによって正しく表示されない可能性もあります。私の環境 (macOS 10.14.4) では見た目上は全く同じに表示されます。
このとき変数 line には UTF-8 でデコードされて Unicode の文字列がセットされます。次に NFC/NFD 問題をイメージしやすいように Unicode の文字列を文字単位で扱うサンプルを紹介します。
import sys import unicodedata def show_unicode_name(line): for char in line.strip(): name = unicodedata.name(char) space = ' ' if unicodedata.combining(char) != 0: space += ' ' print(f'{char}{space}: {name}') filename = sys.argv[1] with open(filename, encoding='utf-8') as f: for line in f: text = [char for char in line.strip()] print(f'文字単位: {text}, 長さ: {len(text)}') show_unicode_name(line)
実行結果。
NFC で正規化されたテキストを文字単位で分割して Unicode データベースに登録されている名前を表示する。
$ python3 read_file_and_show_unicode_name.py NFC_sample1.txt 文字単位: ['プ', 'ロ', 'グ', 'ラ', 'ミ', 'ン', 'グ'], 長さ: 7 プ : KATAKANA LETTER PU ロ : KATAKANA LETTER RO グ : KATAKANA LETTER GU ラ : KATAKANA LETTER RA ミ : KATAKANA LETTER MI ン : KATAKANA LETTER N グ : KATAKANA LETTER GU
NFD で正規化されたテキストを文字単位で分割して Unicode データベースに登録されている名前を表示する。
$ python3 read_file_and_show_unicode_name.py NFD_sample1.txt 文字単位: ['フ', '゚', 'ロ', 'ク', '゙', 'ラ', 'ミ', 'ン', 'ク', '゙'], 長さ: 10 フ : KATAKANA LETTER HU ゚ : COMBINING KATAKANA-HIRAGANA SEMI-VOICED SOUND MARK ロ : KATAKANA LETTER RO ク : KATAKANA LETTER KU ゙ : COMBINING KATAKANA-HIRAGANA VOICED SOUND MARK ラ : KATAKANA LETTER RA ミ : KATAKANA LETTER MI ン : KATAKANA LETTER N ク : KATAKANA LETTER KU ゙ : COMBINING KATAKANA-HIRAGANA VOICED SOUND MARK
NFD で正規化された文字列では濁音や半濁音が別の文字として扱われていて、Unicode の文字として数えたときの文字数も異なっていることがわかります。例えば「プ (U+30D7)」と、「フ (U+30D5)」と合成用半濁点 (U+309A) の組み合わせで表す「プ」の文字は Unicode 上では別の文字なのでこの違いにより、検索や置換などで一致しないということが発生します。
このような Unicode における正規化の違いによって起こる問題のことを NFC/NFD 問題と呼ばれたりします。
NFC/NFD 問題が発生する状況
OS によって Unicode の正規化形式が異なります。Windows/Linux では NFC を採用しており、昔の macOS (Mac OS X) では NFD を採用していました。同じプラットフォーム上で日本語ファイル名や日本語のテキストを扱っている分には、この問題に遭遇することは少ないかもしれません。
また macOS では NFD を採用しているという記事をみかけますが、最近の macOS や iOS では NFC を使うように変わっているそうです。ファイルシステムが HFS+ から Apple File System (APFS) に置き換えられたときに変わったのかもしれません。私はその背景に明るくないため、詳しい方がいましたら教えてください。
私の環境は macOS 10.14.4 ですが、APFS を使っているせいか、NFC で Unicode 正規化が行われます。
$ mount /dev/disk1s1 on / (apfs, local, journaled)
この NFC/NFD 問題は昔の macOS (Mac OS X) で作られた日本語ファイル名や、Unicode を文字集合に用いる符号化方式でエンコードされたコンテンツを Windows/Linux 環境で扱うとき、もしくはその逆のときに発生します。一方で NFD を採用していたときの macOS (Mac OS X) であっても、例えば Web ブラウザからコピペしたコンテンツは NFC で扱われたりして、同じプラットフォーム上でも NFD と NFC が混在してしまうといったことも発生するそうです。そして、見た目上は全く同じ文字にみえるため、この問題に気付くのが難しいというのが NFC/NFD 問題の厄介なところです。
Python で NFD を NFC に正規化する
さて本題です。ここまでで NFC/NFD 問題の概要を説明しました。
Python では標準ライブラリで Unicode データベースを扱うライブラリ unicodedata が提供されています。このライブラリを使うことで Unicode 文字の情報を取得したり、読み込んだ文字列に対して NFC/NFD といった正規化形式の変換をやり直すこともできます。
unicodedata.normalize() の引数に NFC や NFD といった正規化形式と Unicode 文字列を渡すと変換できます。
unicodedata.normalize('NFC', unistr)
また結合文字かどうかは unicodedata.combining() に文字を渡してゼロ以外が返ってくるかどうかで判断できます。
unicodedata.combining(chr)
次のサンプルコードでは、濁音、半濁音などの結合文字があるかどうかを調べて、結合文字がある場合は NFC に変換します。
import sys import unicodedata def is_nfd(line): for char in line.strip(): if unicodedata.combining(char) != 0: return True return False def show_unicode_name(line): for char in line.strip(): name = unicodedata.name(char) space = ' ' if unicodedata.combining(char) != 0: space += ' ' print(f'{char}{space}: {name}') filename = sys.argv[1] with open(filename, encoding='utf-8') as f: for line in f: text = [char for char in line.strip()] print(f'文字単位: {text}, 長さ: {len(text)}') show_unicode_name(line) if is_nfd(line): print('NFD から NFC への変換') converted = unicodedata.normalize('NFC', line) show_unicode_name(converted)
実行結果。
$ python3 read_file_and_normalize.py NFD_sample1.txt 文字単位: ['フ', '゚', 'ロ', 'ク', '゙', 'ラ', 'ミ', 'ン', 'ク', '゙'], 長さ: 10 フ : KATAKANA LETTER HU ゚ : COMBINING KATAKANA-HIRAGANA SEMI-VOICED SOUND MARK ロ : KATAKANA LETTER RO ク : KATAKANA LETTER KU ゙ : COMBINING KATAKANA-HIRAGANA VOICED SOUND MARK ラ : KATAKANA LETTER RA ミ : KATAKANA LETTER MI ン : KATAKANA LETTER N ク : KATAKANA LETTER KU ゙ : COMBINING KATAKANA-HIRAGANA VOICED SOUND MARK NFD から NFC への変換 プ : KATAKANA LETTER PU ロ : KATAKANA LETTER RO グ : KATAKANA LETTER GU ラ : KATAKANA LETTER RA ミ : KATAKANA LETTER MI ン : KATAKANA LETTER N グ : KATAKANA LETTER GU
まとめ
Python で Unicode の文字列が NFC/NFD のどちらの形式で正規化されているか、また別の正規化形式に変換する方法を紹介しました。
最近の macOS が NFC を採用していることからいまが過渡期で NFC/NFD 問題は徐々に NFC に統一されていって将来的にはこういった問題が起こらなくなるのかもしれません。一方で NFD という表現形式も Unicode で定義されている仕組みであり、この仕組み自体がなくなるわけではありません。
本稿では濁音・半濁音のみを紹介しましたが、特殊な記号を複数の記号の組み合わせで表現することもできます。Unicode にはこのような表現形式があるというのを覚えておくといつか役に立つことがあるかもしれません。
リファレンス
www.slideshare.net