算法与数据结构 / regex · Unicode 字符模型与一个自研引擎 / \p{P} 标点七子类:脚注符号到底归哪一类 待审核 6 / 36
基础 · \p{P} 标点七子类

\p{P} 标点七子类:脚注符号到底归哪一类

码点的档案 的 gc 表里,\p{P}(Punctuation 标点)只占一行——但它切成 7 个子类 Pc Pd Ps Pe Pi Pf Po。传统脚注符号 * § 全落在成员最多的 catch-all 子类 Po 里;而看着成对的开括号 / 闭括号(Ps / Pe)、前引号 / 后引号(Pi / Pf)数量却配不齐。这页把这 7 个子类全用原生 /\p{…}/u 实测着看清楚:每个码点恰好属于一个 gc 子类,首字母 P 就是它的大类。

注 · gc 子类是「身份」,不是「住址」。一个码点的 General_Category 在 UCD 里是单值的——* 的 gc 永远是 Po,无论它出现在脚注、乘法还是脚标里。这和 Block(住址)不同:住址只说它住哪段码位。本页所有「是不是某子类」都用 /^\p{…}$/u 当场测,不是查表。

1 · 六个脚注符号全是 Po

排版里第一个脚注用 *、第二个 、第三个 ……传统排版有固定次序 * † ‡ § ‖ ¶,但这是排版约定的次序,Unicode 的码位顺序根本不照它走:§ 早早躺在 U+00A7 / U+00B6U+2016 才到 U+2020 / U+2021,被彻底打散。它们在 gc 里没有专属子类,一律归入「其他标点 Po」。

图 1-1 · 六个脚注符号的 gc 子类实测,以及四个最沾边的二元属性一并落空的读数。

注 · 对照着看:U+2016)是 Po,而长得像它的管道符 |U+007C)却是 Sm(数学符号,根本不在 \p{P} 里);§ 在 Unicode 6.1(2012)之前曾是 So(其他符号),之后才并入 Po——老数据可能仍标成 Symbol。

注 · 不止 gc——任何可查属性都不编码「能当脚注」。把最沾边的二元属性挨个试过去也全落空:这六个符号 \p{Terminal_Punctuation} / \p{Sentence_Terminal} / \p{Dash} / \p{Quotation_Mark} 一律 No。唯一「全 Yes」的是 \p{Pattern_Syntax}——但它是干扰项:它的含义是「保留给编程语法、不可作标识符」,把 ! ? % & 和上百个符号一并圈入,同样挑不出脚注。

2 · 按字符 Name 筛选候选

既然六个脚注符号在 gc 里全是 Po、和 ! ? % & 同属 641 个成员的 catch-all 子类,那「哪些字符能当脚注 / 引用记号」这件事 category 根本不编码——它是排版约定(editorial convention,Chicago / Oxford 等 style manual 沿袭的活字传统),不是字符属性。Unicode 唯一沾边的可机读线索是字符的 Name(及 NamesList.txt 注解),比如 U+203B 干脆就叫 REFERENCE MARK。所以只能反过来按名字筛选候选。

图 2-1 · 按 Unicode Name 关键词筛出的候选记号及其 gc 分布。数据取自官方 UnicodeData.txt,因为属性类查不到 Name。

警示 · 两点结论。其一,用 gc 选不出脚注符号——Po 里九成以上跟脚注无关,「属于 Po」推不出「能当脚注」。其二,即便改用 Name 关键词,命中也横跨 Po / Sm / So / Mn / Lo / Cf——「名字里有 ASTERISK」不等于「是标点」( ASTERISK OPERATOR 是 Sm、❃ 是 So 装饰、◌͙ COMBINING ASTERISK BELOW 是 Mn),更不等于「能当脚注」。Name 是线索不是标签;footnote-ness 这种语义 Unicode 不编码——真要判定,得查 style manual,不是查码点属性。

注 · 连 NamesList 注解也只是线索,给不了判定。反例两头都有:U+070D SYRIAC HARKLEAN ASTERISCUS 的注解明写 marks the beginning of a phrase, word, or morpheme that has a marginal note——语义上就是旁注记号、名字也含 ASTERIS、gc 还是 Po,却没被收进上面的候选(它是 Syriac 专用);反过来 ASTERISM(U+2042)在 NamesList 里一条注解都没有,却作为正统排版记号被收了。可见「是不是脚注符号」最终由 style manual 的排版约定裁定——UnicodeData / NamesList 只能帮着缩小候选,最后一步还得人工按约定取舍。

