为了正确比较而规范Unicode字符串

为了正确比较而规范Unicode字符串

因为Unicode有组合字符(变音字符和附加到前一个字符上的记号,打印时作为一个整体),所以字符串比较起来很复杂。
例如,"café"这个词可以使用两种方式构成,分别由4个和5个码位,但是结果完全一样:

s1 = 'café'
s2 = 'cafe\u0301'
s1, s2
('café', 'café')
len(s1), len(s2)
(4, 5)
s1 == s2
False

U+0301是COMBINING ACUTE ACCENT,加在'e'后面得到'é'。在Unicode标准中,'é'和'e\u0301'这样的序列叫"标准等价物"(canonical equivalent),应用程序应该把它们视作相同的字符,但是Python看到的是不同的码位序列,因此判定二者不相等

这个问题的解决方案是使用unicodedata.normalize函数提供的Unicode规范化。这个函数的第一个参数是这4个字符串中的一个:'NFC'、'NFD'、'NFKC'和'NFKD'。

NFC(Normalization Form C)使用最少码位构成等价的字符串,而NFD把组合字符分解成基字符和单独的组合字符。这两种规范化方式都能让比较行为符合预期

from unicodedata import normalize
s1 = 'café'
s2 = 'cafe\u0301'
len(s1), len(s2)
(4, 5)
len(normalize('NFC', s1)), len(normalize('NFC', s2))
(4, 4)
len(normalize('NFD', s1)), len(normalize('NFD', s2))
(5, 5)
normalize('NFC', s1) == normalize('NFC', s2)
True
normalize('NFD', s1) == normalize('NFD', s2)
True

西方键盘通常能输出组合字符,因此用户输入的文本默认是NFC形式。不过,安全起见,保存文本之前,最好使用normalize('NFC',user_text)清洗字符串。NFC也是W3C(Character Model for the World Wide Web: String Matching and Searching)的规范推荐的规范化形式。

使用NFC时,有些单字符会被规范成另一个单字符。例如电阻的单位欧姆(Ω)会被规范成希腊字母大写的欧米茄。这两个字符在视觉上是一样的,但是比较时并不相等,因此要规范化,防止出现意外:

from unicodedata import normalize,name
ohm='\u2126'
name(ohm)
'OHM SIGN'
ohm_c=normalize('NFC',ohm)
name(ohm_c)
'GREEK CAPITAL LETTER OMEGA'
ohm==ohm_c
False
normalize('NFC',ohm)==normalize('NFC',ohm_c)
True

在另外两个规范化形式(NFKC和NFKD)的首字母缩略词中,字母K表示'compatibility'(兼容性)。这两种是比较严格的规范化形式,对'兼容字符'有影响。虽然Unicode的目标是为各个字符提供“规范的”码位,但是为了兼容现有标准,有些字符会出现多次。例如,虽然希腊字母表中有“μ”这个字母(码位是U+03BC,GREEK SMALL LETTER MU),但是Unicode还是加入了微符号'µ'(U+00B5),以便与latin1相互转换。因此微符号是一个“兼容字符”。

在NFKC和NFKD格式中,各个兼容字符会被替换成一个或多个“兼容分解”字符,即便这样有些格式损失,但仍是“首选”表述——理想情况下,格式化是外部标记的职责,不应该由Unicode处理。

二分之一"½"(U+00BD)经过1兼容分解后得到的是三个字符序列'1/2';微符号‘µ’(U+00B5)经过兼容分解后得到的是小写字母'μ'(U+03BC)

from unicodedata import normalize, name
half = '½'
normalize('NFKC', half)
'1⁄2'
four_squared='4²'
normalize('NFKC',four_squared)
'42'
micro = 'µ'
micro_kc = normalize('NFKC', micro)
micro, micro_kc
('µ', 'μ')
ord(micro), ord(micro_kc)
(181, 956)
name(micro), name(micro_kc)
('MICRO SIGN', 'GREEK SMALL LETTER MU')

使用'1/2'替‘½’可以接受,微符号也确实是小写的希腊字母'μ',但是把'4²'转换成‘42’就改变原意了。某些应用可以把‘4²’保存为'42',但是可以为搜索和索引提供便利的中间表述:用户搜索'1/2 inch'时,如果还能找到包含'½ inch'的文档,那么用户会感到满意

