算法与数据结构 / regex · Unicode 字符模型与一个自研引擎 / 归一化:同一个 Å 的几种写法 待审核 18 / 36
字符的真面目 · 同字多形 + 归一化

归一化:同一个 Å 的几种写法

Å 既可以是单个码点 U+00C5,也可以是 A 加组合环 U+030A 两个码点;𝕏 则与 A1X 语义相关而字形不同。这些写法渲染出来一样或相近,字节序列却不同,=== 一律判不等。Unicode 把这类「同字多形」的关系写进码点档案,并定义四种归一化形式(NFC、NFD、NFKC、NFKD)把它们收敛到一形。大小写差异不在归一化职责内,那是 大小写 页的 case folding。

1 · 规范等价与兼容等价

同一个抽象字符与码点序列并非一一对应,Unicode 把互相等价的写法分成两层。规范等价指字形与语义完全相同,只是预组合与分解之别,或组合记号排列不同,Å 的两种写法即属此层,NFC 与 NFD 处理它,无损且可逆。兼容等价指语义相关而字形或格式不同,全角 A1fi 都在这层,只有带 K 的 NFKC 与 NFKD 会合并,代价是丢掉「全角」「带圈」「连字」这层信息。

表 1-1 · 四种形式是两个维度的组合:取哪层等价,收敛到哪种形态。
尽量合成 彻底分解
规范等价 NFC,存储与比较的通用默认 NFD
兼容等价 NFKC,搜索与去重用,有损 NFKD

这两层不靠临场判断,而是查每个码点在 UCD 里的两个字段算出来的。Decomposition_Mapping 记录一个码点分解成哪些码点,映射是否带 <compat> 一类的标记,正是两层的分界;Canonical_Combining_Class(ccc)则在一个基字符挂多个组合记号时决定标准次序,归一化按它重排,细节见 组合记号

2 · 四种形态

四种形式作用于同一个输入常给出四种结果。NFC 与 NFD 只在合成与分解之间转换,字形不变;NFKC 与 NFKD 执行兼容分解,字形可能变—— 会变成 1 会拆成 fi。没有预组合形态的输入(例如同时带上点与下点的 q)即便走 NFC 也只能停在分解态,至多按 ccc 重排记号次序。

图 2-1 · 一个输入在四种归一化形式下的结果,逐形列出码点链,与原串不同的行高亮。可换输入或点预设。

建议 · NFC 是存储、传输与比较的通用默认,无损。NFKC 把兼容变体归约成基本字符,适合搜索、去重与标识符防伪这类只关心本质的场景,但它有损,不应拿它的输出回存原文。

3 · 归并的类目

兼容分解在 UCD 里都带一个尖括号标记,标明变体类型。全角与半角只是其中一类。

表 3-1 · 兼容等价的主要标记与实测的 NFKC 结果,这一层只有带 K 的形式会合并。
标记 类目 NFKC 后
<wide> <narrow> 全角与半角 A、全角空格→普通空格
<font> 数学字母变体 𝕏XR
<circle> 带圈字母数字 1A
<square> 方块单位与缩写 m2(株)
<super> <sub> 上标与下标 ²22
<fraction> 分数 ½1⁄2(中间是分数斜杠)
<compat> 杂项:连字、罗马数字 fiXII
<noBreak> 不断行空格 U+00A0U+0020
<vertical> 竖排标点变体

表里 <compat> 一行在中文文本里最常撞上的不是连字,而是 Kangxi Radicals(U+2F00U+2FD5):214 个画出来与汉字无异的码点,NFKC 逐个折回对应的汉字。它们的 gc 是 So 而非 Lo,由此带出的正则漏判见 符号四子类 §9。

规范层不带这些标记,字形与语义完全相同。其中最容易被忽略的是 singleton:单个码点的规范别名,即便 NFC 也会把码点换掉。

表 3-2 · 规范等价的四个类目,NFC 与 NFD 即可处理。
类目
预组合与分解 U+00C5 等价于 A + U+030A
组合记号按 ccc 重排 上点与下点次序不同的 q̣̇ 排成同序后相等
singleton 别名 欧姆号 U+2126U+03A9、埃号 U+212BU+00C5、CJK 兼容字 U+F900U+8C48
谚文的算法式合成 + 等价于音节

