Перевод сайта с помощью gettext

Перевод сайта с помощью gettext

iSergium

Довольно часто перед веб-разработчиками возникает необходимость перевести сайт на несколько языков, сделать сайт мультиязычным. Многие веб-подмастерья используют для хранения переводов базу данных, тем самым создавая на неё дополнительную и ненужную нагрузку. Обращение к файловой системе имеет более высокую скорость, но создаёт необходимость грамотно огранизовать хранение и считывание переводов. Благо, в PHP уже имеется максимально оптимальный для таких целей механизм gettext, который не просто работает с файловой системой, так ещё и с уже скомпилированными файлами, обеспечивая наилучшую производительность.

Функции gettext реализуют NLS (Native Language Support) API. Официальная документация находится на сайте gnu.org. Для работы модуля требуется пакет gettext. Его можно установить из репозиториев, например в Debian-дистрибутивах это будет так:

sudo apt-get install gettext

Проверить версию установленного пакета можно командой:

gettext -V

Применение

Самый простой пример использования gettext в PHP:

echo _("message");

Слово "message" ищется в библиотеке переводов и, при его наличии, выводится найденный перевод, иначе выводится исходное слово "message".

Сами переводы хранить неважно где, главное чтоб они были доступны для кода. Для удобства расположим их в корне сайта в папке langs. Пусть будет 3 языка: немецкий, английский и русский. Конечная структура папки langs будет такой:

.
├── de_DE
│   └── LC_MESSAGES
│       ├── de_DE.mo
│       └── de_DE.po
├── en_US
└── ru_RU
    └── LC_MESSAGES
        ├── ru_RU.mo
        └── ru_RU.po

Файлы *.po содержат переводы в текстовом виде, *.mo - их компилированные версии. В имени файлов удобно использовать их локаль. Английскому языку эти файлы не обязательны, так как все ключи в коде будем писать на нём. Для ключей можно использовать и любой другой язык, лишь бы кодировка позволяла, но если сайт международный, то крайне желательно использовать английский.

Структура po-файлов простая:

msgid "message"
msgstr "сообщение"

В начале файла пишут служебную информацию, например для русского языка можно написать так:

"POT-Creation-Date: 2016-04-10 17:15+0500\n"
"PO-Revision-Date: 2016-04-29 02:14+0500\n"
"Last-Translator: Sergey\n"
"Language: ru_RU\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=3; plural=(n % 10==1 && n % 100!=11 ? 0 : n % 10>=2 && n % 10<=4 && (n % 100<10 || n % 100>=20) ? 1 : 2);\n"

Строка "Plural-Forms" задаёт правила для форм множественных чисел. Ниже в примере она будет разобрана подробнее.

Подключение файлов с переводами к проекту:

define('BASE_PATH', realpath(dirname(__FILE__)));
define('LANGUAGES_PATH', BASE_PATH . '/langs');
 
$locale = 'ru_RU';
 
putenv("LC_ALL=" . $locale);
setlocale(LC_ALL, $locale, $locale . '.utf8');
bind_textdomain_codeset($locale, 'UTF-8');
bindtextdomain($locale, LANGUAGES_PATH);
textdomain($locale);

Здесь подключается русский язык, чтобы подключить другой язык его нужно так же записать в переменную $locale. Брать его из выставленных пользователем настроек или из URL сайта - этот выбор зависит от особенностей Вашего сайта.

Пример использования в коде:

echo mb_ucfirst(_('message')) . '. ' . _('Message') . '. ' . _('Second message') . '. ' . sprintf(_('Message #%d'), 3) . '. 4 ' . ngettext('message', 'messages', 4) . '. 5 ' . ngettext('message', 'messages', 5) . '.';

Здесь шесть обращенией к gettext:

  1. _('message')
  2. _('Message')
  3. _('Second message')
  4. _('Message #%d')
  5. ngettext('message', 'messages', 4)
  6. ngettext('message', 'messages', 5)

