繁简转换要么转台湾要么转香港,OpenCC 偏偏先转成一种谁都不用的繁体

发布于 2026年08月02日 21:50 #Github 解读#CLI 原文链接

繁简转换要么转台湾要么转香港,OpenCC 偏偏先转成一种谁都不用的繁体 封面图
  • 定义中间层“OpenCC 标准繁体”,将维护复杂度从 O(n²)降至 O(n)
  • 词典分为简繁通用、地区字形异体、地区惯用词三层,配置即编排
  • 采用逆向最大匹配分词,并利用前缀跳过大段不可匹配字符优化性能
  • 底层支持 Darts 和 Marisa 两种 Trie 引擎,上层无感知切换
  • 转换不可逆且采用高精度低召回策略,避免歧义字误转

2010 年,还在读本科的 Carbo Kuo(byvoid)开了个仓库,想解决一个看似简单的问题,中文简繁转换。十六年过去,这个叫 OpenCC 的项目攒到了 9800 多颗 Star,fork 超过一千,Debian、Ubuntu、Fedora、Arch、Homebrew、WinGet 全都收了它,npm 上叫 opencc,PyPI 上也叫 opencc。截至我写这篇文章,最近一次 push 就在昨天,2026 年光 formal release 就发了五个(1.3.0 到 1.4.1)。

一个「简体转繁体」的功能,凭什么能写十六年还在更新?

答案藏在一个有点反直觉的设计里。OpenCC 没有直接把简体翻译成台湾繁体或香港繁体,而是先翻译成一种谁都不用的繁体,再用它当跳板转到各个地区。这不是炫技,是一个把维护成本从平方级压到线性级的工程决策。

先翻译成一种「不存在的繁体」

先说清楚这个项目到底干什么。OpenCC 做的是正字法转换(orthographic conversion),不是翻译。它处理的是字形和地区用词的差异,比如「鼠标」变「滑鼠」、「汉字」变「漢字」、「软件」变「軟體」。它不管语法,不管语义,不把普通话翻成粤语。

真正有意思的是它的转换策略。

设想一下最朴素的方案,为每一种「源 → 目标」配对单独维护一张转换表。中国大陆简体、新加坡简体、台湾正体、香港繁体、澳门繁体,加上日本新字体,随便两两组合就是几十张表。每多支持一个地区,要新增的表数量是指数级增长的,用术语说就是 O(n²)。

OpenCC 的解法很干脆,它凭空定义了一个中间层,叫 OpenCC 标准繁体(OpenCC Standard Traditional Chinese)。所有转换都先汇集到这一层

地区简体  ↔  OpenCC 标准繁体  ↔  地区繁体

这样一来每新增一个地区,只要写两张表,一张「它 ↔ 中间层」,O(n²) 直接降成 O(n)。DESIGN_PRINCIPLES.md 里把这套关系讲得很清楚,s2tw 概念上约等于 s2t + t2tws2hk 约等于 s2t + t2hk

但这里有个关键细节容易被误读。这个「OpenCC 标准繁体」不是台湾繁体,也不是香港繁体,现实中没有任何一群人这么写字。它是工程上的中间表示,参考《康熙字典》和历史用法选出来的「较正确或语义上较适当的形式」。

举几个例子。简体的「台」在中间层会变成「臺」,到了台湾模式还是「臺」,但到了香港模式可能又变回「台」。简体的「吃」在中间层某些语境下会变成古字「喫」,到了台湾和香港模式再统一变回「吃」。简体的「才」同理,中间层先存「纔」,地区层再还原成「才」。

你想想看,这其实是在中间层故意保留更多信息量,方便下游地区层做二次裁剪。这跟编译器里的 IR(中间表示)是一个思路,先升到一个信息无损的中间态,再往各个目标平台降。

词典拆三层,再串成一条链

理解了中间层,再来看它怎么把这套抽象落地。

OpenCC 的词典按职责分三类,全部是纯文本 .txt,一行一对,谁都能拿文本编辑器改。

第一类是简繁通用层STCharacters.txtSTPhrases.txt 负责简体到中间繁体,TSCharacters.txtTSPhrases.txt 负责反向。这是骨架。

第二类是地区字形异体层TWVariants.txtHKVariants.txt 处理同一个字在不同地区的字形差异,比如「裏」和「裡」。TWVariantsPhrases.txt 是词组级补充,优先级高于单字表。