警示 · singleton 那一行有违直觉:U+2126 走一趟 NFC 就变成 U+03A9,字形看不出区别,码点却换了。「NFC 无损」说的是信息不丢,不是码点不动。另有几类常被误当成归一化能解决的问题,各归各的机制:大小写属 case folding,繁简与异体字见 Han 统一,同形混淆靠 skeleton,见 同形字,忽略大小写或音标的排序等价属 排序

4 · 比较前的归一

预组合的 Å 与分解写法的 Å 肉眼无从分辨,=== 直接判不等;各自 normalize('NFC') 之后才相等。用户名、去重键、缓存 key 这类要先归一再比,否则「看着一样」的两串会被当成两个。

图 4-1 · 两个字符串的直接比较与归一化后比较,同时列出各自的码点序列与码元数。可改两侧输入。

5 · 全角与半角

兼容表里的 <wide><narrow> 值得单独展开:它除了被 NFKC 归约,还在显示宽度与正则匹配上各留一个后果。这两件事都不归归一化管,却都由「全角是另一个码点」而起。

5.1 · 区块结构

全角 U+FF21,普通 AU+0041,差 0xFEE0。整个 Halfwidth and Fullwidth Forms 区块(U+FF00U+FFEF)就是这样一批「改变宽度的副本」:全角 ASCII 是 U+0021U+007E 整体平移 0xFEE0 的结果,此外还有半角片假名与几个全角符号。偏移并不处处规整:全角空格是 U+3000,既不在这个区块也不走这个偏移;半角片假名也没法靠减偏移还原, 加浊点 经 NFKC 会合成

图 5-1 · 输入文本里可配对的半角与全角字符,逐对列出码点与偏移,并给出整段的全角化与半角化结果。可改文本或点预设。

5.2 · 显示宽度

等宽字体里 与全角 各占两列,ASCII A 占一列,这由码点的 East_Asian_Width 属性决定,而 str.length 反映不出来。JS 的 \p{…} 查不到这一栏,所以本页内嵌了一份区间表,并用 getBoundingClientRect 逐字符实测渲染宽度与之对照。

表 5-1 · East_Asian_Width 的六个取值与占列数。
含义 列数
W 东亚宽字符 中 ぁ 한 2
F 全角,ASCII 的宽度副本 A $ ? 2
H 半角,片假名等的窄形 ア カ 1
Na 窄,ASCII 字母数字标点 A 1 ? 1
N 中性,不参与东亚排版 é Ω 1
A 歧义,东亚环境两列、否则一列 ★ ° § 1 或 2
图 5-2 · 逐字符的 East_Asian_Width 取值、应占列数与实测渲染宽度,占两列者高亮。可改文本或点预设。

按宽度截断同样不能按码点切:取同样个数的码点,纯 ASCII 与 CJK 占的列数能差一倍。

图 5-3 · 同一段文本按码点数截断与按列数截断的对照,超出列宽上限的一行标红。可改文本或拖动列数上限。

5.3 · 正则匹配

JS 正则的 \d 恒等于 [0-9]\w 恒等于 [A-Za-z0-9_],加不加 u 都一样,全角 一律不命中。两条出路:匹配前先 NFKC 归约回 ASCII,或改用 \p{Nd}\p{L}——全角 的 gc 正是 Nd。后者有个陷阱:\p{Nd} 也匹配阿拉伯-印度数字 ٣٤,而 parseInt('٣٤') 得到 NaN,所以「要当数值用」的场合仍以先归一再按 ASCII 匹配为稳。

图 5-4 · 四条正则在同一段文本上的命中对照,可勾选匹配前先做 NFKC 归约,观察 ASCII 类转义何时开始命中。

注 · 全角与半角的两个后果可以各记一句:排版与对齐算列数而非 length;正则校验先归一再按 ASCII 匹配。至于大小写,那是另一套机制,正则的 i 标志走的就是它。

6 · 参考文献

  1. Unicode. UAX #15: Unicode Normalization Forms. 四种形式的定义、规范序与合成算法,以及规范等价与兼容等价的分界。unicode.org
  2. Unicode. UAX #44: Unicode Character Database. Decomposition_Mapping 的格式与全部尖括号标记、Canonical_Combining_Class 的取值。unicode.org
  3. Unicode. UAX #11: East Asian Width. 六个取值的定义与歧义宽度的处理建议,本页宽度表照它。unicode.org
  4. MDN. String.prototype.normalize(). 四种形式在 JS 里的调用方式与常见用途。developer.mozilla.org