Мультиязычное приложение Phonegap

Выбор техники перевода

Приложение Phonegap представляет собой обычную html-страницу и JavaScript файл. Многоязычность можно достигнуть несколькими путями.
1. Например, можно сделать по одной страничке html на каждый язык. Что-то типа index-ru.html, index-en.html и подгружать нужную из главной index.html.

Минусы такого подхода

  • Очень сложно будет вносить одинаковые изменения в разные файлы по мере развития программы.
  • Невозможно одновременно работать программисту и переводчику (если это разные люди).
  • Очень сложно будет делать изменения по текстам, к которым уже сделан перевод.

Плюс такого подхода

  • Переводчику будет легко переводить и сразу видеть изменения (в браузере).
2. Можно пойти другим путём - возложить задачу перевода на JavaScript. На HTML делать только каркас приложения, а все тексты подгружать средствами JavaScript.

Минусы подхода

  • Очень легко где-то ошибиться программисту и тогда в нужном месте html-страницы будет либо пусто, либо не тот перевод.
  • Переводчику будет очень сложно работать. Например, будет эталонный языковой файл (английский). Переводчик его берёт, копирует и делает перевод непосредственно в файле. Пока всё легко и гладко. Потом программист изменяет эталонный языковой файл. Переводчику нужно скачать новый файл, понять, что изменилось, найти уже переведённое, как-то всё объединить в новый перевод. И так каждый раз...

Плюс подхода


  • Программист и переводчик могут работать одновременно.

3. Чтобы избежать всех этих неудобств был придуман и годами утвердился метод переводов Gettext. Помимо устранения всех перечисленных выше трудностей, этот способ решает проблемы с формами единственного и множественного числа (одно яблоко; два яблока; пять яблок) - которая в разных языках отличается. Смысл методики следующий. Программист пишет программу, где весь текст оборачивается в специальные конструкции, например {{_ "My text"}}. Специальная программа (стандартная xgettext) натравливается на файлы с такими обёрнутыми текстами и собирает их в один файл .PO. Например, en.po для английского языка. Переводчик берёт себе этот файл и работает с ним привычными средствами (есть много программ для работы с этим форматом). При изменении программы генерируется новый файл .PO, который учтёт уже имеющиеся переводы! Этот формат в конечном счёте можно преобразовать в какой-то другой для удобного встраивания в программу.

Минусы подхода


  • Стандартными средствами этот метод не работает с файлами HTML и JavaScript.

Плюсы подхода


  • Программист и переводчик могут работать одновременно, не мешая друг другу.
  • Легко вносить изменения в переводы.
  • Нет проблем с формами множественного числа.
  • В худшем случае, если нет перевода, текст программа покажет на английском языке.

Изобретаем велосипед

Я предлагаю подход к интернационализации приложения, задействующий целый стек технологий. Само приложение пишется на HTML и JavaScript и затем компилируется под ту или иную платформу (Android, Windows и т.п.) с помощью PhoneGap. Чтобы упростить себе жизнь и не писать всё с нуля, задействован очень элегантный фреймворк Framework7. В рамках этого фреймворка используется шаблонизатор Template7. В шаблонизаторе задействован механизм помощников (helpers), который позволяет мне обёрнутые текстовые конструкции из файла html перевести с помощью популярной библиотеки Jed. Языковые файлы jed использует в формате JSON. Чтобы сгенерировать эти файлы, нужно сначала по методике Gettext собрать исходные тексты из html/javascript и сформировать файлы .po, с которыми будет работать переводчик в программе PoEdit (или любой другой, поддерживающей Gettext). После того, как перевод готов, файлы .po преобразуются в .json с помощью утилиты po2json.
Вся загвоздка в этой расписанной схеме кроется в том, что родная утилита xgettext не ищет тексты для переводов в файлах .html, .js (Poedit с версии 1.7 начал понимать JavaScript вслед за GNU Gettext версии 0.18.3) и .json. Видимо, когда создавали этот инструмент, не предвидели, что кому-то понадобится создавать приложение на HTML. Но прогресс не стоит на месте и это стало востребовано, а вот инструмент заточить не подоспели. Приходится идти на ухищрения.

Сбор текстов для переводов из файлов .js и .json