第三类是地区惯用词层TWPhrases.txt 这种,处理的是「計程車」对「出租車」这种完全不同的词。注意,只有带 p 后缀的模式(s2twptw2sp)才会启用这一层。

这些词典怎么组合,全靠配置文件说了算。打开 data/config/s2twp.json,你能看到一条 conversion_chain,它把上面三类词典按顺序串起来

"conversion_chain": [
  {
    "dict": {
      "type": "group",
      "match_policy": "short_circuit",
      "dicts": [
        { "type": "group", "match_policy": "union",
          "dicts": [ "STPhrases.ocd2", "STPhrases_GeneratedFromRegionalPhrases.ocd2" ] },
        { "type": "ocd2", "file": "STCharacters.ocd2" }
      ]
    }
  },
  {
    "dict": {
      "type": "group", "match_policy": "short_circuit",
      "dicts": [ "TWPhrases.ocd2", "TWVariantsPhrases.ocd2", "TWVariants.ocd2" ]
    }
  }
]

两个设计值得点出来。一是 group 里的 match_policyshort_circuit 意味着组内词典按顺序匹配,前面命中了就不查后面的,这正是「词组优先于单字」的实现,STPhrases 排在 STCharacters 前面。二是词典文件用的是 .ocd2 后缀,这是 OpenCC 自己的二进制词典格式,比纯文本查询快得多,后面会讲。

配置即编排,这套设计让换词典、加地区、调优先级都不用动代码,改 JSON 就行。词库和函数库完全分离,这点 README 也单独拎出来讲过。

逆向最大匹配,外加两套 Trie

转换的实际过程分两步,先分词,再逐段查词典替换。

分词用的是最大匹配(MaxMatchSegmentation),而且是逆向的,从左到右贪心地找最长的词典命中。这部分代码在 src/MaxMatchSegmentation.cppSegmentText 函数里。我读到一段很妙的优化,当某个字符在词典里根本没有任何前缀匹配时,它不会傻傻地一个字一个字往前推进,而是调用 prefixMatch->SkipUnmatchable()整段跳过所有不可能命中词典前缀的字符,一次 bulk scan 搞定。对于夹杂大量 ASCII、标点、空白的中英混排文本,这个优化省下的字符级查找非常可观。

匹配命中后,命中片段作为一个 segment 输出,没命中的连续字符也攒成一个 segment,交给后面的转换链处理。

转换链的核心是 src/ConversionChain.hpp 里的 ConversionChain 类。它持有一个有序的 Conversion 列表,每个 Conversion 包一层词典。链式执行时,前一个 Conversion 的输出 segment 直接喂给下一个。注释里点明了这是「priority-ordered substitution」,词组词典排在字符词典前面,保证多字命中优先。

为了不产生中间字符串分配,热路径走的是 AppendConvertedSegment(const char* segment, std::string* output),直接往输出 buffer 追加,省掉 s2t + t2tw 两段之间多余的 string 构造。这种细节在处理大段文本时很关键。

一张图看懂整条流水线

上面讲的分词、转换链、词典三层,加上顶部的多语言接入和底部的词典数据,构成了一条完整的流水线。

OpenCC 系统架构
OpenCC 系统架构

最值得注意的不是某一层,而是层和层之间的解耦。词典引擎(Darts / Marisa)换实现不影响转换链,转换链换编排不影响分词,分词换策略不影响词典。这种「每层都能独立替换」的结构,才是它能撑十六年还在演进的根本原因。

转换完,就回不去了

底层词典引擎有两套,这是我个人觉得最能体现这个项目「认真」的地方。2010 年最早的版本用的是 Darts(Double-Array Trie),代码在 src/DartsDict.hpp,双数组前缀树,查询 O(1) 级别,静态结构特别省内存。2020 年又加了一套 Marisasrc/MarisaDict.hpp),MARISA 是个更紧凑的 Patricia Trie 变种,空间占用更小。两套引擎并存,都实现了 SupportsFastPrefixMatch() 接口,上层逻辑无感知。你能在 src/ 目录里看到 DartsDictTest.cppMarisaDictTest.cpp 两套并行的测试,这种「新引擎灰度上线、老引擎不删」的工程克制,比追新踩烂要有品味得多。

