常见陷阱¶
本站被翻译成三十五种语言,每一种都是照着这份文档所讲的循环跑出来的。按业界 标准,这只是一个很小的语料规模,可它已经足以撞上大多数让 i18n 比看上去更难的 陷阱。
下面每一节都是这里真实出过的问题:当时它长什么样,以及本库替你检查的部分与 仍属于你自己判断的部分之间,界线落在哪里。
重命名一个变量,就等于重新翻译一个句子¶
msgid 就是目录的键,而被插值的名称就在这个键里面。把一个常量移到模块作用域,
并按 Python 风格要求改成大写——author 改成 AUTHOR——就把
Copyright © 2026 {author} · MIT License 变成了一条任何目录都没见过的消息。
这行字的每一份译文都会被重新送进 fuzzy 周期,每种语言都要走一遍,而这次重命名
在读者眼里什么也没改变。
本库不会阻止你:两种写法都是合法的占位符名称。它真正做到的,是让这个名称值得 被保护——插值必须是纯名称,因此出现在 目录键里的是翻译者读得懂的词,而不是一段表达式。
反过来的情形则天生安全。转换和格式说明不属于 msgid,所以把 {amount:,.2f}
收紧成 {amount:,.0f} 不改变任何键,也不会让任何语言的任何译文失效。
nplurals=2 并不意味着两条不同的字符串¶
土耳其语、匈牙利语、波斯语和孟加拉语都声明两种复数形式,而在这四种语言里,
一条计数消息的两种形式合理地就是同一条字符串——名词在数词之后保持单数,所以
{n} sayfa 对一页和十页都成立。一位“修正”了这处重复的审校者,反而弄坏了译文。
反向的错误同样容易犯。拉脱维亚语的第三种形式只为零而存在;斯洛文尼亚语的
第二种是双数,恰好用于二;罗马尼亚语的最后一种形式需要用到 de 这个词,
而前两种绝不能带它。用一个单数和一个复数去填这些槽位,得到的目录只在没人测试
的计数上出错。
更糟的是,槽位的顺序并没有语义可言。威尔士语给它的五种形式编号时,
msgstr[0] 是通用情形,msgstr[1] 才是单数。按看起来顺理成章的次序填写,
会把单数放到每条不计数的消息都会取到的位置上。
本库对这些一概不揽,而这正是要点:目标语言的复数规则活在它自己的目录头里, 而并集/交集规则允许译文拥有比源语言更多或更少的形式。它检查的, 是在不知道语言的前提下唯一能检查的事——每一种形式都保留了它所需要的占位符。
两种形式相同,是有原因的¶
爱尔兰语有五种复数形式,而在本站的构建报告里,其中好几种拼写完全一样。这不是
复制粘贴的疏漏:leathanach 以 l 开头,而爱尔兰语数词触发的两种词首辅音变化
都不会写在 l 上。这些形式仍在做实事——词干在 leathanach 与 leathanaigh
之间交替,十以上的计数又回到单数——只是没有哪个表示“页”的名词能显出这种差别。
任何把重复形式标记为可疑的检查,都会误报正确的爱尔兰语。这件事只有懂这门语言 的人才能审。
一条消息只能与一个计数保持一致¶
本站的构建报告会说明渲染了多少页、花了多长时间。写成“Rendered {n} pages in
{seconds} seconds”看起来人畜无害,实则无法翻译:gettext 依据一个计数选出一种
形式,而那个计数是 n。seconds 这个词却必须与一个复数机制根本看不见的数字
保持一致。
解法是把第二个数量写成单位符号而不是单词,而单位符号本身也是要本地化的:本站的
目录里带着 s、с、ث、שנ׳ 和 mp,法语、西班牙语和瑞典语的排版还要求在
符号前加一个空格,英语则不要。这些都不归本库管——但察觉到一条消息需要两处
一致,这件事是要紧的,而唯一的工具就是把消息换一种写法。
编辑一句英语,就是在编辑外语的语法¶
首页曾经写着“all ten language editions”。把这个数字删掉——一处一个词的英语改动, 之所以做是因为那个数字老是过时——却把一个复数主语变成了单数。西班牙语、 意大利语、葡萄牙语、俄语、乌克兰语、希腊语、荷兰语和希伯来语都得重新调整动词 的一致关系;有几种还得连分词一起改。
一处在英语里读来微不足道的源改动,到了下游并不微不足道。把它标记为 fuzzy——
也就是 pybabel update 所做的事——正是让每位翻译者有机会察觉的机制。
看不见的差异能挺过每一次复制粘贴¶
指南里引用了一条包含 (nаme) 的诊断——那是刻意写出的转义形式,因为它所指的
字符是一个 Cyrillic а,没有读者能把它和 Latin 的那个区分开。本站的翻译者
把这个转义还原成了实际字符,前后共五次,分布在五种不同的语言里,每次都产出
一个看起来正确、实则错误的页面。
这一条本库确实能抓住,而这也正是诊断被设计成如今这个样子的原因:字母混用了不同 书写系统的占位符会被报告两次, 一次可读、一次转义,因为只有转义形式这一种拼写能把二者区分开。花括号内的 no-break space 会按 code point 打印,出于同样的道理。目录检查器会在这条消息 发布之前就拒绝它。
非空并不等于已翻译¶
一份把 msgid 复制进 msgstr 搭起来的目录,能通过所有幼稚的检查:没有空的、没有 fuzzy 的、消息集合完全吻合。本站有一个语言版本就以这种状态上线了好几个小时。 另一个语言版本里也有八个页面是英语源文件的逐字节副本——它们能通过“比对两边代码 块”的检查,因为它们本来就是同一个文件。
这两件事都不是一个翻译库能看见的。两件事都很容易测,但不能靠“要求每一条都必须
与源文不同”来测:OK、产品名、人名、缩写和代码标识符都会翻译成它们自己,禁止
这种情况的检查会永远产生误报。
该测的是比率——以整份目录或整个页面为单位统计,再把异常值交给人来看。本站自己 的测试正是这么做的:它把每个语言版本的散文行与英语源文比对,同一率超过 25% 就 判定失败。那份伪造的版本是 87%;每一个真实译本都落在 4% 到 8% 之间,那正是少数 合理重合的行,比如 URL 和引用的程序输出。这两类样本相距足够远,阈值不必精确。
被翻译的东西不只有目录¶
这里有两次失败与 gettext 毫无关系。
翻译一个标题会改变由它生成的锚点,于是每一条指向该小节的跨页链接都会断掉—— 悄无声息,而且只在那一种语言里断。本站在每个标题上都钉住英语锚点,并由一个测试 从英语页面推导出期望的锚点清单。
另外,站点生成器为六十八种语言提供界面翻译,其中并不包含斯瓦希里语和爱尔兰语。 缺了它,构建并不会降级成英语;模板 include 会失败,那个语言版本根本构建不出来。 本仓库里有两个文件的存在,就是为了填上这个缺口。
你的工具也有 bug¶
这份文档推荐用来捕捉过期目录的那一步 CI——pybabel update --check——对任何用到
pgettext 或 npgettext 的项目都干不了这活。在 Babel 2.18.0 上,它会在每次运行
时把每一个含 msgctxt 的目录都报成过期。这项比对经由 Catalog.is_identical
完成,它按每条消息的存储键去查找消息——而对带上下文的消息来说,那个键是
(id, context) 这个二元组,Catalog.get 并不接受它。于是查找一无所获,两个目录
也就永远不会比较相等:
>>> from babel.messages.catalog import Catalog
>>> c = Catalog(locale="ja")
>>> c.add("Guide", "ガイド", context="navigation")
<Message 'Guide' (flags: [])>
>>> c.is_identical(c)
False
这个问题是在这里试着用它时发现的,已上报上游,而替代的检查 在生产实践页上。
普遍的教训则是那条让人不舒服的:一个永远亮红灯的关卡比没有关卡更糟,因为团队会 干脆把它关掉。在你信任一个 CI 检查会正确地失败之前,先验证它真的能够通过。
一句话说清本库是干什么的¶
这一页的大部分内容,都是没有工具能替你承担的判断。工具能做的,是保证一份译文 无法改变它所翻译的那个句子的结构——不能丢掉一个值、不能凭空造一个、不能重新格式化 一个,也不能伸手去碰你的对象——并且能用一句话把这件事说给那个必须去修它的人听, 让他知道该怎么动手。这就是本库承诺的全部,而本站其余部分讲的都是它如何守住这个 承诺。