Для генерации файлов переводов будем использовать программу Poedit. Для тех, у кого номер версии Poedit менее 1.7 потребуются следующие ухищрения (для более новых версий ничего дополнительно делать не требуется). В программе заходим в меню [Правка/Установки], переходим на последнюю вкладку "Парсеры". Преобразовываем настройки парсера для Perl, чтобы он искал нам тексты в JavaScript файлах. Для этого дополним список расширений до следующего состояния: "*.pl;*.js;*.json". Остальные настройки оставляем без изменений:

Сбор текстов для переводов из файлов .html

С файлами HTML придётся помучится больше. Сначала установим парсер xgettext для Handlebars. Пропишем его в настройках PoEdit, как в инструкции на странице разработчика. Получим ещё один парсер:
Чтобы этот парсер стал собирать тексты из HTML, придётся все расширения .html на момент обработки изменить на .hbs... Или применить доп настройку (которую любезно подсказал автор утилиты):
список список расширений дополняем до

*.hbs ; *.html
команду парсера дополняем до

xgettext-template --force-po -o %o %C %K %F -L Handlebars
Теперь всё готово к сбору текстов прямо из html-файлов. Итоговый вид парсера (экстрактора):

Готовим файлы к переводу

Для тестирования того, что тексты для переводов собираются из файлов нормально, подготовим тестовый файл HTML test.html:
<!DOCTYPE html>
<html>
  <head>
    <title>My App</title>
  </head>
  <body>
    <p id="pText">{{_ myText}}</p>
    <button id="btnSign">{{_ "Sign in"}}</button>
    <p id="pPlural">{{ngettext "There is one translation." "There are %d translations." count}}</p>
  </body>
</html> 
Кроме HTML у меня используются данные, подгружаемые из JSON. Видимо, придётся изначально готовить отдельные файлы JSON по каждому языку. Сейчас объясню, почему. Чтобы парсер смог собрать тексты из JSON, нужно его составить так (text.json):
{"exerciseType":[
  {
    "id": 0,
    "name": {{_ "Exercise 1"}},
    "exercises": [
      {
        "name": {{_ "WG pull down"}}
      },
      {
        "name": {{_ "NG pull down"}}
        ]
      },
      {
        "name": {{_ "Chin up"}}
      }
    ]
  }
]}
Но тогда JSON файл будет не правильной структуры (ожидаются определённые простые типы значений параметров) и программа его просто не поймёт. Можно сделать в таком формате файл, парсером собирать с него тексты для перевода, а перед сборкой программы убирать все конструкции "{{_" и "}}", а затем в программе переводить полученные данные с помощью Jed: i18n.gettext(). Это нужно будет проделывать каждый раз при сборке программы - не удобно. Поэтому отказываемся от идеи хранить тексты к переводу в .json.
Открываем программу Poedit и создаём в ней первый проект по переводу. Для начала пусть это будет английский язык. Все тексты в программе и так на английском, но для примера перевод на английский в данном случае также делаем. В меню нажимаем [Файл/Создать каталог...] и заполняем форму. Для параметра "Формы множественного числа" внизу окошка приведена ссылка на шпаргалку. Либо можно про всё это по-подробнее прочитать в GNU первоисточнике. Для английского языка эта магическая строка выглядит так:
nplurals=2; plural=n != 1;
В старых версиях Poedit нужно было вручную вписывать правильные формулы множественного числа. В новых версиях программы реализовано большое удобство - по выбранному языку автоматически выставляется нужная формула:

