算法与数据结构 / regex · Unicode 字符模型与一个自研引擎 / 字素簇:一个字符到底多长 待审核 15 / 36
字符的真面目 · grapheme cluster

字素簇:一个字符到底多长

Basic_EmojiRGI 序列 两页拆过 👨‍👩‍👧 是五个 code point。换个更极端的例子 🤦🏼‍♂️(扶额男、浅肤色):Rust 数出长度 17,JS、Java 与 C# 数出 7,Python 3 数出 5,Swift 只数出 1。四个答案都对,差别在按哪一层数——UTF-8 字节、UTF-16 码元、code point,以及最上面那层 grapheme cluster(字素簇)。只有最后一层对应人眼感知的「一个字符」。

1 · 四种长度

🤦🏼‍♂️ 的五个码点是 U+1F926(扶额)、U+1F3FC(浅肤色)、U+200D(ZWJ)、U+2642(男性符号)、U+FE0F(VS16)。往下摊:前两个是补充平面字符,各占一对 UTF-16 代理对,其余三个各占一个码元,合计 7 个码元;换成 UTF-8,那两个补充平面字符各占四字节,另外三个各占三字节,合计 17 字节。往上收:五个码点合成一个字素簇,于是是 1。

各语言的默认长度取的正是不同层:Rust 的 .len() 是 UTF-8 字节,JS 的 .length 与 Java、C# 是 UTF-16 码元,Python 3 的 len() 是 code point,Swift 的 .count 与 Elixir 是字素簇。所以「字符串长度」这个问题本身有歧义,得先问是哪一层。

图 1-1 · 同一段文本在四层上的计数。可改文本或点预设,观察哪些输入会让码元数与字素簇数分道扬镳。

2 · 切成字素簇

浏览器原生的 Intl.Segmentergranularity: 'grapheme' 把文本切成字素簇。切出来的每一段就是用户眼里的一个字符,段内可能裹着若干码点:肤色序列、ZWJ 序列、区域指示符对、组合记号,都会被收进同一段。

图 2-1 · Intl.Segmenter 切出的字素簇,每个虚线框为一段,框内列出它含的 code point。文本与图 1-1 共用。

3 · UAX #29 的分类与规则表

Intl.Segmenter 内部并不神秘,切分规则全写在 UAX #29 里,两步:先给每个 code point 标一个 Grapheme_Cluster_Break 属性值,再用一张固定的规则表,只看相邻两个码点的属性值(外加少量跨字符状态),判断此处是断开还是黏连。

表 3-1 · Grapheme_Cluster_Break 的主要属性值及其作用。
属性值 含义
Extend 组合记号、肤色修饰符、变体选择符,黏住前一格
ZWJ U+200D,粘合 emoji 序列
RI 区域指示符,两两成对拼一面旗
L V T LV LVT 韩文谚文音节的拼合零件
Prepend 极少数前置格式符,黏住后一格
Control 控制符与行分隔符,两侧必断
Other 普通字符,默认各自成簇

规则表本身只有十来条,核心几条如下(完整实现在系列根的 regex.ts):

function shouldBreak(a, b, st) {
  if (a.cat === 'CR' && b.cat === 'LF') return false;                    // GB3  \r\n 不拆
  if (/^(CR|LF|Control)$/.test(a.cat)) return true;                      // GB4  控制符后必断
  if (/^(CR|LF|Control)$/.test(b.cat)) return true;                      // GB5  控制符前必断
  if (a.cat === 'L' && /^(L|V|LV|LVT)$/.test(b.cat)) return false;       // GB6 ┐
  if (/^(LV|V)$/.test(a.cat) && /^(V|T)$/.test(b.cat)) return false;     // GB7 ├ 谚文拼合
  if (/^(LVT|T)$/.test(a.cat) && b.cat === 'T') return false;            // GB8 ┘
  if (b.cat === 'Extend' || b.cat === 'ZWJ') return false;               // GB9  记号 / ZWJ 黏前
  if (b.cat === 'SpacingMark') return false;                             // GB9a 占位记号黏前
  if (a.cat === 'Prepend') return false;                                 // GB9b 前置符黏后
  if (st.gb11 && b.pict) return false;                                   // GB11 emoji ZWJ 序列
  if (a.cat === 'RI' && b.cat === 'RI' && st.riCount % 2) return false;  // GB12 旗成对
  return true;                                                           // GB999 其余一律断
}
图 3-1 · 手写规则引擎的逐格判定:每两格之间标出断(÷)或连(×)与命中的规则号,末尾与 Intl.Segmenter 的结果对账。可改文本或点预设。

警示 · 预设里的 क्षि(Devanagari 连写)会让两边对不上:手写引擎切成 2 段,Intl.Segmenter 切成 1 段。差在 GB9c——它管的是 Indic 连写,依赖 Indic_Conjunct_Break 属性,而 JS 的 \p{…} 查不到这一栏(写 \p{Indic_Conjunct_Break=Linker} 直接抛 Invalid property name),所以本页的简化引擎没有实现它。这条规则并非一直都在:对照 UAX #29 的两个修订版,GB9c 在 Unicode 15.1 的第 43 版里首次出现,15.0 的第 41 版全文找不到它。切分规则会随 Unicode 版本增补,照着某一年的规则手搓,迟早与跟着版本走的 ICU 分家。

4 · 字符串反转

反转是最容易暴露层次错误的操作。按码元反转会把代理对拆散,产出孤儿代理,屏幕上是乱码;按码点反转保住了单个码点,但 ZWJ 序列、肤色修饰符会跟错基字符,🤦🏼‍♂️ 会散成几个不相干的符号;只有按字素簇反转才保住每个「字符」完整。

图 4-1 · 同一段文本按码元、码点、字素簇三种方式反转的结果对照,文本与图 1-1 共用。改那里的输入即可观察前两种何时开始出错。

注 · 前面 emoji 几页拆过的 ZWJ 序列、肤色修饰符、VS16 与区域指示符旗,本质都是「多个 code point 组成一个字素簇」的具体形态,字素簇是这一现象的统称。切分规则随版本变,所以限长、截断、反转这类操作应交给跟着 Unicode 版本更新的实现(Intl.Segmenter、ICU、Swift 标准库),而不是自己按码点凑。

5 · 参考文献

  1. Unicode. UAX #29: Unicode Text Segmentation. 字素簇、词、句三种边界的规范:Grapheme_Cluster_Break 属性值定义与 GB1–GB999 规则表,本页的手写引擎照它实现。unicode.org
  2. Unicode. UAX #29(第 41 版 / Unicode 15.0). 与最新版对照可见 GB9c 尚未出现,是本页版本差异一节的依据。unicode.org
  3. MDN. Intl.Segmenter. granularity 的三个取值与 segment() 返回的迭代结果结构。developer.mozilla.org
  4. Henri Sivonen. It's Not Wrong that "🤦🏼‍♂️".length == 7. 那组 17 / 7 / 5 / 1 的出处,逐层解释各语言为何给出不同长度。hsivonen.fi