工程
「什麼」为什么会变成「什幺」
OpenCC tw2s 转换非幂等的一次排查
给自 FORK 的 简繁转换浏览器扩展 opencc-extension 升级 opencc-js 之后,我发现繁体页面自动转换后出现了「什幺」这种字。我第一反应是不信——「什麼」转简体怎么会变成「什幺」?「幺」和「么」明明是两个字。
复现
直接调转换函数,单次转换是对的:
「什麼」→ tw2cn →「什么」 ✓
但再转一次就坏了:
「什么」→ tw2cn →「什幺」 ✗
也就是说转换不幂等。
为什么会有第二次转换? 扩展的 auto 模式实现很简单:监听 DOM 变化,一有动静就把整页所有文本节点重新转一遍。现代网页几乎必然有动态内容(懒加载、广告、交互渲染),所以第二次转换几乎必然发生——第一次把页面变成简体,第二次又把简体文本喂进同一条链,「什么」就变成了「什幺」。浏览器里看着就像页面被转坏了。
不过这里得说清楚,这不完全是重复转换的问题。就算只转一次,「什么」也会变成「什幺」——在 popup 的文本框里输入简体选 tw2cn,或者打开一个简体页面(lang=zh-CN)第一次转换,都是一次就坏。重复转换只是把「偶尔错」放大成「必然错」,根子还是转换链对简体输入不设防。
定位
opencc-js 的 tw2cn 走的是官方 tw2s 配置:
TWVariantsRevPhrases → TWVariantsRev → TSPhrases → TSCharacters
分阶段跑一遍,破坏发生在第二步 TWVariantsRev。这个词典只有 42 条,全是台湾变体字到标准繁体的映射,里面赫然躺着一条:
么 幺
简体的「么」被当成台湾异体字处理了。更麻烦的是它不可逆:后续的 TS 阶段里「幺」没有反向条目(「幺」在繁简里字形相同),所以「什幺」一旦产生就回不去。对比一下其他条目,「才→纔」转错之后 TS 阶段有「纔→才」能纠正回来,唯独「么」这条没有后路。
再挖一层
TWVariantsRev.txt 文件头写着一行注释:Generated from TWVariants.txt。也就是说这个反向词典是构建脚本从正向词典自动反转出来的,TWVariants.txt 里对应的是「幺→么」。
这条目本身有没有道理?「幺」和「么」在古籍里是同一个字的正异体关系,台湾教育部字典里「么」是「幺」的异体。所以「幺→么」作为台湾用字习惯的映射说得通——台湾民间确实有人把 yāo 写成「么」。问题在于它反转成「么→幺」之后,被用在了 tw2s 这条繁转简的链上,而链上的词典对输入形态完全不设防。
顺带发现:TWVariantsRevPhrases 里其实已经有大量自映射保护条目,比如「人才 人才」「顯著 顯著」,就是为了防止字符级词典误转高频词。但「什么」「怎么」「这么」「那么」「多么」这些最高频的简体词全都不在保护名单里。
词典是怎么组织的
查到这里,我把 opencc 的词典体系整个捋了一遍,后面修复的时候这些关系都用得上。
命名规则
opencc-data 的 data/ 目录下有一堆 txt,按两个维度切分,命名很直白:
- 方向前缀:
ST简→繁、TS繁→简、TW台湾 ↔ 标准繁体(简称 t)、HK香港 ↔ t - 粒度后缀:不带后缀是单字映射,带
Phrases是短语映射
Rev 表示反向,由构建脚本 reverse.py 从正向词典自动反转生成——所以 TWVariantsRev.txt 文件头才会写着一行注释:Generated from TWVariants.txt。我们踩的「么→幺」就是这么来的:TWVariants.txt 里一行「幺→么」,反转之后就变成了「么→幺」。
全部词典大约这些:
| 词典 | 方向 | 说明 |
|---|---|---|
STCharacters / STPhrases |
简 → 繁 | 字符/短语 |
TSCharacters / TSPhrases |
繁 → 简 | 字符/短语 |
TWVariants / TWVariantsRev |
台湾 ↔ t | 字形变体 |
TWVariantsPhrases / TWVariantsRevPhrases |
台湾 ↔ t | 变体短语 |
TWPhrases / TWPhrasesRev |
台湾 ↔ t | 词汇 |
HKVariants / HKVariantsRev(+Phrases) |
香港 ↔ t | 字形变体 |
HKPhrases / HKPhrasesRev |
香港 ↔ t | 词汇 |
JPShinjitaiCharacters / JPShinjitaiPhrases |
— | 日文新字体 |
STPhrases_GeneratedFromRegionalPhrases |
简 → 繁 | 从地区词汇生成的短语 |
CJK_Compatibility_Ideographs |
— | 兼容表意文字规范化 |
注意 TW 家族其实分了两类,容易搞混:字形变体(Variants)和词汇(Phrases)是不同的东西。台湾写「裡」、t 写「裏」,这是同一个字的字形差异,归 Variants 管;台湾说「軟體」、t 说「軟件」,这是不同的词,归 TWPhrases 管。tw2t 只做字形归一,不做词汇替换——实测 軟體 → tw2t → 軟體(不变),而词汇替换要等 twp 链里的 TWPhrasesRev 才发生(軟體 → twp2cn → 软件)。
转换链就是词典的叠放
以 tw2cn 为例,官方配置是:
normalize: CJK_Compatibility_Ideographs
segmentation: TSPhrases
conversion_chain:
[TWVariantsRevPhrases, TWVariantsRev] # 台湾字形 → t
[TSPhrases, TSCharacters] # t → 简体
其他方向只是换掉对应家族的词典:
twp2cn: [TWPhrasesRev, TWVariantsRevPhrases, TWVariantsRev] → [TSPhrases, TSCharacters]
cn2tw: [STPhrases, STPhrases_Generated, STCharacters] → [TWVariantsPhrases, TWVariants]
hk2cn: [HKVariantsRevPhrases, HKVariantsRev] → [TSPhrases, TSCharacters]
可以看出来,第二段(TS 繁→简)是共用的,各地区的差异全在第一段消化掉。这就是链长得这么长的原因:先变体、后简繁,每层只负责一件事。台湾和香港各自只需要维护自己跟 t 的差异(TWVariantsRev 只有 42 条),不用每人复制一份完整的繁转简词典。
为什么短语层必须在字符层前面
一简对多繁只能在词里消歧。「乾」这个字,在「乾隆」里要保持「乾」,在「乾杯」里要变成「干」——字符层的映射是单字的,分不出这两种情况,只能靠短语层先把「乾杯」整体吃掉。所以每个方向的链都是短语在前、字符兜底。
自映射保护
变体短语词典里还混着一大批自映射条目,比如:
人才 人才
顯著 顯著
一表人才 一表人才
翻译过来就是「这些词保持不变」。它们的存在是为了挡住字符层词典的误转:TWVariantsRev 里有「才→纔」,如果「人才」不被短语层先拦截,就会先变成「人纔」。这是 opencc 自己早就有的防御机制,只不过「什么」「怎么」这些最高频的简体词没被覆盖到——我们这次的 bug 和修复,本质都是这个机制的应用与缺失。
完整走一遍
光看链看不出问题,把「做什麼的?」实际走一遍就清楚了。规范化那步只处理 CJK 兼容表意字符,对普通中文没影响,这里略过。
第一次转换,输入是正常的繁体:
① TWVariantsRevPhrases 做什麼的? → 做什麼的? (不是台湾变体短语,不动)
② TWVariantsRev 做什麼的? → 做什麼的? (「麼」是标准字形,不动)
③ TSPhrases 做什麼的? → 做什麼的? (没有「什麼→什么」这条短语)
④ TSCharacters 做什麼的? → 做什么的? (字符级「麼→么」在这里完成)
转换是正确的,而且注意「什麼→什么」实际发生在最后一步的字符级兜底,不是短语层。
第二次转换,输入是被转过的简体,破坏出现了:
① TWVariantsRevPhrases 做什么的? → 做什么的? (保护条目都是繁体写法,匹配不到简体)
② TWVariantsRev 做什么的? → 做什幺的? ◀ 就在这里,「么→幺」
③ TSPhrases 做什幺的? → 做什幺的? (「什幺」不是合法繁体词,无条目)
④ TSCharacters 做什幺的? → 做什幺的? (「幺」在繁简里字形相同,无条目)
两件事导致破坏不可逆:一是「什幺」不是任何词典里的合法词,后面三步都找不到它;二是「幺」在 TS 阶段没有反向条目——不像「才→纔」那样转错之后还有「纔→才」能纠正回来。所以「幺」一旦出现,就一路输出到最终结果。
为什么不在 TS 阶段修
我试过在 TS 阶段加「幺→么」来纠正,效果如下:
什么 → 什么 ✓
幺妹 → 么妹 ✗
幺蛾子 → 么蛾子 ✗
「幺」在简体里是合法字(yāo 义),无差别改成「么」等于把所有正常的幺都毁了。「么→幺」执行之后「幺」的来源信息已经丢失,没法区分它是被误转的还是本来就该写幺。破坏一旦发生就不可逆,所以只能在破坏发生之前拦住,也就是在 TWVariantsRev 这一层保护「么」。
上游为什么没暴露
官方 OpenCC 是命令行工具,输入约定是繁体,用户不会拿简体去喂 tw2s。这个问题在上游存在了不知道多久,都没人发现。但转换非幂等对库用户来说是真实存在的坑——批量处理、文本流里混入已转换片段,都会踩到。浏览器扩展只是把这个坑放大了。
修复
扩展这边没法等上游,于是搞了一个本地保护词典 protect.txt,makeConverter 加载后插在转换链最前面:
[tw-cn]
么 么
显著 显著
著称 著称
著录 著录
著书 著书
著文 著文
土著 土著
编著 编著
专著 专著
[cn-tw]
幺 幺
「么」用字符级保护,一条覆盖所有含么的词;「著」不行,字符级保护会把「執著→执着」这种正确转换也拦掉,只能用短语级一个个列。「显著」「土著」这些是我全量扫 TWVariantsRev 的 42 个源字时发现的,测试之前完全没想到「著」也会中招——繁体输入没毛病,简体输入全坏。
后来也顺手确认了 cn2tw 方向同样有问题(「幺妹」→「么妹」),所以 protect.txt 里加了一节。hk 系列方向扫过是安全的。
为什么这几条就够了
保护词典不是拍脑袋列的。我把 TWVariantsRev 的 42 个源字全扫了一遍,每个都拿简体常用词去试,结论是破坏不可逆的只有两个字:
- 「么」:转成「幺」后,
TS阶段没有「幺→么」的反向条目 - 「著」:转成「着」后同理,「着」在
TS阶段没有反向条目
其他 40 个源字都不用管:要么不是简体常用字(偽、參、啟这些简体输入里根本不会出现),要么转错也能被 TS 阶段纠正回来(「才→纔→才」就是典型)。所以「么」用一条字符级保护覆盖所有含么的词,「著」用几条短语保护,就覆盖完全了。测试之前我也没想到「著」会中招——繁体输入一切正常,简体输入全坏。
为什么保护条目是简体写法
转换链的词典对输入形态不设防,而重复转换后进入链的文本已经是简体,所以保护条目的源字必须跟实际输入一致才匹配得上——要拦的是「什么」,就得写「什么」。上游 TWVariantsRevPhrases 里那些自映射保护(「人才 人才」)都是繁体写法,因为上游的输入域是繁体;扩展要拦的是简体输入,所以写简体。两边源字不同,各拦各的,不冲突。
这也是给上游提 issue 时只建议加短语、不建议加字符的原因:TWVariantsRevPhrases 被 tw2t 和 tw2s 两条链共用,字符级「么→么」会让 tw2t 的「么儿→幺儿」归一化失效;而短语「什么→什么」在 tw2t 里匹配不到任何东西(台湾输入不会出现简体词),安全。扩展不受这个限制,因为它只在 tw2cn 这一条链上插保护,tw2t 方向根本用不到。
上游的 issue
给 BYVoid/OpenCC 提了个 issue(#1469),用「转换非幂等」而不是「简体输入被破坏」来表述——前者是无争议的性质问题,后者可能会被一句「输入必须是繁体」打发掉。给的建议是延续他们已有的保护惯例,在 TWVariantsRevPhrases 里补上「什么」「怎么」这几条,或者重新审视「幺↔么」这个条目本身。
数据链是 byvoid/opencc → opencc-data → opencc-js,上游一动,下游全部自动跟着修,所以扩展里的 protect.txt 只是个过渡方案。
一点感想
这个 bug 最坑的地方在于单次转换是对的,问题藏在幂等性里。做词典类工具,转换结果被再次处理是很常见的用法,但很少有人会主动测 convert(convert(x))。现在扩展里加了一条幂等性测试用例,以后升级词典数据的时候能第一时间发现这类回归。