Переходим на следующую вкладку и вручную (?!) заполняем путь до каталога, где лежат наши тестовые файлы:
Переходим на следующую вкладку и заполняем все ключевые слова. У меня будут использоваться следующие: "_", "ngettext:1,2" (для множественного числа) и "gettext" (для файлов .js). В новых версиях Poedit (а может и в старых) можно тут вообще ничего не указывать, т.к. для каждого языка парсер уже знает предопределённые ключевые слова.
Нажимаем "OK" и создаём файл en.po. После этого моментально наши файлы просканируются и будет выдан результат - все найденные тексты для перевода (с учётом JSON, который я потом переделал):
Всё. Теперь сгенерированный файл en.po можно отдавать переводчику, который всё в той же программе Poedit (или в какой-нибудь другой) сможет перевести все строки. Допустим, переводчик перевёл одну фразу. Посмотрим, что получилось внутри файла en.po:
msgid ""
msgstr ""
"Project-Id-Version: Example\n"
"POT-Creation-Date: 2015-01-14 16:40+0300\n"
"PO-Revision-Date: 2015-01-14 16:44+0300\n"
"Last-Translator: Oleg Ekhlakov <subspam@mail.ru>\n"
"Language-Team:  <subspam@mail.ru>\n"
"Language: en\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: Poedit 1.5.4\n"
"X-Poedit-KeywordsList: _;ngettext\n"
"X-Poedit-Basepath: .\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
"X-Poedit-SourceCharset: UTF-8\n"
"X-Poedit-SearchPath-0: /home/oleg/Загрузки/testi18/hbs\n"
 
#: /home/oleg/Загрузки/testi18/hbs/text.json:4
msgid "Exercise 1"
msgstr "First exercise" 
 
#: /home/oleg/Загрузки/testi18/hbs/text.json:7
msgid "WG pull down"
msgstr "" 
 
#: /home/oleg/Загрузки/testi18/hbs/text.json:10
msgid "NG pull down"
msgstr "" 
 
#: /home/oleg/Загрузки/testi18/hbs/text.json:14
msgid "Chin up"
msgstr "" 
 
#: /home/oleg/Загрузки/testi18/hbs/test.hbs:8
msgid "Sign in"
msgstr "" 
 
#: /home/oleg/Загрузки/testi18/hbs/test.hbs:9
msgid "country"
msgstr ""

#: /home/oleg/Загрузки/testi18/hbs/test.hbs:10
msgid "There is one translation."
msgid_plural "There are %d translations."
msgstr[0] ""
msgstr[1] "" 
Некрасиво получилось с кириллицей в пути, - от кириллицы лучше избавиться.
Всё, что осталось без перевода будет по-умолчанию заменено на исходный текст.
Для русского языка по аналогичной схеме создаём файл ru.po. Для начала пересохраним текущий перевод: [Файл/Сохранить как] и укажем имя ru.po. Откроем свойства [Каталог/Свойства...]. В форму множественного языка попадёт более великая и могучая формула:
nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);
Меняем язык на "ru". Остальное без изменений.

Конвертируем файлы перевода для применения в программе

В программе специально подготовленные переводы будет обрабатывать библиотека Jed, поэтому нужно под неё подстроиться. Конвертируем .po в .json. Для этого применим утилиту po2json. Установим её по инструкции через npm. Можно запускать в консоли (переходим в консоли в рабочий каталог):
po2json ru.po ru.json --format=jed --pretty
С параметром --pretty будет получаться красиво форматированный файл (для примера удобно, но для рабочего применения стоит убрать, чтобы сэкономить в размере файла).
Я предварительно перевёл некоторые строки в русском файле и сохранил:
Вот что получается после конвертации в JSON:
{
   "domain": "messages",
   "locale_data": {
      "messages": {
         "": {
            "domain": "messages",
            "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);",
            "lang": "ru"
         },
         "Exercise 1": [
            null,
            "Первое упражнение"
         ],
         "WG pull down": [
            null,
            ""
         ],
         "NG pull down": [
            null,
            ""
         ],
         "Chin up": [
            null,
            ""
         ],
         "Sign in": [
            null,
            "Зарегистрироваться"
         ],
         "There is one translation.": [
            "There are %d translations.",
            "Один перевод",
            "%d перевода",
            "%d переводов"
         ]
      }
   }
}
На момент написания этой статьи формат Jed изменился, а вот выхлоп утилиты po2json остался прежним. Есть обсуждение данной проблемы со стороны po2json, а так же со стороны Jed, но прошло уже несколько месяцев, а в релизе пока остаётся старый формат. Подвижки нектрые были. В частности, появился новый ключ для утилиты po2json
--format=jed1.x
Но что с ключом, что без ключа, итоговый json формируется неправильного формата и требует ручной доработки.
Так что пока поправим готовый файл вручную (в надежде на то, что в будущем всё поправят). Для этого в полученном JSON файле нужно убрать все строки "null,", а также преобразовать форму множественного числа, удалив первый из четырёх вариантов. Должно получиться так:
{
   "domain": "messages",
   "locale_data": {
      "messages": {
         "": {
            "domain": "messages",
            "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);",
            "lang": "ru"
         },
         "Exercise 1": [
            "Первое упражнение"
         ],
         "WG pull down": [
            ""
         ],
         "NG pull down": [
            ""
         ],
         "Chin up": [
            ""
         ],
         "Sign in": [
            "Зарегистрироваться"
         ],
         "There is one translation.": [
            "Один перевод",
            "%d перевода",
            "%d переводов"
         ]
      }
   }
}