Обращения 1, 2 и 3 максимально просты: есть ключ, ищется перевод. Обращение 4 с этой позиции ничем не отличается от предыдущих, разница лишь в последующем использовании: вставлен %d для подставления туда чисел, например с помощью sprintf. Также можно использовать любые другие ключевые слова для их последующей замены, например "%username%", и заменять их потом с помощью str_replace, главное не перевести их. Обращения 5 и 6 используют множественные формы.

Функция mb_ucfirst самописная, ибо ucfirst плохо работает с юникодом. В надежде что когда-то появится нативная функция mb_ucfirst перед определением функции делается проверка на её существование. Иногда приходится использовать одно и то же слово в разных регистрах, в таких случаях подобные функции помогают.

if (!function_exists('mb_ucfirst')) {
    function mb_ucfirst($string) {
        return mb_strtoupper(mb_substr($string, 0, 1, 'UTF-8'), 'UTF-8') . mb_substr($string, 1, mb_strlen($string), 'UTF-8');
    }
}

В итоге файл langs/ru_RU/LC_MESSAGES/ru_RU.po пусть будет таким (даты будут изменяться автоматически описанным ниже ПО):

msgid ""
msgstr ""
"POT-Creation-Date: 2016-04-10 17:15+0500\n"
"PO-Revision-Date: 2016-04-29 02:14+0500\n"
"Last-Translator: username\n"
"Language: ru_RU\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=3; plural=(n % 10==1 && n % 100!=11 ? 0 : n % 10>=2 && n % 10<=4 && (n % 100<10 || n % 100>=20) ? 1 : 2);\n"

msgid "Second message"
msgstr "Второе сообщение"

msgid "Message #%d"
msgstr "Сообщение #%d"

msgid "message"
msgid_plural "messages"
msgstr[0] "сообщение"
msgstr[1] "сообщения"
msgstr[2] "сообщений"

Третья запись ("message") покрывает обращения 1, 5 и 6, первая и вторая - 3 и 4. Перевод для обращения 2 не будет найден, т.к. регистрозависимость.

Третья запись здесь самая интересная, т.к. описывает множественные формы. Правила описаны в начале файла в пункте "Plural-Forms".

"Plural-Forms: nplurals=3; plural=(n % 10==1 && n % 100!=11 ? 0 : n % 10>=2 && n % 10<=4 && (n % 100<10 || n % 100>=20) ? 1 : 2);\n"

nplurals - количество форм, в данном случае равно трём.
plural - сами правила, представляют из себя вложенный тернарный оператор, который возвращает число от 0 до 2, в зависимости от этого числа берётся элемент из msgstr. Сравнив эти условия и числа с примером из po-файла всё станет понятно. Правила в разных языках отличаются.

Немецкий файл langs/de_DE/LC_MESSAGES/de_DE.po заполняется по аналогии:

msgid ""
msgstr ""
"POT-Creation-Date: 2016-04-10 17:15+0500\n"
"PO-Revision-Date: 2016-04-29 02:14+0500\n"
"Last-Translator: username\n"
"Language: de_DE\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"

msgid "Second message"
msgstr "Zweite nachricht"

msgid "Message #%d"
msgstr "Nachricht #%d"

msgid "message"
msgid_plural "messages"
msgstr[0] "nachricht"
msgstr[1] "nachrichten"

В нём множественных форм две, потому и правило заметно проще. Также изменился пункт "Language". Это единственные поля в данном примере которые нужно менять при добавлении новых языков.

Компилирование MO

Из po-файлов получить скомпилированный mo-файл можно несколькими способами.

Первый - с помощью программ вроде Poedit. В ней есть и редактирование po-файлов и возможность автоматически компилировать файл MO при сохранении. Включается/отключается она в общих настройках приложения. Минус - нельзя задать параметры этого компилирования. Плюс - там немало возможностей для редактирования переводов; например, сканирование кода на предмет вызова gettext и добавления их в PO.

Второй способ - консольная команда msgfmt. Полный список параметров доступен в документации по ссылке, но обычно достаточно такой простой команды:

/usr/bin/msgfmt "ru_RU.po" -f -o "ru_RU.mo"

