normalization:同一个「Å」为什么有多种 code point 写法
同一个 abstract character 往往对应不止一种 code point 序列:Å 既可以是 precomposed 的单一 code point U+00C5,也可以由 A 加 combining ring above U+030A 两个 code point 拼成;
此外还有一类 compatibility 变体(全角 A 对应 A、① 对应 1、𝕏 对应 X),同样是同一字符的不同写法。
它们渲染结果一致,字节序列却不同,=== 会直接判定为不相等。(大小写差异属于另一套机制 case folding,不在 normalization 的职责内,见 case folding 那页;那里还涉及 locale 差异。)
为将这些「同字多形」收敛到统一形态,Unicode 定义了四种 normalization form:NFC / NFD / NFKC / NFKD。
Å 既可以是 precomposed 的单一 code point U+00C5,也可以是 A(U+0041)加 combining ring above U+030A 两个 code point——渲染相同、字节不同。NFC 倾向 precomposed(尽量 compose)、NFD 倾向 decomposed(拆开)。precomposed 之所以存在,是为与 Latin-1 等遗留编码无损往返(Å 在这些编码里本就是单字节)。注意 precomposed 是 Unicode 冻结的有限清单:许多组合(如同时带上点与下点的 q̣̇)没有 precomposed 形态,即便 NFC 也只能停在 decomposed 态,至多按 ccc 重排 combining mark 的顺序。
根源:同一字符的多种 code point 写法 —— Unicode equivalence
问题源于 Unicode 的一项设计:abstract character 与 code point 序列并非一一对应——同一字符往往可由不止一种 code point 序列表示,
Unicode 规定这些写法互相等价(Unicode equivalence)。normalization 所做的即:将互相等价的各种写法按规则收敛到唯一形态,
使 ===、索引、查重得以可靠。等价分两层,恰好对应 NF(normalization form)是否带 K:
- Canonical Equivalence(规范等价) —— 字形与语义完全相同,仅是 precomposed 与 decomposed 之别,或 combining mark 排列不同。
Å= 单一 code pointU+00C5=A(U+0041)加 combining ring aboveU+030A。NFC/NFD 处理这一层,无损且可逆。 - Compatibility Equivalence(兼容等价) —— 语义相关但字形 / 格式不同:
全角
A↔A、①↔1、fi↔fi、²↔2、𝕏↔X。 仅 NFKC/NFKD 会将其合并,代价是丢失「全角 / 带圈 / ligature / 上标 / 数学字体」这层信息。
四种 normalization form 是两个维度的组合:采用哪层等价(canonical / compatibility)× 收敛到哪种形态(尽量 composed / 彻底 decomposed):
| Composed 合成 precomposed 字符 | Decomposed 拆成 base character + combining mark | |
|---|---|---|
| Canonical 规范等价 | NFC 存储 / 传输 / 比较的通用默认 | NFD |
| Compatibility 兼容等价 | NFKC 搜索 / 去重 / 防伪,有损 | NFKD |
这两层等价并非临时判断,而是查每个 code point 的 UCD 记录(UCD 是什么见 \p{…} 属性类那页)中两个字段计算得出:
- Decomposition_Mapping —— 每个 precomposed 字符都记录「分解为哪些 code point」。
Å记录为A+U+030A; 映射是否带 compatibility tag(<compat>),正是 canonical decomposition 与 compatibility decomposition 的分界。 - Canonical_Combining_Class(简称 ccc)—— 当一个 base character 附带多个 combining mark 时,normalization 按各 mark 的 ccc 重新排序(canonical ordering)。
这一步最易被忽略:同一个「上有一点、下有一点的 q」,先附上点(
U+0307)或先附下点(U+0323),code point 序列不同; 但 ccc 将其排成同一顺序(下点 ccc=220 排在上点 ccc=230 之前),故 normalization 后相等。下方 lab 点击q+乱序记号preset 即可观察这步重排。
四种 normalization form
输入一个字符,观察四种 form 各将其转换为何。NFC/NFD 仅在 composed / decomposed 间转换、字形不变;NFKC/NFKD 执行 compatibility decomposition,可能改变字形(带圈数字、ligature、上标都会被还原为基本字符)。
末尾的 q+乱序记号 没有 precomposed 形态,但 NFD/NFC 仍会按 ccc 调换两个 combining mark 的次序——这正是 canonical ordering:
原始输入
四种 normalization form(变化的行高亮)
①→1、fi→fi、²→2、𝕏→X)归约为基本字符,有损——
适合搜索 / 去重 / identifier 防伪这类「只关注本质」的场景,不应用其回存原文。
normalization 究竟合并哪些「同字多形」
「同字多形」并非零散特例,而是由 UCD 为每个 code point 登记的 Decomposition_Mapping 字段计算得出——映射是否带 compatibility tag,决定其归入 canonical 层还是 compatibility 层。下面列出两层各自合并的全部类目。
Compatibility Equivalence:仅 NFKC / NFKD 处理
每条 compatibility decomposition 都带一个 tag(angle-bracket tag),标明变体类型。全角 / 半角即其中的 <wide> / <narrow>,此外还有一系列同类:
| tag | 类目 | 例(NFKC 后) |
|---|---|---|
<wide><narrow> | 全角 / 半角 —— 下游还涉及 display width 与正则匹配,见 下文「全角 / 半角」一节 | A→A、ア→ア、全角空格 →普通 space |
<font> | 数学字母变体(script / double-struck / fraktur) | 𝕏→X、ℝ→R |
<circle> | 带圈 alphanumeric | ①→1、Ⓐ→A |
<square> | 方块单位 / 缩写 | ㎡→m2、㈱→(株) |
<super><sub> | superscript / subscript | ²→2、₂→2 |
<fraction> | 分数 | ½→1⁄2(带 fraction slash) |
<compat> | 杂项 catch-all:ligature、Roman numeral… | fi→fi、Ⅻ→XII |
<noBreak> | no-break space | NBSP U+00A0→U+0020 |
<vertical> | 竖排标点变体 | ︱→— |
<small> / <initial> 等 | small form 变体、Arabic 字形位置变体 | ﹅、Arabic initial / medial / final 形 |
Canonical Equivalence:NFC / NFD 即可处理
不带 compatibility tag 的 decomposition 属 canonical 层,字形与语义完全相同:
| 类目 | 例 |
|---|---|
| precomposed ↔ decomposed(带 diacritic 的 Latin 字母) | Å = U+00C5 ↔ A + combining ring above U+030A |
| combining mark 的 ccc 重排序(canonical ordering) | 上点 / 下点先后不同的 q̣̇ → 排成同序后相等 |
| Singleton(单 code point 的 canonical 别名,易被忽略——即便 NFC 也会改变 code point) | ohm 号 Ω U+2126→Greek Ω、ångström 号 Å U+212B→U+00C5、CJK compatibility 表意字 U+F900→统一 code point |
| Hangul(算法式 compose / decompose) | 字母 ᄀ+ᅡ ↔ 音节 가 |
K → k)属 case folding;
繁简 / 异体字(謝 / 谢)是同一词的不同 code point,见 Han unification 那页;
同形混淆(Cyrillic а vs Latin a)靠 skeleton,见 confusables 那页;
排序等价(忽略大小写 / diacritic 至某一 level)靠 collation,见 collation 那页。
比较前先 normalize:看似相同,=== 却不等
左右两框默认放入「precomposed Å」与「A + combining ring above」——肉眼无法区分。直接 === 判定不等;各自 normalize('NFC') 后方相等:
深入一类:全角 / 半角(<wide> / <narrow>)
compatibility 表中的 <wide> / <narrow> 值得单独展开。全角 A(U+FF21)与普通 A(U+0041)是不同 code point,位于 Halfwidth and Fullwidth Forms 区块(U+FF00~U+FFEF)。除被 NFKC 归约(上方四形 lab 点 A / ア 即见),「全角是另一个 code point」还在 display width 与正则匹配上各留一个后果——这两者均不归 normalization 处理,却都由此而起。
区块解剖:全角不过是 ASCII code point 平移 +0xFEE0
区块 U+FF00~U+FFEF 收纳几组「改变宽度的副本」:全角 ASCII(U+FF01 !~U+FF5E ~)是 ASCII U+0021~U+007E 整体平移 +0xFEE0 的副本;半角片假名(U+FF61~U+FF9F);全角 / 半角符号变体(¥ ¢ 等)。输入文本,将其中 ASCII / 全角字符成对列出,观察 code point 偏移:
半角 ↔ 全角配对(仅列有宽度副本的字符)
整段互转
U+3000,不在本区块、亦不走 +0xFEE0,对应普通 space U+0020。半角片假名也无法靠减偏移还原(还涉及 カ+゙ → ガ),交由 NFKC 处理。
后果其一 · display width(East_Asian_Width)
其一是 code point 的 East_Asian_Width(EAW) 属性:monospace 字体中 中、全角 A 占两列,ASCII A 占一列。按字符数对齐的表格一旦混入全角 / CJK 即错位——str.length 无法反映这一点:
逐字符实测渲染宽度(getBoundingClientRect)求列数,占两列者底线标绿,并给出内嵌表查得的 EAW 值(JS 的 \p{…} 无法查询 EAW):
逐字符 EAW 值与实测列数
| 值 | 含义 | 例 | 列数 |
|---|---|---|---|
| W | Wide,东亚宽字符(CJK、kana、Hangul) | 中 ぁ 한 | 2 |
| F | Fullwidth,全角(ASCII 的全角变体) | A ! $ | 2 |
| H | Halfwidth,半角(katakana 等的半角形) | ア カ | 1 |
| Na | Narrow,窄(ASCII 字母数字标点) | A 1 ! | 1 |
| N | Neutral,中性(其余不参与东亚排版的字符) | é Ω | 1 |
| A | Ambiguous,歧义——东亚环境占 2 列、否则 1 列 | ★ ° § | 1 / 2 |
按宽度截断同样不能按 code point 切:同取 6 个单位,纯 ASCII 留 6 字、CJK 占 12 列早已超框。对比「按 code point 切」与「按列宽切」:
后果其二 · 正则匹配:\d / \w 仅识别 ASCII
其二体现在匹配:JS 正则的 \d 恒等于 [0-9]、\w 恒等于 [A-Za-z0-9_](无论是否加 u flag),全角 2 / A 一律不命中。或先 NFKC 归约回 ASCII 再以 \d 匹配(勾选下方开关即见),或改用基于 Unicode property 的 \p{Nd} / \p{L}——全角 2 的 gc 正是 Nd。注意 \p{Nd} 匹配所有 decimal digit(含 Arabic-Indic ٣٤),无法直接传入 parseInt,故「需作为数值使用」时仍以先 NFKC 再 \d 更稳妥:
normalize(通常 NFC),否则「看似相同」的两个串会被视作不同;仅关注本质的搜索 / 防伪、以及将全角数字 / 字母归约回 ASCII 用 NFKC,但不应用其回存原文。
全角 / 半角的两个后果可归纳为:排版 / 对齐应计列数(EAW)而非 length;正则校验应先 NFKC 再按 ASCII 匹配。
大小写不归 normalization 处理——那是另一套 case folding(正则 i flag 即走此机制),见 case folding 那页。