使用NFKC和NFKD规范化形式时要小心,而且不能在特殊情况中使用,例如搜索和索引,而不能用于持久存储,因此这两种转换会导致数据丢失

大小写折叠

大小写折叠其实就是把所有文本变成小写,再做些其他转换。这个功能有str.casefold()方法支持。

对于只包含latin1字符的字符串s,s1.casefold()得到的结果和s.lower()一样,唯有两个例外:微符号‘µ’会变成小写的希腊字母‘μ’(在大多数字体中二者看起来一样);德语Eszett(‘sharp s',ß)会变成'ss'。

micro='µ'
name(micro)
'MICRO SIGN'
micro_cf=micro.casefold()
name(micro_cf)
'GREEK SMALL LETTER MU'
micro,micro_cf
('µ', 'μ')
eszett='ß'
name(eszett)
'LATIN SMALL LETTER SHARP S'
eszett_cf=eszett.casefold()
eszett,eszett_cf
('ß', 'ss')

自从Python3.4开始,str.casefold()和str.lower(0得到不同结果的有116个码位。Unicode6.3命名了110122个字符,这只占0.11%。

与Unicode相关的任何问题一样,大小写折叠是个复杂的问题,有很多语言上的特殊情况,但是Python核心团队尽力提供了一种方案,能满足大多数用户的需求

规范化文本匹配实用函数

NFC和NFD可以放心使用,而且能合理比较Unicode字符串。对大多数应用来说,NFC是最好的规范化形式。不区分大小写的比较应该使用str.casefold()。

如果要处理多语言文本,可以自定义函数nfc_equal和fold_equal

from unicodedata import normalize


def nfc_equal(str1, str2):
    return normalize('NFC', str1) == normalize('NFC', str2)


def fold_equal(str1, str2):
    return (normalize('NFC', str1).casefold() == normalize('NFC', s2).casefold())

极端“规范化”:去掉变音符号

Google搜索涉及很多技术,其中一个显然是忽略变音符号(如重音符、下加符等),至少在某些情况下会这么做。去掉变音符号不是正确的规范化方式,因为人们有时很懒,或者不知道怎么正确使用变音符号,而且拼写规则会随时间变化,因此实际语言中的重音经常变来变去。

除了搜索,去掉变音字符还能让URL更易于阅读,至少对拉丁语系语言是如此

# 去掉全部组合记号的函数
import unicodedata 
import string
def shave_marks(text):
    '''去掉全部变音符号'''
    norm_txt=unicodedata,normalize('NFD',txt) # 把所有字符分解成基字符和组合记号
    shaved=''.join(c for c in norm_txt if not unicodedata.conbining(c)) # 过滤掉所有组合记号
    return unicodedata.normalize('NFC',shaved)  # 重组所有字符

通常去掉变音字符是为了把拉丁文本变成纯粹的ASCII,但是shave_marks函数还会修改非拉丁字符(如希腊字符),而只去掉重音符并不能把它们变成ASCII字符。因此,应该分析各个基字符,仅当字符在拉丁字母表中时才删除附加的记号

def shave_marks_latin(txt):
    '''把拉丁基字符中所有的变音符号删除'''
    norm_txt = unicodedata.normalize('NFD', txt) # 把所有字符分解成基字符和组合记号
    latin_base = False
    keepers = []
    for c in norm_txt:
        if unicodedata.combining(c) and latin_base: # 基字符为拉丁字母时,跳过组合记号
            continue # 忽略拉丁基字符上的变音符号
        keepers.append(c) # 
        if not unicodedata.combining(c):# 检测新的基字符,判断是不是拉丁字母
            latin_base = c in string.ascii_letters
    shaved = ''.join(keepers)
    return unicodedata.normalize('NFC', shaved) # 重组所有字符
# 把西文印刷字符转换成ASCII字符
def dewinize(txt):
    return txt.translate(mu)
上一篇:学习ECMAScript 2015【10】Unicode


下一篇:Java| 编码格式介绍(ANSI、GBK、GB2312、UTF-8、GB18030和 UNICODE)