转换完,就回不去了

讲了这么多好的,该说局限了。坦白讲,OpenCC 有几个绕不开的硬限制,而且它自己在 DESIGN_PRINCIPLES.md 里讲得比谁都坦诚。

第一条,转换不可逆。 t2s(s2t(x)) == x 不成立。原因是中间层会做归一化,多个来源形式可能被合并成同一个标准形式,信息在这一步就丢了。比如两个不同的简字可能都映射到中间层的同一个繁字,再转回去就只能得到其中一个。DESIGN_PRINCIPLES 里直接写了「轉換不保證可逆」,没有半点遮掩。

第二条,宁可少转,也不乱转。 这是它的高 precision / 低 recall 策略。遇到「干」「发」「沉」这种强歧义字,如果上下文判断不出语义,OpenCC 倾向保持原文不转。这跟很多用户的预期相反,你以为它「转换不完整」,其实它是故意压低误转率。 到底是「發」还是「髮」, 到底是「幹」还是「乾」,没有可靠判据就按兵不动。

第三条,分词顺序会咬人。 issue #475 是个经典案例,19 条评论,标题直接点出「演算法缺陷导致 s2twp『正则表达』=>『正規表示』无作用」。根因是地区惯用词的匹配依赖前一步的分词结果,而分词词典和地区词词典不是同一套,分词切错或切得太碎,后面的地区词替换就接不上。这不是 bug 修一修就完,是「先分词后替换」这个两阶段架构的固有代价,要彻底解决得引入上下文语义判断,而 OpenCC 明确说自己不做深层语义分析。

这三条局限里,前两条是设计取舍(有得有失,不算缺陷),第三条才是真正值得使用者警惕的工程坑。你在生产环境用 OpenCC 做大批量转换,尤其是带地区惯用词的 s2twp,最好抽样人工 review,别迷信「转换完就万事大吉」。

还有个容易忽略的点,它的「中间繁体」参考的是《康熙字典》和历史用法,不等于任何一套现行官方标准。DESIGN_PRINCIPLES 开篇就声明,配置里的 s/t/tw/hk 只是工程标记,「不代表 OpenCC 对任何特定国家、地区、政治实体、语言政策或正字法主张的认同或否定」。政治中立写得明明白白。台湾模式长期参考台湾教育部《重编国语辭典修訂本》,香港模式参考《香港小學學習字詞表》,但 PR #418 专门修过香港模式里和实际用字习惯不符的字形,说明它不照搬任何官方表,实际语料和转换准确性才是最终判据。

这个模式有个名字,中间表示层

OpenCC 最值得带走的东西,不是它的繁简词库,也不是它的 Darts/Marisa 双引擎,而是它示范的那套**「中间表示层」模式**(Canonical Intermediate Representation)。

凡是涉及多对多映射的转换问题,都该先想想能不能插一层中间态。简繁转换是「地区 ↔ 地区」,货币换算可以插一个基准货币,单位转换可以插一个 SI 基本单位,字符编码转换插一个 Unicode 码位。这层中间态的核心价值是把信息先升到一个无损或低损的高维表示,再往各个目标降维。OpenCC 选「康熙字典式的古正字」做中间层,就是为了在简→繁这一步尽量保留语义区分度(「台」先存成「臺」),给下游地区层留出裁剪空间。

这个思路的副作用也要记住,中间层会丢信息,转换不可逆。所以它适合单向或「最终消费」场景(内容发布、字幕转写、文档分发),不适合需要反复来回转、还要保证原样回来的场景。

OpenCC 一个人(byvoid)起家,十六年里靠社区贡献把词库磨到了现在的精度,9800 多颗 Star 背后是成千上万条词典条目的人工校订。它不是最快的(最大匹配分词本身不算先进),也不是最全的(不做粤语翻译,不做整句改写),但在「简繁正字法转换」这个窄领域,它用一层克制的中间表示,把一个 O(n²) 的维护噩梦,收拾成了一个 O(n)、词库可热替换、政治上中立、还能反向扩展到日本新字体的基础设施。

下次你看到任何「A 转 B、A 转 C、B 转 D」的转换矩阵在失控膨胀,先别急着加表,问自己一句,能不能像 OpenCC 一样,先发明一个谁都不用的中间态。

评论互动

© 2026 王若风的技术博客 · Powered by Astro