Оживляем приложение

Чтобы увидеть переведённые тексты в своём HTML приложении, нужно дописать обработку JavaScript, которая будет подменять все шаблоны на их перевод. Так как я применяю фреймворк, то все примеры будут с учётом подгрузки необходимых ему библиотек.
В таком приложении библиотеки задействуют возможности друг друга, поэтому их нужно загружать в определённом порядке. Ранее я для этого пробовал использовать библиотеку Modernizr, но потом отказался от этого - дико тормозит.
Внутри тега <head> добавим загрузку Jed и файлов стилей Framework7. Перед закрывающим тегом </body> добавим поочерёдную загрузку Framework7 и своего скрипта. Получается следующее:
<!DOCTYPE html>
<html>
  <head>
    <title>My App</title>
    <link rel="stylesheet" href="css/framework7.min.css">
    <script src="js/jed.min.js"></script>
  </head>
  <body>
    <p id="pText">{{_ myText}}</p>
    <button id="btnSign">{{_ "Sign in"}}</button>
    <p id="pPlural">{{ngettext "There is one translation." "There are %d translations." count}}</p>
    <script src="js/framework7.min.js"></script>
    <script src="js/my-app.min.js"></script>
  </body>
</html>
Теперь пришло время написать собственный скрипт обработки для приложения. Сначала откладываем инициализацию приложения Framework7 напотом:
var myApp = new Framework7({
  init: false
});
Объявляем все необходимые переменные, в том числе и язык (в конечном приложении язык надо взять откуда-то из настроек приложения). После этого загружаем нужный языковой файл JSON (можно с помощью jQuery, но в Framework 7 входят аналогичные инструменты - $$.getJSON). Как только загрузка прошла, объявляем переменную i18n как объект Jed, которому передаём полученный файл перевода:
i18n = new Jed(response);
После того, как проинициализирован объект, обслуживающий переводы, формируем хелперы, которые налету обработают все наши шаблоны (типа, {{_ myText}}) и подменят их строками перевода:
Template7.registerHelper('_', function(msgid) {
  return i18n.gettext(msgid);
});
Template7.registerHelper('ngettext', function(msgid, plural, count) {
  return i18n.translate(msgid).ifPlural(count, plural).fetch(count);
});
Эти хелперы работают благодаря встроенному в Framework7 функционалу. Информацию о них можно почерпнуть в официальной документации Template7.
Теперь можно инициализировать приложение и хелперы можно будет применять:
myApp.init();
Начинаем обрабатывать заготовленные заранее шаблоны. В первом шаблоне у нас был не текст, а переменная. Значит, нужно сначала предать ей значение, а потом это значение перевести. Алгоритм обработки такой: сначала сохраняем шаблон в переменную, потом "компилируем" шаблон с помощью хелпера. Получится некий объект, который можно обработать функцией compiledTemplate(), передав в неё контекст, т.е. значение всех используемых переменных. На выходе получаем искомую переведённую на нужный язык строку, которую остаётся только вставить на место шаблона.
var template = $$('#pText').html();
var compiledTemplate = Template7.compile(template);
var context = {
  myText: 'My super text!!!'
};
var html = compiledTemplate(context);
document.getElementById("pText").innerHTML = html;
Со следующей строкой из нашего учебного примера справляемся аналогичным образом. Только на этот раз у нас в шаблоне не переменная, а сам текст. Так что контекста никакого нет.
var btnTemplate = $$('#btnSign').html();
var compiledBtnTemplate = Template7.compile(btnTemplate);
var html2 = compiledBtnTemplate();
document.getElementById("btnSign").innerHTML = html2
Для тестирования разных форм множественного числа в тексте программы можно менять значение параметра count и смотреть на результат в браузере.
var plurTemplate = $$('#pPlural').html();
var compiledPlurTemplate = Template7.compile(plurTemplate);
var context = {
  count: 0
}
var html3 = compiledPlurTemplate(context);
document.getElementById("pPlural").innerHTML = html3;
Смотрим, что у нас получилось. В целом собственный скрипт приложения приобрёл вид:
var myApp = new Framework7({
  init: false
});
var $$ = Dom7;
var lang = 'ru';
var fLang;
var langData;
var i18n;
if (lang === 'ru') {
  fLang = './ru.json';
} else if (lang === 'en') {
  fLang = './en.json';
}
$$.getJSON(fLang, function(response) {
    i18n = new Jed(response);
  Template7.registerHelper('_', function(msgid) {
    return i18n.gettext(msgid);
  });
  
  Template7.registerHelper('ngettext', function(msgid, plural, count) {
    return i18n.translate(msgid).ifPlural(count, plural).fetch(count);
  });
  myApp.init();
  
  var template = $$('#pText').html();
  var compiledTemplate = Template7.compile(template);
  var context = {
    myText: 'My super text!!!'
  };
  var html = compiledTemplate(context);
  document.getElementById("pText").innerHTML = html;
  
  var btnTemplate = $$('#btnSign').html();
  var compiledBtnTemplate = Template7.compile(btnTemplate);
  var html2 = compiledBtnTemplate();
  document.getElementById("btnSign").innerHTML = html2;
 
  var plurTemplate = $$('#pPlural').html();
  var compiledPlurTemplate = Template7.compile(plurTemplate);
  var context = {
    count: 0
  }
  var html3 = compiledPlurTemplate(context);
  document.getElementById("pPlural").innerHTML = html3;
});
Запускаем в браузере test.html и видим, что все шаблоны заменены на переведённый текст! Но один текст остался на английском, т.к. он изначально не попал в наш набор текстов для перевода. Оформим и его как шаблон. В скрипте строку
myText: 'My super text!!!' 
заменим на
myText: i18n.gettext('My super text!!!')
Ещё раз откроем Poedit и файл ru.po. Пополним ключевые слова исходных файлов новым "gettext". Нажмём "Обновить" и увидем, что появилась новая строка для перевода. Добавилась прямо из нашего JavaScript. Переведём её в Poedit и пройдём по всей цепочке операций, чтобы получить новый файл ru.json.
Теперь все простые строковые варианты перевода работают.
В реальном приложении нет необходимости каждую строку "компилировать" отдельно. Можно сделать перевод всех строк текста приложения вызовом одной функции. Например, такой:

