Введение
О переводе начинаешь думать тогда, когда приложение уже написано и работает. Архитектура сложилась органически, строки разбросаны по хелперам и метаданным перечислений, или торчат где-то в теле функции. Когда код пишется, особо не задумываешься о том, что всё это однажды придётся переводить.
Добавить поддержку i18n в Lazarus — дело пяти минут. Проблемы начинаются потом: DefaultTranslator не подхватывает файлы, компилятор молча принимает resourcestring в const-массиве и ничего не переводит, fuzzy-флаг тихо блокирует строку без единого предупреждения.
Эта статья — про те вещи, которые не являются очевидными и, к сожалению, не описаны в базовых руководствах.
Почему DefaultTranslator не cрабатывает
Когда я приступил к задаче перевода, я нашел и стал следовать подробному руководству по локализации в Lazarus — там хорошо описаны механизмы работы с .po-файлами, приводятся инструменты и структура проекта. Рекомендую эту статью в качестве базового материала, с небольшой поправкой на текущее состояние инструментов в свежей версии IDE.
Правильнее было бы сначала вынести все строки в resourcestring, и только потом включать поддержку i18n — но на практике обычно делаешь наоборот, сначала хочется быстро проверить, что механизм вообще работает.
Включается механизм через настройку расположенную в диалоговом окне Проект → Параметры проекта → i18n. Где необходимо включить поддержку i18n и указать каталог для выходных файлов. После сборки проекта Lazarus IDE создаст .pot-файл — он и будет являться исходной точкой для всех переводов. На начальном этапе в него попадают только строки с элементов UI: подписи кнопок, заголовки окон, тексты меток.
.pot-файл — это только шаблон, приложение его не читает. Для каждого языка нужно сделать копию и переименовать по схеме <AppName>.<lang>.po, например prog.en.po. Этот файл уже можно открывать в текстовом редакторе или в Poedit и выполнять перевод.
После перевода .po можно скомпилировать в бинарный .mo — Poedit делает это автоматически при сохранении. Однако это необязательный шаг: Lazarus умеет читать .po напрямую. Порядок поиска файлов локализации при запуске приложения такой:
Ищется
.mo— если найден, используется он.Если
.moнет — загружается.po.Если нет ни того ни другого — строки берутся из
resourcestringкак есть.
На практике для разработки удобно держать только .po и не компилировать .mo после каждого изменения. Для финальной сборки и дистрибуции лучше включить .mo — он загружается быстрее и не требует парсинга текстового формата.
После запуска приложения с ключом --lang en должны использоваться значения из английской локализации.
Однако сформированный по примеру из руководства код с использованием DefaultTranslator у меня не заработал. Причина оказалась банальной: DefaultTranslator ищет файлы локализации только в каталогах languages/ и locale/, но не в langs/. А у меня папка называлась именно так (причём я последовал примеру автора руководства, который написал что обычно называет папку langs). Можно переименовать папку, дав ей имена которые обрабатываются автоматически, или заменить DefaultTranslator на явный вызов SetDefaultLang с указанием нужного каталога.
Пример указания имени каталога отличного от стандартных:
uses ..., LCLTranslator; begin SetDefaultLang('', 'langs'); Application.Initialize; Application.CreateForm(TForm1, Form1); Application.Run; end.
После этого всё заработало, и я приступил к экспериментированию.
Что попадает в .pot, а что нет
С кнопками, метками и полями ввода проблем нет — LCLTranslator автоматически обходит компоненты формы из .lfm и извлекает для перевода свойства Caption, Text и Hint. Однако заголовки колонок TStringGrid в .pot не оказываются.
Причина в том, что эти заголовки хранятся в TGridColumn.Title.Caption. Это вложенное свойство внутри коллекции, и парсер .lfm → .pot его просто не индексирует. В итоге инициализацию таблиц пришлось перенести в код и использовать resourcestring.
Обратная проблема — в .pot попадают строки, которые переводить не нужно. Например, тексты служебных панелей, скрытых элементов для отладки или вспомогательной информации. Чтобы избежать лишнего “мусора”, такие элементы следует добавить в список игнорируемых в настройках проекта. Достаточно указать идентификаторы объектов, которые не должны участвовать в извлечении строк для перевода в Проект → Параметры проекта → i18n, в поле Исключённые → Идентификаторы.
Ловушки рабочего процесса
Когда обновляется .pot файл
Я сперва никак не мог понять как это работает, вношу изменения в resourcestring, сохраняю файл и потом весь проект — а .pot остался прежним. Потом выяснилось, что обновление происходит не при сохранении исходника, а только при сборке проекта. При этом Lazarus просто заново сканирует все подключённые модули и перегенерирует файл целиком. Поэтому если переименовал строку, добавил новую или удалил старую — изменения появятся в .pot только после следующей сборки.
Отсюда вытекает практическое следствие: не стоит редактировать .po параллельно с активной работой над кодом — есть риск, что после очередной сборки .pot обновится, и ручные правки в .po разойдутся с актуальным состоянием шаблона.
Переименовал строку в resourcestring — перевод пропал
По ходу перевода иногда хочется переименовать какую то строку в resourcestring например было имя rsshnamebtt, а потом его изменили на rsshnamebottom. После сборки проекта, переименованные строки перестали переводиться.
В po-файле появилась следующая запись:
#: enumdemo.rsnamebottom #, fuzzy msgctxt "enumdemo.rsnamebottom" msgid "Нижний блок" msgstr "Bottom block"
Флаг fuzzy — означает, что «перевод неточный / требует проверки». Пока стоит fuzzy, перевод обычно не используется, поэтому строка может отображаться в оригинале. Соответственно, необходимо проверить и исправить все эти данные, в Poedit есть режим просмотра fuzzy-записей, либо можно вручную удалить строку #, fuzzy из .po-файла.
Модуль есть в uses, но строки не попадают в .pot
Ещё один нюанс: если модуль добавлен в раздел Uses, проект успешно собирается, но данные по resourcestring из этого модуля не попадают в *.pot, пока файл явно не добавлен в список файлов проекта через меню Проект → Инспектор проекта.
Паттерны неудобные для перевода
Я часто использую хелперы для перечислений, для меня это удобный паттерн, его я описывал в статье Lazarus IDE для аналитика. Приемы работы в современном Free Pascal — 2. Идея проста: везде работаешь с типизированным enum, а когда нужны человекочитаемые данные — получаешь их через хелпер. Я делал так, что метаданные хранятся в массивах строк или record’ов прямо внутри хелпера в константе.
const BlockMeta: array[ESectionType] of record FullName, ShortName: string; end = ( (FullName: 'Главный блок'; ShortName: 'Гл.'), (FullName: 'Верхний блок'; ShortName: 'Верх.'), ... );
Проблема здесь понятна: const-массивы и record-константы инициализируются на этапе компиляции, до любого кода resourcestring. Поместить resourcestring прямо в const array не получится, необходимо вынести хранилище метаданных в область имплементации за resourcestring.
Кроме того, нельзя просто разместить resourcestring прям в массив. В Lazarus эта ситуация не очевидна, компилятор молча принимает resourcestring в const array, Не выводится никаких предупреждений, однако перевод в этом случае не работает, так как копируются данные, которые там были до инициации, и они останутся в массиве такими, какими были.
Необходимо поместить в массив именно ссылки на эти строки, и тогда хелпер будет обращаться к переведенным на указанный язык строкам.
Пример для хелпера
unit EnumDemo; {$mode ObjFPC}{$H+} {$modeswitch typehelpers} interface uses Classes, SysUtils, TypInfo, fpjson, jsonparser; type TStringPtr = ^AnsiString; type EDemoType = (stMain = 0, stTop = 1, stBottom = 2, stSide = 3); TDemoTypeHelper = type helper for EDemoType private function GetData(Index: Integer): string; public property ToString: string index 0 read GetData; property Name: string index 1 read GetData; property ShortName: string index 2 read GetData; end; implementation resourcestring rsNameMain = 'Главный блок'; rsShNameMain = 'Гл.'; rsNameTop = 'Верхний блок'; rsShNameTop = 'Верх.'; rsNameBottom = 'Нижний блок'; rsShNameBottom = 'Нижн.'; rsNameSide = 'Боковой блок'; rsShNameSide = 'Бок.'; var EDemoRecords: array[EDemoType, 1..2] of ^AnsiString; { TDemoTypeHelper } function TDemoTypeHelper.GetData(Index: Integer): string; begin if Index <> 0 then Result := EDemoRecords[Self, Index]^ else Result :=GetEnumName(TypeInfo(EDemoType),ORD(Self)); end; procedure SetDemoRecord(AType: EDemoType; const AName, AShort: TStringPtr); begin EDemoRecords[AType, 1] := AName; EDemoRecords[AType, 2] := AShort; end; initialization SetDemoRecord(stMain, @rsNameMain, @rsShNameMain); SetDemoRecord(stTop, @rsNameTop, @rsShNameTop); SetDemoRecord(stBottom, @rsNameBottom , @rsShNameBottom ); SetDemoRecord(stSide, @rsNameSide, @rsShNameSide); finalization end.
пример .po файла
msgid "" msgstr "Content-Type: text/plain; charset=UTF-8" #: enumdemo.rsnamebottom msgid "Нижний блок" msgstr "Bottom block" #: enumdemo.rsnamemain msgid "Главный блок" msgstr "Main block" #: enumdemo.rsnameside msgid "Боковой блок" msgstr "Side block" #: enumdemo.rsnametop msgid "Верхний блок" msgstr "Top block" #: enumdemo.rsshnamebottom msgid "Нижн." msgstr "Bottom" #: enumdemo.rsshnamemain msgid "Гл." msgstr "Main" #: enumdemo.rsshnameside msgid "Бок." msgstr "Side" #: enumdemo.rsshnametop msgid "Верх." msgstr "Top" #: tform1.button1.caption msgid "Кнопка" msgstr "Button" #: tform1.caption msgid "Заголовок" msgstr "Caption"
Множественные формы (Plural forms)
При переводе с русского языка, с точки зрения локализации есть один неудобный момент: у существительных три формы в зависимости от числа: 1 пакет, 2 пакета, 5 пакетов. В английском всё проще — форм две, и правило тривиальное: если Count = 1, то packet, иначе packets. При переводе на английский создаётся ощущение, что проблема исчезает. На практике она лишь меняет направление: при добавлении языков вроде польского или чешского правила становятся ещё сложнее.
На Хабре есть статья DxGetText — GNU Gettext for Delphi and C++ Builder от 2015 года, где затрагивается множественных форм и приводится работа с ними через формулы Plural forms. К сожалению, штатные инструменты Lazarus IDE эту задачу не решают, там отсутствует ngettext и Plural-Forms не поддерживаются напрямую в TPOFile.Translate. Пропытка использовать “в лоб” модуль gnugettext также не увенчалась успехом, так как появлялись множественные ошибки компиляции.
С модулем gettext, входящим в IDE, получить данные из заголовков .po-файла (включая Plural-Forms) напрямую невозможно: большинство необходимых функций объявлены как внутренние и не экспортируются. Кроме того, в Lazarus отсутствует встроенный механизм выбора формы по числу во время выполнения, поэтому соответствующую логику всё равно приходится реализовывать самостоятельно.
После длительных экспериментов и попыток использовать различные подходы (включая подсказки нейросетей) я остановился на трёх возможных стратегиях обхода этой проблемы.
Переформулировать строку. Самый простой и надёжный способ — убрать склоняемое слово из строки вообще. Вместо «Найдено 5 пакетов» написать «Пакеты: 5» или «Результатов: 5». Перевод становится тривиальным, и проблема множественного числа исчезает. Минус — не всегда возможно с точки зрения UX, и иногда выглядит “казённо”.
Явная функция с таблицей форм. Если переформулировать не получается — пишем функцию, которая выбирает нужную форму вручную:
Пример функции обработчика для Plural-Forms
resourcestring rsPkgOne = 'пакет'; rsPkgFew = 'пакета'; rsPkgMany = 'пакетов'; function PluralRu(Count: Integer; const One, Few, Many: string): string; var N, M: Integer; begin N := Abs(Count) mod 100; M := N mod 10; if (N >= 11) and (N <= 19) then Result := Many else if M = 1 then Result := One else if (M >= 2) and (M <= 4) then Result := Few else Result := Many; end; // Использование: Label1.Caption := Format('%d %s', [Count, PluralRu(Count, rsPkgOne, rsPkgFew, rsPkgMany)]);
Формы передаются через resourcestring — значит, они попадут в .po и будут переведены вместе с остальными строками, но перевод для каждой формы будут свой.
Вынести логику в отдельный модуль. Если подобных мест в проекте много, функции вроде PluralRu имеет смысл оформить в отдельный модуль, например Pluralize. В него можно добавить диспетчер, учитывающий текущий язык приложения, чтобы вызывающий код передавал только число и набор форм, не зная о конкретных правилах склонения. Такой подход делает решение более универсальным и переиспользуемым, однако требует дополнительных усилий на проектирование и поддержку, поэтому его целесообразность зависит от масштаба проекта.
В своём проекте я выбрал первый вариант — переформулировал спорные строки. Таких мест оказалось немного, и это было быстрее, чем строить универсальный механизм.
Числа, даты, разделители
Это отдельная боль при локализации, и здесь важно понимать две независимые задачи: перевод строк (что делает resourcestring) и форматирование данных (что делает локаль системы или указанные явные настройки).
Системная локаль и DefaultFormatSettings
FPC при старте программы инициализирует глобальную переменную DefaultFormatSettings на основе системной локали. Именно оттуда берутся DecimalSeparator, ThousandSeparator, DateSeparator, ShortDateFormat, LongDateFormat и другие параметры.
Типичный сценарий, с которым я столкнулся: приложение написано на машине с русской локалью, где десятичный разделитель — запятая. Пользователь запускает на системе с английской локалью — и StrToFloat('1,5') падает с исключением, потому что там ожидается точка.
В итоге пришлось искать все преобразования StrToFloat/FloatToStr без явного указания FormatSettings и исправлять их.
В целом удобно договориться с самим собой о двух форматах:
Внутренний / файловый — всегда нейтральный (точка как разделитель, ISO-дата), явный
FormatSettings.Отображаемый пользователю — системная локаль или выбранная в приложении.
Итог
Перевод приложения оказался трудоёмким в первую очередь потому, что исходный код изначально не проектировался с учётом локализации. Если бы подготовка к переводу была заложена с самого начала, большинства описанных проблем удалось бы избежать.
В итоге основное время ушло не на сам перевод строк, а на приведение кода к состоянию, пригодному для локализации: вынос текстов из констант, инициализаторов массивов, заголовков колонок. По сути это небольшой рефакторинг, который заодно сделал код чище и более структурированным.
Перед началом новых проектов я теперь стараюсь заглядывать в исходники самой Lazarus IDE, чтобы понять, как там и что организовано и использовать этот опыт. Например, в IDE все переводимые строки вынесены в отдельный файл lazarusidestrconsts.pas — это несколько тысяч строк resourcestring, на которые ссылаются остальные модули. Аналогичный подход используется и в официальных примерах локализации (lazarus/examples/translation/), где строки собраны в отдельном модуле StringsUnit.pas.
Послесловие
Вопрос, который я задаю себе каждый раз перед тем как собираюсь что-то написать для публикации: зачем писать статью, если на любой технический вопрос можно получить ответ от нейросети за тридцать секунд? И вроде для себя я нашёл несколько причин. Статья — это фиксация собственного опыта: я сам периодически возвращаюсь к своим материалам, когда забываю детали реализации, и нахожу подробную информацию намного быстрее, и что самое главное она уже проверена мной. Процесс написания текста статьи заставляет структурировать мысли — даже если пишешь с помощью той же нейросети, ты всё равно рисуешь структуру статьи и делаешь постановку задачи на написание какого-то абзаца, осмысливаешь и проверяешь каждый тезис. Иногда дополнительно разбираешь какой-то определенный нюанс, для того чтобы более подробно раскрыть тему. Ну и, пожалуй, самое ценное что ты получаешь — это обратную связь после публикации в виде комментариев экспертов. Когда тебе пишут «а вот здесь ты не так смотришь» или «есть более простой способ», получается новый взгляд на материал, который до этого момента был не так очевиден.
