Unicode等价性与正规化

Wan Xiao
9 min readJun 26, 2022

--

本文主要介绍Unicode等价性(Unicode equivalence)和正规化(normalization)相关的概念。

此前我有写一篇基础字符编码知识,其中有介绍关于UTF-16代理对、变种UTF-8相关的知识,这些概念如果不注意,很容易引入一些奇怪的问题和崩溃。本文介绍的Unicode等价性和正规化的概念,对于某些字符串处理软件,同样是非常重要的。

阅读文本前建议先阅读基础字符编码知识,前文中所述的基础知识和名词这里不会再叙述一遍。

什么是Unicode等价性

Unicode等价性,是说某些码点(code point)序列,实际上是和另一个码点是等价的。
通俗来讲,就是可能存在两个或多个Unicode字符组成的序列,和另一个Unicode字符是等价的。
Unicode等价性有两种,标准等价(canonically equivalent)和兼容等价(compatible)。

标准等价

无论在印刷或展示时,码点序列和某个码点对应的字符有相同的外观和含义。比如U+006E(n),其后紧跟一个U+0303( ̃ ),在unicode中标准等价于U+00F1(ñ),因此,这两个序列在展示时完全一样,无论是字符排序或者搜索,均可以互相替换。同样的,韩语音节块,也可以被替换为2~3个码点的序列。

兼容等价

在展示时,可能有不同的外观,但在某些上下文中,有相同的含义,比如,U+FB00(ff)与序列U+0066 U+0066(两个f)是兼容等价,而非标准等价。在某些应用中,兼容等价序列可能会被以相同的方式使用/替代,但其他情况下可能不会。

可以看出,标准等价比兼容等价更严格,是兼容等价的子集。
标准等价一定是兼容等价,但兼容等价不一定是标准等价。

为什么需要Unicode等价性

字符重复

由于兼容或者其他原因,Unicode有时将不同的码点分配给实际上相同的字符,比如字符Å,可以被编码为U+00C5(标准名:LATIN CAPITAL LETTER A WITH RING ABOVE),也可以被编码为U+212B(标准名:ANGSTROM SIGN)。大部分符号都只有单个码点。如果不同的码点序列表示完全等价的字符,即这些字符在渲染时完全相同,则这些码点序列被认为是正规等价的。

组合及预组字符

为了和某些古老的标准保持一致,unicode在给许多看起来像是在其他字符上做了修改的字符分配了单独的码点,比如U+00F1(ñ)、U+00C5(Å)。

为了和其他标准保持一致,以及为了更大的灵活性,Unicode同样提供了一些码点,这些码点并不是单独使用的,而是代表修改或组合之前的基本字符,比如U+3099(无法展示,请看图)。

が(U+304C) 可以视为 か (U+304B)后面跟一个U+3099。这样的例子还有很多,以下只截取了部分:

在Unicode中,字符组合(character composition)是将基础字符及后面跟着的一个或多个结合字符(combine character)组合成一个预组字符(precomposed character)。字符分解(character decomposition)则是相反的过程。

一般来说,预组字符被定义成标准等价于其基础字符加上后面跟的组合附加符号,无论附加符号顺序如何,但也有例外,参考下面的标准顺序小节

排版惯例

出于美观的要求,unicode同样给某些字符,或者某些字符组合提供了码点,比如U+FB00(ff)、U+0132(IJ)这样的合字(ligature),半角片假名,全角字符。或者是给某个字符添加新的语义,比如处于上下角标位置的数字,或者带圈数字①(U+2460)。这种序列通常认为是兼容等价原字符,但并不标准等价,毕竟他们的语义上有区别,且影响了文字的渲染。

正规化

实现Unicode字符搜索和比较的字处理软件,需要将等价码点考虑进去,如果忽略了这些,就会导致bug。例如一段文本中,存在两个视觉上完全一样的字符,他们是标准等价的,但使用不同的码点表示,当用户以其中一个字符搜索另一个字符时,程序无法正确搜索到。

Unicode标准定义了文本正规化过程,叫做Unicode正规化(Unicode normalization),用于替换等价序列,这样两个等价的文本最终能产生相同的码点序列,这个相同的码点序列就叫做原文本的规范化形式(normalization form)/正规形式(normal form)。

正规形式有两种,一种叫完全组合(fully composed),即尽可能将多个码点替换为单个码点;另一种叫完全分解(fully decomposed),即尽可能将单个码点分解为多个码点。

又由于等价形式有标准等价和兼容等价,因此Unicode提供的正规化算法,一共有四种:

字处理软件在实现比较和搜索Unicode字符串时,使用分解还是组合形式,对结果没有影响,但使用标准等价还是兼容等价就会有影响。

比如对应合字U+FB03(ffi),罗马数字U+2168(Ⅸ)或者上下角标字符,例如U+2075(⁵)都有他们自己的码点。如果使用NFC做正规化不会影响他们,因为他们无法被标准等价分解。但如果使用NFKC会把他们分解为组成他们的字符,比如U+FB03(ffi)被分解为兼容等价的三个字符f f i,同时因为是兼容等价,不是标准等价,因此不会再被标准等价组合。此时如果搜索U+0066(f),就会在NFKC中能成功搜索到f(由U+FB03(ffi)兼容等价分解得到),但在NFC中就搜索不到。

这些转换算法都是幂等的,也就是说对一个已经被正规化的字符串,用同样的正规化算法再处理一次,它的内容不会被改变。

但他们并不满足单射,即经过正规化后,无法转换回之前的形式,更不满足双射。比如U+212B(Å)和U+00C5(Å)都会被NFD和NFKD展开为U+0041(A) U+030A(◌̊),然后被NFC或者NFKC组合为U+00C5(Å)。

正规化转换算法对字符串拼接不封闭,即两个正规化的字符串拼接后,可能破坏正规化,参考下表:

标准顺序

标准顺序(canonical ordering)主要跟组合字符的排序有关。Unicode给每一个字符分配了一个组合类别(combining class),由一个数字标识,非组合字符类别数为0,组合字符组合类别大于0。要想获取标准顺序,每一个有非0组合类别值的字符子串都需要按组合类别值(combine class value)进行从小到大的稳定排序。之所以进行稳定排序,是因为有相同组合类别值的组合字符,其不同的顺序被假定为会影响排版,因此它们的不同顺序之间并非等价。

比如U+1EBF(ế),它的正规分解形式是U+0065 (e) U+0302 ( ̂ ) U+0301 ( ́ ),后面两个字符U+0302 ( ̂ )和U+0301 ( ́ )的组合类别值均为230,因此U+1EBF(ế)和U+0065 (e) U+0301 ( ́ ) U+0302 ( ̂ )并不等价。

因此并非所有的组合顺序都有等价的预组字符,比如U+0065 (e) U+0301 ( ́ ) U+0302 ( ̂ )只能组合为U+00E9(é) U+0302( ̂ )。

Unicode标准中定义了canonical decomposition mapping,在里面可以查到每个字符的的标准分解形式。

需要注意什么

字符串的比较和搜索,需要注意Unicode等价性,即便中文,也有等价的情况,比如U+2F8A6(慈)就和U+6148(慈)标准等价。

另外我知道在泰语中,存在非常多组合字符,所以泰语输入法是可以输入这些字符的,包括韩语、日语可能都有这种情况。在处理字符串的时候,需要注意判断字符串的length,可能和用户实际看到的字符串的长度,并不一定对得上,把用户的输入内容发给后端时,也需注意Unicode等价性可能带来的问题。

另外如果两个程序共享一段unicode数据,双方所使用的正规化算法的差异,也可能导致数据丢失。

老实说,我暂时还没有遇到Unicode等价性的问题,所以在需要注意的方面无法给读者多少预警和帮助,但理解了本文的内容,应该会对你识别和解决Unicode等价性导致的问题,有一定帮助。

参考链接

https://www.unicode.org/Public/9.0.0/ucd/UnicodeData.txt
https://minaret.info/test/normalize.msp
https://r12a.github.io/app-conversion/
https://www.compart.com/en/unicode/combining

--

--