Здесь файл ru_RU.mo компилируется из находящихся в файле ru_RU.po исходников с использованием fuzzy-записей. Fuzzy - это нечёткие переводы, которые могут быть некорректными. Появляются они, например, при автоматических переводах. Если возникают сомнения в правильности этих переводов, то параметр -f следует убрать.

Чтобы скомпилировать все файлы в папке langs можно воспользоваться bash-скриптом. Предположим, что все bash-скрипты расположены в папке bash в корне сайта:

cd ../langs
for
lang_locale in * ; do
    if [ ! -f "$lang_locale/LC_MESSAGES/$lang_locale.po" ]; then
        continue
    fi
    cd "$lang_locale/LC_MESSAGES"
    /usr/bin/msgfmt "$lang_locale.po" -f -o "$lang_locale.mo"
    echo "$lang_locale - compiled"
    cd ../../
done

Проверку наличия файла (третья строка) нельзя назвать обязательной, но она предотвратит ошибки в случае если в папке найдутся какие-либо не относящиеся к переводам файлы и папки. Итог выполнения:

$ sh compile.sh
de_DE - compiled
ru_RU - compiled

После этого можно проверить вывод переводов через PHP. Итоговый index.php выглядит так:

define('BASE_PATH', realpath(dirname(__FILE__)));
define('LANGUAGES_PATH', BASE_PATH . '/langs');
 
$locale = 'ru_RU';
 
putenv('LC_ALL=' . $locale);
setlocale(LC_ALL, $locale, $locale . '.utf8');
bind_textdomain_codeset($locale, 'UTF-8');
bindtextdomain($locale, LANGUAGES_PATH);
textdomain($locale);
 
if (!function_exists('mb_ucfirst')) {
    function mb_ucfirst($string) {
        return mb_strtoupper(mb_substr($string, 0, 1, 'UTF-8'), 'UTF-8') . mb_substr($string, 1, mb_strlen($string), 'UTF-8');
    }
}
 
echo mb_ucfirst(_('message')) . '. ' . _('Message') . '. ' . _('Second message') . '. ' . sprintf(_('Message #%d'), 3) . '. 4 ' . ngettext('message', 'messages', 4) . '. 5 ' . ngettext('message', 'messages', 5) . '.';

Результатом выполнения кода будет это:

Сообщение. Message. Второе сообщение. Сообщение #3. 4 сообщения. 5 сообщений.

Также самостоятельно проверьте вывод в немецкой локали de_DE.

Если вывод по-прежнему на английском, то, вероятно, в Вашей системе не установлены нужные локали. Список умеющихся можно получить командой:

locale -a

Недостающие можно добавить командой (на примере немецкой):

sudo locale-gen de_DE.utf8

После добавления новой локали иногда требуется перезапуск апача, иначе он её не увидит.

sudo service apache2 restart

Сканирование кода, добавление новых переводов

Ручное добавление всех новых фраз в po-файлы в процессе разработки - не самый удобный способ, из-за него приходится отвлекаться от самой разработки. Гораздо удобнее сразу писать код так, словно перевод уже есть - когда он появится тогда он и выведется. В таком случае потребуется сканирование кода на предмет новых переводов. Для этих целей используется утилита xgettext.

Добавим в index.php новое слово, пусть будет "Application":

echo mb_ucfirst(_('message')) . '. ' . _('Message') . '. ' . _('Second message') . '. ' . sprintf(_('Message #%d'), 3) . '. 4 ' . ngettext('message', 'messages', 4) . '. 5 ' . ngettext('message', 'messages', 5) . '. ';
echo _("Application");

Bash-скрипт для сканирования кода и добавления новых найденных переводов пусть лежит в той же папке bash. Сначала разберём этот пример сканирования:

LIST=`find . -name "*.php"`
/usr/bin/xgettext --language=PHP $LIST --from-code=UTF-8 --no-location --no-wrap -o /tmp/xgettext.pot

Здесь происходит поиск всех файлов *.php, затем весь этот список отдаётся в xgettext и все найденные в этих файлах фразы (без указания их расположения в файлах (--no-location) и без переносов(--no-wrap)) пишутся в /tmp/xgettext.pot. Дополнительно заданы кодировка файлов (UTF-8) и язык (PHP). Утилита поддерживает немало других языков, включая JavaScript. Название и расположение файла экспорта можно сделать любым другим, но временная папка /tmp подходит для этого дела идеально.

