vertilia / text
通过 gettext 和 PO 格式进行翻译
Requires
- php: >=7.4
- nikic/php-parser: ^5
Requires (Dev)
- phpunit/phpunit: ^9
README
基于 gettext 概念、工具和 PO 文件的翻译库。
描述
此库旨在继续使用 gettext 作为项目国际化方法,保留经过验证的本地化方法,但消除对复杂的目标系统配置和 gettext php 扩展的依赖。后者可能不适用于特定环境。
标准 gettext 工具链中的 xgettext 提取器不能正确处理所有系统上的 PHP heredoc/nowdoc 语法。我们在这里提供了一个替代的 xtext 脚本,该脚本将字符串提取到 POT 格式,这可以作为您的 gettext 工作流程的源文件使用。
从代码库(通过标准 gettext 方法或使用 xtext)提取的 PO 目录(代表 gettext 域),然后通过捆绑的 po2php 工具转换为本地的 .php 类。此类在内存中保留翻译,并作为原生 php 代码实现特定语言的规则。
简单消息通过 _() 方法维护,复数形式和上下文通过相应的 nget()、pget() 和 npget() 方法维护。
优点
从手册中
GNUgettext被设计为最小化国际化对程序源代码的影响,保持这种影响尽可能小且几乎不可察觉。如果国际化非常轻量级,或者至少在查看程序源代码时看起来如此,那么国际化更有可能成功。
- 无需在源代码中使用常量或其他中间结构替换消息;
- 处理复数形式;
- 处理上下文感知函数(
pgettext系列),这些函数目前尚未包含在 php 扩展中; - 无需在目标 OS 上安装附加的区域设置或设置环境变量;
- 基于 PO 文件的标准翻译过程;可以使用多种编辑器或过程;
- 翻译存储在
.php文件中,这允许快速自动加载和 opcache 缓存,最小化将翻译放入内存以及查找源字符串翻译的运行时努力; - 在多进程网络环境中稳定且可预测地工作,不受主机系统配置和当前安装的系统区域设置的限制;
- 捆绑的工具可以正确处理 PHP heredoc/nowdoc 语法,并将 .PO 文件编译为本机 php 代码。
安装
composer require vertilia/text
用法
使用 gettext 在 C 中编程历史上包括以下阶段(简化)
- 定义代码中使用的源消息语言(通常是英语)
- 在处理本地化消息时,在源代码中使用 gettext 函数
- (如果没有现有翻译,gettext 函数简单地返回传递的字符串,因此代码已经在这个阶段工作,在所有环境中返回英语消息)
- 使用 gettext 工具扫描代码并提取本地化消息,生成(或更新)
.po文本文件- (
.po文本文件包含从代码中提取的消息,目标语言的翻译以及目标语言复数形式的规则)
- (
- 在
.po文件中翻译新/更新的消息 - 将文本
.po文件编译成二进制.mo文件 - 将
.mo文件复制到您的代码中,现在 gettext 函数(在第 2 点中提到)可以使用它们来提取其参数的翻译 - 如果在应用程序中添加了新语言,请确保在目标系统上配置了相应的区域设置。
在 Text 中,我们可以跳过第 5、6 和 7 个阶段,并直接将 .po 文件编译成 .php 类,这些类包含目标语言的翻译字符串和复数形式规则。
这些生成的类存储在 locale/ 文件夹中,通过 composer 自动加载进行配置。它们就像其他 PHP 代码一样被 opcache 处理,并且在运行时占用最小的空间,因为只需要进行一次 CRC32 变换即可返回现有的翻译(或没有翻译)。
对于大型代码库,就像正常的 gettext 一样,您可以在域上分解翻译,这通常意味着每个域使用不同的 .po 文件。
当您开始项目时,如果您还没有 .po 文件,您可以使用基础 \Vertilia\Text\Text 对象来提供翻译文本。它将简单地返回传递的参数作为翻译消息,这对于调试目的来说通常是足够的。即使在基本形式中,它也已经足够智能,可以正确处理英语消息的复数形式。
<?php include __DIR__ . '/../vendor/autoload.php'; $t = new Vertilia\Text\Text(); echo $t->_('Just a test'), PHP_EOL; // output: Just a test echo $t->pget('page', 'Next'), PHP_EOL; // output: Next echo $t->nget('One page', 'Multiple pages', 1), PHP_EOL; // output: One page echo $t->nget('One page', 'Multiple pages', 5), PHP_EOL; // output: Multiple pages echo $t->npget('page', 'One sent', 'Multiple sent', 5), PHP_EOL; // output: Multiple sent
当您使用 gettext 工具(我们强烈建议使用广泛可用的翻译工具,如 POedit 或其他工具)从上述代码中提取消息时,您将生成一个扩展名为 .po 的文本文件,其中包含源语言消息、目标语言(例如法语)的占位符翻译以及法语复数形式转换的规则。在将 .po 文件中的占位符翻译成法语后,您通常将结果文件包含在您的项目中,作为 locale/fr_FR/LC_MESSAGES/messages.po。这是一个 GNU 标准,但在 Text 中,您可以使用任何文件夹和文件名。这里最重要的部分是您(以及您的代码库的其他用户)可以轻松地找到翻译文件,并清楚地区分语言和域。
有关在您的代码库上运行 gettext 工具时需要配置的内容,请参阅下文的
xgettext关键字。
如果
xgettext在您的系统上无法正常工作,请参阅下文的xtext参考。
以下是我们项目中 messages.po 文件内容的简化视图
注意您存储的结果 messages.po 文件的位置,因为您将立即需要它来生成翻译类。为此,您将运行捆绑的 po2php 命令(请参阅下文的示例 下面),并给出 messages.po 文件的路径。它将输出您将作为易于定位的 locale-src/MessagesFr.php 文件包含到项目中的 PHP 代码。
vendor/bin/po2php -n App\\L10n -c MessagesFr \ locale/fr_FR/LC_MESSAGES/messages.po \ >src/L10n/MessagesFr.php
因此,目前您已在项目中生成了 2 个额外的文件
locale/fr_FR/LC_MESSAGES/messages.po:包含来自您的应用程序代码的英语源消息、每条消息的法语翻译以及描述目标语言(法语)中复数形式使用的简单规则的文本文件。您将需要此文件来更新现有翻译、添加新翻译和删除未使用的翻译。这是一个标准的 PO 文件,您可以使用许多可用的工具进行编辑。每个翻译局都会处理此格式(如果它不处理,您最好选择另一个格式)。locale-src/MessagesFr.php:从messages.po文件生成的 PHP 类,它封装了目标语言的翻译以及处理法语复数形式的方法。
现在,是时候使用生成的 MessagesFr 类来显示您的翻译消息,而不是使用基础 Text 类了。
<?php require_once __DIR__ . '/../vendor/autoload.php'; $t = new App\L10n\MessagesFr(); echo $t->_('Just a test'), PHP_EOL; // output: Juste un test echo $t->pget('page', 'Next'), PHP_EOL; // output: Suivante echo $t->nget('One page', 'Multiple pages', 1), PHP_EOL; // output: Une page echo $t->nget('One page', 'Multiple pages', 5), PHP_EOL; // output: Plusieurs pages echo $t->npget('page', 'One sent', 'Multiple sent', 5), PHP_EOL; // output: Plusieurs envoyées
有关
composer的示例配置,请参阅下文的 建议的composer和git配置。
现在,您需要为其他语言创建翻译。
流程概述
Vertilia\Text\Text 对象包含处理翻译消息的方法。基类没有翻译,因此其方法简单地返回传递的参数。当将翻译添加到 .po 文件时,它们被保存为扩展 Vertilia\Text\Text 基类并具有翻译和重写 plural() 方法的语言类,以便在目标语言中选择正确的复数形式。
通常,您的代码将包括注入 Vertilia\Text\TextInterface 对象,创建消息,并使用外部 PO 编辑程序从代码中提取消息,处理 .po 文件中的翻译,并使用 po2php 工具更新语言类。
使用 Text 的本地化过程将遵循以下路径
Text 参考
Text::_()
翻译消息
public function _(string $message): string;
参数
$message源语言中的消息。
返回值
翻译成目标语言的消息(如果没有找到翻译,则为原始消息)。
示例 1:基类,没有翻译
$t = \Vertilia\Text\Text(); echo $t->_("Several words"); // output: Several words
示例 2:翻译 messages.po 为俄语后创建的 MessagesRu 类
$t = \App\L10n\MessagesRu(); echo $t->_("Several words"); // output: Несколько слов
Text::nget()
基于参数的复数形式的翻译。
public function nget(string $singular, string $plural, int $count): string;
参数
$singular源语言中消息的单数形式。$plural源语言中消息的复数形式。$count计数器以选择复数形式。
返回值
根据提供的 $count 在目标语言中翻译消息的复数形式之一。
示例 1:基类,没有翻译
$t = \Vertilia\Text\Text(); printf($t->nget("%u word", "%u words", 1), 1); // output: 1 word printf($t->nget("%u word", "%u words", 2), 2); // output: 2 words printf($t->nget("%u word", "%u words", 5), 5); // output: 5 words
示例 2:翻译 messages.po 为俄语后创建的 MessagesRu 类
$t = \App\L10n\MessagesRu(); printf($t->nget("%u word", "%u words", 1), 1); // output: 1 слово printf($t->nget("%u word", "%u words", 2), 2); // output: 2 слова printf($t->nget("%u word", "%u words", 5), 5); // output: 5 слов
Text::npget()
根据参数翻译消息的复数形式,在给定上下文中。
public function npget(string $context, string $singular, string $plural, int $count): string;
参数
$context源语言中消息的上下文。$singular源语言中消息的单数形式。$plural源语言中消息的复数形式。$count计数器以选择复数形式。
返回值
根据提供的 $count 在目标语言和上下文中翻译消息的复数形式之一。
示例 1:基类,没有翻译
$t = \Vertilia\Text\Text(); printf($t->npget("star", "%u bright", "%u bright", 1), 1); // output: 1 bright printf($t->npget("star", "%u bright", "%u bright", 2), 2); // output: 2 bright printf($t->npget("star", "%u bright", "%u bright", 5), 5); // output: 5 bright
示例 2:翻译 messages.po 为俄语后创建的 MessagesRu 类
$t = \App\L10n\MessagesRu(); printf($t->npget("star", "%u bright", "%u bright", 1), 1); // output: 1 яркая printf($t->npget("star", "%u bright", "%u bright", 2), 2); // output: 2 яркие printf($t->npget("star", "%u bright", "%u bright", 5), 5); // output: 5 ярких
Text::pget()
在给定上下文中翻译消息。
public function pget(string $context, string $message): string;
参数
$context源语言中消息的上下文。$message源语言中的消息。
返回值
目标语言和上下文中的翻译消息。
示例 1:基类,没有翻译
$t = \Vertilia\Text\Text(); printf($t->npget("star", "It's bright")); // output: It's bright
示例 2:翻译 messages.po 为俄语后创建的 MessagesRu 类
$t = \App\L10n\MessagesRu(); printf($t->npget("star", "It's bright")); // output: Она яркая
用于 xgettext 的关键字
为了允许 xgettext 从替换经典 gettext 函数的 Text 方法中提取消息,应提供以下配置给 xgettext 命令行实用程序
xgettext ... --keyword=_ --keyword=pget:1c,2 --keyword=nget:1,2 --keyword=npget:1c,2,3
图形界面工具,如 POedit,将提供配置屏幕,其中可以指定以下列表中的关键字
nget:1,2pget:1c,2npget:1c,2,3
为 composer 和 git 提出的配置
在生成 Text 类时,我们建议您将它们存储在应用程序的 L10n/ 文件夹中。考虑以下布局(简化,3 种语言)
/app/
├─ locale/
│ ├─ en_US/
│ │ └─ LC_MESSAGES/
│ │ └─ messages.po
│ ├─ fr_FR/
│ │ └─ LC_MESSAGES/
│ │ └─ messages.po
│ ├─ ru_RU/
│ │ └─ LC_MESSAGES/
│ │ └─ messages.po
│ └─ messages.pot
├─ src/
│ ├─ L10n/
│ │ ├─ MessagesEn.php
│ │ ├─ MessagesFr.php
│ │ └─ MessagesRu.php
│ └─ ...
├─ vendor/
│ ├─ autoload.php
│ ├─ composer/
│ └─ vertilia/
├─ www/
│ └─ index.php
├─ .gitattributes
└─ composer.json
在此,您的应用程序代码位于 src/ 和 www/ 文件夹中,假设应用程序命名空间为 App,消息类命名空间为 App\L10n,您的 composer autoload 指令配置如下
{
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}
请注意,存储 .po 文件的 locale/ 文件夹与 Text 消息类分开存储在 L10n/ 文件夹中,以简化从应用程序的二进制版本中排除此文件夹。在生产主机上,您不需要中间文件,因此您很可能将以下行包含到您的 .gitattributes
/locale export-ignore
xtext 参考
vendor/bin/xtext -h
Usage: xtext [OPTIONS] FILES,
OPTIONS:
-h Display usage message and quit
-x EXCLUDE_PATH
Pattern to exclude when scanning FILES, may be repeated to
provide multiple patterns. Executes before -i.
-i INCLUDE_PATH
Pattern to include when scanning FILES, may be repeated to
provide multiple paths. Executes after -x. Default: *
-c COMMENT_TAG
Comment tag to mark extractable comments from the source code.
Use empty string to extract all comments
FILES:
A list of one or more file or directory. If directory names are
specified, they are scanned recursively. Options -x and -i are
used to limit processed files.
EXAMPLES:
xtext *.php
Scan all .php files in current dir
xtext -c '' *.php
Scan all .php files in current dir, also extract comments
xtext -x /*/vendor -x /*/tests -i '*.php' -c TRANSLATORS: /app >msg.pot
Scan all .php files in /app directory and sub-directories,
excluding vendor and tests folders, extract comments starting
with TRANSLATORS: tag and write output to msg.pot file
在某些系统上,用于扫描 PHP 源文件并提取可翻译字符串的 xgettext 工具可能会在 PHP 文件使用 heredoc/nowdoc 语法时中断,特别是使用缩进关闭标识符(自 PHP 7.3 以来可用)。
为了从源代码中正确提取 gettext 行,可以使用捆绑的 xtext 工具。此工具将扫描源文件夹,找到可翻译字符串,并输出包含所有检测到的字符串的 POT 文件。它维护翻译的代码引用,并且还可以包括代码中对相应 Text 和 gettext 函数的调用之前的注释。
上述提到的POedit工具可以使用两种更新现有PO文件的方法,一种是通过使用xgettext扫描源目录以生成POT文件,并将其透明地合并到现有翻译中,另一种是使用带有提取翻译的现有POT文件。第一种方法更简单,但如果POedit无法自动从您的代码库中的所有文件中提取翻译,则可以使用xtext生成POT文件,并使用它来更新翻译。
po2php参考
vendor/bin/po2php --help
Usage: po2php [OPTIONS] messages.po
OPTIONS:
-n, --namespace=NAMESPACE Namespace to use (default: none)
-c, --class=CLASS_NAME Class name (default: Messages)
-e, --extends=PARENT_CLASS Parent class name implementing \Vertilia\Text\TextInterface
(default: \Vertilia\Text\Text)
-5, --php5 Produce php5-compatible code (use php5 branch of Text)
-h, --help Print this screen
示例1:在tests/locale中生成MessagesRu目录
vendor/bin/po2php -n App\\Tests\\Locale -c MessagesRu \ tests/locale/ru_RU/LC_MESSAGES/messages.po \ >tests/locale/MessagesRu.php
示例2:使用docker在tests/locale中生成MessagesRu目录
docker run --rm --volume "$PWD":/app php \ /app/vendor/bin/po2php -n App\\Tests\\Locale -c MessagesRu \ /app/tests/locale/ru_RU/LC_MESSAGES/messages.po \ >tests/locale/MessagesRu.php
不同语言中的复数形式
实际上,包含在PO文件中的复数形式选择器是一个C语言代码片段,它返回复数形式的0索引。在大多数情况下,此代码以相当直接的方式翻译成PHP,如下面的示例所示
# English, nplurals = 2
(n != 1)
像英语或德语这样的日耳曼语系语言只有2种复数形式,且不是1的任何数字实际上都是复数
其他语系可能包含更复杂的条件
# Russian, nplurals = 3
(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<12 || n%100>14) ? 1 : 2)
# Russian (alternative form), nplurals = 3
(n%10==0 || n%10>4 || (n%100>=11 && n%100<=14) ? 2 : n%10 != 1)
斯拉夫语系语言如俄语或塞尔维亚语有3种复数形式,用一句话很难描述。单数形式是任何以1结尾(但不是11)的数字。第一个复数形式是任何以2、3或4结尾(但不是12、13或14)的数字。其余所有(包括0、11、12、13和14)都是第二个复数形式。示例
您可以在gettext手册中找到更多示例。
特定语言的复数形式规则重写(在php 8.0之前)
在PHP 8.0之前,三元条件语句在PHP中的结合性不同于C,因此对于使用链式三元运算符作为复数形式选择器的语言,PO文件中的默认规则需要通过在PO文件中使用额外的括号进行更正
# Russian (php7-compat), nplurals = 3
(n%10==1 && n%100!=11 ? 0 : (n%10>=2 && n%10<=4 && (n%100<12 || n%100>14) ? 1 : 2))
# Russian (php7-compat alternative form), nplurals = 3
(n%10==0 || n%10>4 || (n%100>=11 && n%100<=14) ? 2 : (n%10 != 1))
使用printf()的复数形式使用
简要来说:要根据变量的值生成使用复数形式的行,请使用以下结构
printf($t->nget("%d file removed", "%d files removed", $n), $n);
在此,对nget的第一个调用使用$n将选择对应于单数或复数形式的格式字符串,然后此选定的字符串将被传递给使用另一个$n的printf,现在这个$n将插入相应的%d占位符。
有关详细讨论,请参阅gettext手册。
gettext函数的类方法替换
注意缺少域函数(dgettext...)。gettext中的域由不同的翻译文件表示,因此要使用另一个域的翻译,应实例化另一个Text对象。
示例
gettext样式putenv('LC_ALL=fr_FR'); setlocale(LC_ALL, 'fr_FR'); bindtextdomain("myPHPApp", "./locale"); textdomain("myPHPApp"); echo gettext("Welcome to My PHP Application"), "\n"; echo dgettext("anotherPHPApp", "Welcome to Another PHP Application"), "\n";
Text样式$myDomain = \App\L10n\MyPhpAppFr(); $anotherDomain = \App\L10n\AnotherPhpAppFr(); echo $myDomain->_("Welcome to My PHP Application"), "\n"; echo $anotherDomain->_("Welcome to Another PHP Application"), "\n";
此外,虽然上下文感知函数(pgettext系列)在捆绑的PHP gettext扩展中缺失,但xtext将在可能的情况下从类似命名的函数中提取字符串。