function translate(fLang) {
  $$.getJSON(path + fLang, function(response) {
    console.log('Загрузили новый языковой файл! fLang = ' + fLang);
    i18n = new Jed(response);
    console.log('i18n = ' + JSON.stringify(i18n));
    // Переводим все шаблоны текстов в html на нужный язык
    var template = $$('.app-text').each(function() {
      //console.log('Переводим очередную строку');
      var compiledTemplate = Template7.compile($$( this ).text());
      //console.log('compiledTemplate = ' + compiledTemplate);
      var htmlText = compiledTemplate();
      //console.log('New htmlText = ' + htmlText);
      $$( this ).text(htmlText);
    });
  });
} 
Чтобы эта функция сработала. нужно всем HTML тегам, содержащим текст к переводу добавить class app-text. Функция отыщет все такие помеченные строки и в цикле переведёт их на нужный язык, который передаётся в параметре. Стоит отметить, что перевести таким образом строки в работающем приложении можно только один раз. Чтобы увидеть приложение уже с другим языком, потребуется перезапуск приложения (с указанием уже нового целевого языка).

Комментарии

  1. Ответы
    1. Спасибо на добром слове! Надеюсь, статья кому-то поможет в нелёгком деле переводов программ на другие языки.

      Удалить

Отправить комментарий

Популярные сообщения из этого блога

Пропорциональное распределение суммы

Битрикс: своя геолокация

Bitrix24 API - разбор демо приложения третьего типа