Получившийся файл - это стандартный po-файл, только без настроек и переводов. В нём находятся все используемые в index.php фразы: к трём уже имеющимся в наших файлах добавились две - "Message" и "Application". Добавлять их вручную к имеющимся тоже не вариант, для этих целей лучше подойдёт утилита msgmerge. В итоге со сканированием получится вот такой bash-скрипт:

cd ..
echo "Parsing..."
LIST=`find . -name "*.php"`
/usr/bin/xgettext --language=PHP $LIST --from-code=UTF-8 --no-location --no-wrap -o /tmp/xgettext.pot
 
echo "Merging..."
cd langs
 
for lang_locale in * ; do
    if [ ! -f "$lang_locale/LC_MESSAGES/$lang_locale.po" ]; then
        continue
    fi
    echo $lang_locale
    cd "$lang_locale/LC_MESSAGES"
    /usr/bin/msgmerge "$lang_locale.po" /tmp/xgettext.pot -U --backup=off --no-wrap --no-fuzzy-matching
    cd ../../
done

Слияние файлов $lang_locale.po и xgettext.pot происходит с обновлением первого (параметр -U), без создания бэкапа (--backup=off; ибо зачем оно когда есть git и аналоги), без переносов длинных строк (--no-wrap; ибо это мешает чтению исходников) и без автоперевода по имеющимся фразам (--no-fuzzy-matching; странный включенный по умолчанию функционал, который ищет по имеющимся переводам похожие и добавляет их к новым фразам с пометкой fuzzy, при этом работает плохо и немного замедляет весь процесс слияния).

Результат выполнения:

$ sh lang.sh
Parsing...
Merging...
de_DE
.... завершено.
ru_RU
... завершено.

Количество точек перед "завершено" зависит от времени выполнения.

После выполнения команды оба недостающих ранее слова добавились в po-файлы внутри папки langs. Теперь им стоит добавить перевод (вручную или с помощью Poedit) и затем выполнить компилирование.

Конечное дерево файлов данного проекта выглядит так:

.
├── bash
│   ├── compile.sh
│   └── lang.sh
├── index.php
└── langs
    ├── de_DE
    │   └── LC_MESSAGES
    │       ├── de_DE.mo
    │       └── de_DE.po
    ├── en_US
    └── ru_RU
        └── LC_MESSAGES
            ├── ru_RU.mo
            └── ru_RU.po

Добавление нового языка

Например, нужно добавить испанский язык. Обозначение локали языка можно найти, например, здесь или здесь (но вместо "-" использовать "_"). Интернациональному испанскому (или испанскому из Испании) соответствует обозначение es_ES.

Нужно проделать эти шаги:

  1. Создать папку langs/es_ES/LC_MESSAGES, в ней создать файл es_ES.po.
  2. Заполнить его по аналогии с приведёнными выше языками, подправив поля "Language" и, при необходимости, "Plural-Forms". Сами фразы можно не добавлять:
    msgid ""
    msgstr ""
    "POT-Creation-Date: 2016-04-10 17:15+0500\n"
    "PO-Revision-Date: 2016-04-29 02:14+0500\n"
    "Last-Translator: username\n"
    "Language: es_ES\n"
    "MIME-Version: 1.0\n"
    "Content-Type: text/plain; charset=UTF-8\n"
    "Content-Transfer-Encoding: 8bit\n"
    "Plural-Forms: nplurals=2; plural=(n != 1);\n
  3. Провести сканирование кода - все имеющиеся фразы добавятся в этот файл (bash-скрипт выше)
  4. Заполнить файл переводами (вручную в текстовом редакторе или специальными программами, лучше всего с этой задачей справятся переводчики)
  5. Скомпилировать MO (bash-скрипт выше)

Затем, если сайт уже рабочий, как-нибудь добавить новый язык в интерфейс, чтобы пользователи могли его выбрать и использовать.

Аналогично можно использовать gettext для любых других проектов с другими языками программирования.

Дополнительно: