归一化:同一个 Å 的几种写法
Å 既可以是单个码点 U+00C5,也可以是 A 加组合环 U+030A 两个码点;A、①、𝕏 则与 A、1、X 语义相关而字形不同。这些写法渲染出来一样或相近,字节序列却不同,===
一律判不等。Unicode 把这类「同字多形」的关系写进码点档案,并定义四种归一化形式(NFC、NFD、NFKC、NFKD)把它们收敛到一形。大小写差异不在归一化职责内,那是 大小写 页的 case folding。
1 · 规范等价与兼容等价
同一个抽象字符与码点序列并非一一对应,Unicode 把互相等价的写法分成两层。规范等价指字形与语义完全相同,只是预组合与分解之别,或组合记号排列不同,Å 的两种写法即属此层,NFC 与 NFD 处理它,无损且可逆。兼容等价指语义相关而字形或格式不同,全角 A 对
A、① 对 1、fi 对 fi 都在这层,只有带 K 的 NFKC 与 NFKD 会合并,代价是丢掉「全角」「带圈」「连字」这层信息。
| 尽量合成 | 彻底分解 | |
|---|---|---|
| 规范等价 | NFC,存储与比较的通用默认 | NFD |
| 兼容等价 | NFKC,搜索与去重用,有损 | NFKD |
这两层不靠临场判断,而是查每个码点在 UCD 里的两个字段算出来的。Decomposition_Mapping 记录一个码点分解成哪些码点,映射是否带 <compat> 一类的标记,正是两层的分界;Canonical_Combining_Class(ccc)则在一个基字符挂多个组合记号时决定标准次序,归一化按它重排,细节见
组合记号。
2 · 四种形态
四种形式作用于同一个输入常给出四种结果。NFC 与 NFD 只在合成与分解之间转换,字形不变;NFKC 与 NFKD 执行兼容分解,字形可能变——① 会变成 1,fi 会拆成 fi。没有预组合形态的输入(例如同时带上点与下点的 q)即便走 NFC 也只能停在分解态,至多按 ccc
重排记号次序。
建议 · NFC 是存储、传输与比较的通用默认,无损。NFKC 把兼容变体归约成基本字符,适合搜索、去重与标识符防伪这类只关心本质的场景,但它有损,不应拿它的输出回存原文。
3 · 归并的类目
兼容分解在 UCD 里都带一个尖括号标记,标明变体类型。全角与半角只是其中一类。
| 标记 | 类目 | NFKC 后 |
|---|---|---|
<wide> <narrow> |
全角与半角 | A→A、ア→ア、全角空格→普通空格 |
<font> |
数学字母变体 | 𝕏→X、ℝ→R |
<circle> |
带圈字母数字 | ①→1、Ⓐ→A |
<square> |
方块单位与缩写 | ㎡→m2、㈱→(株) |
<super> <sub> |
上标与下标 | ²→2、₂→2 |
<fraction> |
分数 | ½→1⁄2(中间是分数斜杠) |
<compat> |
杂项:连字、罗马数字 | fi→fi、Ⅻ→XII |
<noBreak> |
不断行空格 | U+00A0→U+0020 |
<vertical> |
竖排标点变体 | ︱→— |
表里 <compat> 一行在中文文本里最常撞上的不是连字,而是 Kangxi Radicals(U+2F00–U+2FD5):214 个画出来与汉字无异的码点,NFKC 逐个折回对应的汉字。它们的 gc 是 So 而非 Lo,由此带出的正则漏判见
符号四子类 §9。
规范层不带这些标记,字形与语义完全相同。其中最容易被忽略的是 singleton:单个码点的规范别名,即便 NFC 也会把码点换掉。
| 类目 | 例 |
|---|---|
| 预组合与分解 | U+00C5 等价于 A + U+030A |
| 组合记号按 ccc 重排 | 上点与下点次序不同的 q̣̇ 排成同序后相等 |
| singleton 别名 | 欧姆号 U+2126→U+03A9、埃号 U+212B→U+00C5、CJK 兼容字 U+F900→U+8C48 |
| 谚文的算法式合成 | ᄀ + ᅡ 等价于音节 가 |
警示 · singleton 那一行有违直觉:U+2126 走一趟 NFC 就变成 U+03A9,字形看不出区别,码点却换了。「NFC 无损」说的是信息不丢,不是码点不动。另有几类常被误当成归一化能解决的问题,各归各的机制:大小写属
case folding,繁简与异体字见 Han 统一,同形混淆靠 skeleton,见 同形字,忽略大小写或音标的排序等价属 排序。
4 · 比较前的归一
预组合的 Å 与分解写法的 Å 肉眼无从分辨,=== 直接判不等;各自 normalize('NFC') 之后才相等。用户名、去重键、缓存 key 这类要先归一再比,否则「看着一样」的两串会被当成两个。
5 · 全角与半角
兼容表里的 <wide> 与 <narrow> 值得单独展开:它除了被 NFKC 归约,还在显示宽度与正则匹配上各留一个后果。这两件事都不归归一化管,却都由「全角是另一个码点」而起。
5.1 · 区块结构
全角 A 是 U+FF21,普通 A 是 U+0041,差 0xFEE0。整个 Halfwidth and Fullwidth Forms 区块(U+FF00–U+FFEF)就是这样一批「改变宽度的副本」:全角 ASCII 是 U+0021–U+007E 整体平移
0xFEE0 的结果,此外还有半角片假名与几个全角符号。偏移并不处处规整:全角空格是 U+3000,既不在这个区块也不走这个偏移;半角片假名也没法靠减偏移还原,カ 加浊点 ゙ 经 NFKC 会合成 ガ。
5.2 · 显示宽度
等宽字体里 中 与全角 A 各占两列,ASCII A 占一列,这由码点的 East_Asian_Width 属性决定,而 str.length 反映不出来。JS 的 \p{…} 查不到这一栏,所以本页内嵌了一份区间表,并用
getBoundingClientRect 逐字符实测渲染宽度与之对照。
| 值 | 含义 | 例 | 列数 |
|---|---|---|---|
W |
东亚宽字符 | 中 ぁ 한 | 2 |
F |
全角,ASCII 的宽度副本 | A $ ? | 2 |
H |
半角,片假名等的窄形 | ア カ | 1 |
Na |
窄,ASCII 字母数字标点 | A 1 ? | 1 |
N |
中性,不参与东亚排版 | é Ω | 1 |
A |
歧义,东亚环境两列、否则一列 | ★ ° § | 1 或 2 |
按宽度截断同样不能按码点切:取同样个数的码点,纯 ASCII 与 CJK 占的列数能差一倍。
5.3 · 正则匹配
JS 正则的 \d 恒等于 [0-9]、\w 恒等于 [A-Za-z0-9_],加不加 u 都一样,全角 2 与 A 一律不命中。两条出路:匹配前先 NFKC 归约回 ASCII,或改用 \p{Nd} 与 \p{L}——全角 2 的 gc 正是
Nd。后者有个陷阱:\p{Nd} 也匹配阿拉伯-印度数字 ٣٤,而 parseInt('٣٤') 得到 NaN,所以「要当数值用」的场合仍以先归一再按 ASCII 匹配为稳。
注 · 全角与半角的两个后果可以各记一句:排版与对齐算列数而非 length;正则校验先归一再按 ASCII 匹配。至于大小写,那是另一套机制,正则的 i 标志走的就是它。
6 · 参考文献
- Unicode. UAX #15: Unicode Normalization Forms. 四种形式的定义、规范序与合成算法,以及规范等价与兼容等价的分界。unicode.org
- Unicode. UAX #44: Unicode Character Database.
Decomposition_Mapping的格式与全部尖括号标记、Canonical_Combining_Class的取值。unicode.org - Unicode. UAX #11: East Asian Width. 六个取值的定义与歧义宽度的处理建议,本页宽度表照它。unicode.org
- MDN. String.prototype.normalize(). 四种形式在 JS 里的调用方式与常见用途。developer.mozilla.org