3 · 七个子类的实测基数

每个码点恰好属于其中一个。Po 是 catch-all 子类,独占四分之三。

表 3-1 · 七个标点子类及其码点数,合计 856 个(Unicode 17.0)。计数由全码空间逐码点枚举所得,随版本会微涨。
子类 名称 码点数 典型成员
Po Other 其他标点 641 . , ! ? ; : … · * † ‡ § ¶ ‖ 、 。
Ps Open 开标点 79 ( [ { 「 『 (
Pe Close 闭标点 77 ) ] } 」 』 )
Pd Dash 横线标点 27 - – — ~
Pi Initial 前引号 12 « ‹ “ ‘ ‛ ‟
Pf Final 后引号 10 » › ” ’
Pc Connector 连接标点 10 _ ‿ ⁀ _

4 · 探针:任意字符落在哪个子类

图 4-1 · 单个字符的标点子类归属逐项实测。可试 _(下划线竟是标点 Pc)、|(不是标点,是 Sm)、(顿号 Po)。

5 · 七子类着色器

把一段中英混排文本逐码点上色,属于 \p{P} 的按七子类着色、其余灰显,就能看清 gc 怎么在真实标点上切分。

图 5-1 · 一段文本里的标点按七子类着色。可粘贴任意文本或点预设,观察各子类的分布与计数。

6 · 开闭与引号为什么配不齐

直觉上「有开就有闭」,可实测数量对不上:Ps 79 不等于 Pe 77、Pi 12 不等于 Pf 10,两组各差 2。根源是同一件事——引号天生塞不进「开 / 闭」二分法。gc 标的是字符自身的形状与角色,不是配对关系,于是配对就破了。

图 6-1 · 两组开闭数量的差额,以及四个落单引号的 gc 当场实测。

警示 · PsPe 多 2:德式低位引号 U+201AU+201E)被归成开标点 Ps(德语 „…“ 在基线起头),但它们的「闭合形」是抬高的 ,而那俩被归进了 Pi 而非 Pe——于是这两个低引号在 Ps 有身位、Pe 找不到对应。PiPf 多 2:反 9 字形引号 U+201BU+201F)是「翻转当开引号用」的变体,没有专属闭合形(要闭合直接用普通的 ),于是 Pi 这边凭空多两个。

建议 · 那「成对」到底记在哪?真正需要左右镜像的是括号,Unicode 用另一套正交属性专门管:Bidi_Paired_Bracket / Bidi_Paired_Bracket_Type(open / close / none)和 Bidi_Mirrored。引号因为跨语言方向不固定,根本没被纳入 bracket 配对。所以「成对」靠这套属性表达,而不是指望 Ps 数量等于 Pe。可惜 JS 正则的 \p{…} 不支持 Bidi 系列属性(和 Block 一样查不到),本页只能用 gc 把现象呈现出来。

建议 · 本页是 码点的档案 里 gc「七大类 30 小类」表中 \p{P} 那一行的展开,和 \p{Math} 全量速查 是「按身份深挖一类」的两个样本——一个是 gc 子类、一个是二元属性。

7 · 参考文献

  1. Unicode. UAX #44 §General_Category Values. gc 全部大类与小类的官方定义表:Pc/Pd/Ps/Pe/Pi/Pf/Po 各自收哪些字符、判定依据,以及它是单值属性这件事的出处。unicode.org
  2. Unicode. UAX #9: Unicode Bidirectional Algorithm. §3.1.3 的 BD14–BD16 定义括号「成对」怎么识别——为什么配对信息落在 Bidi_Paired_Bracket 上、不在 gc 的 Ps/Pe 数量上。属性本身定义在 UCD 的 BidiBrackets.txtBidi_Mirrored 定义在核心规范 §4.7。unicode.org
  3. Codepoints. 逐码点数据库. 查任意码点的 gc、Bidi_Paired_Bracket、所属 block 等全部属性——核对本页四个落单引号的归类与配对关系最直接。codepoints.net