Настройка ID-based локализации в проектах на CMake

Как вы, возможно, знаете, есть несколько способов локализации приложений. Но одним из самых удобных, на мой взгляд, является id-based локализация, когда ключи локализации задаются в виде идентификаторов. В QML за этот вариант отвечает функция qsTrId. Но детальное погружение в тонкости локализации приложений я оставлю для другого раза, а сейчас рассмотрю неожиданную проблему: использование id-based локализации в проектах на Cmake.

Но для наачла давайте разберёмся, чем id-based локализация лучше стандартной. Пример ts-файла с обычной локализацией:

<context>
    <name>MainPage</name>
    <message>
        <location filename="../qml/pages/MainPage.qml" line="48"/>
        <source>Template</source>
        <translation>Шаблон</translation>
    </message>
</context>

Пример ts-файла с id-based локализацией:

<context>
    <name></name>
    <message id="main.title">
        <source></source>
        <translation>ID-based локализация</translation>
    </message>
</context>

На первый взгляд кажется, что разница не такая уж большая: нет указания qml-файла и номера строки, а также не задан параметр source, который используется для поиска и подстановки перевода. И хоть в целом схема с поиском и подстановкой рабочая, она может приводить к коллизиям, если в одной строке используется несколько токенов локализации. Кроме того, в современной разработке мобильных приложений считается плохим тоном использовать строковые константы в коде. В случае длинного текста приходится придумывать какие-то сокращения, как это сделано в шаблонном приложении: там для полей описания и лицензионного соглашения используются метки “#description” и “#license”. Такие решения вносят дополнительную путаницу и заметно осложняют работу переводчиков, если локализацией занимается не сам разработчик, а специально обученные люди.

Суть проблемы

Итак, с мотивационной частью разобрались, теперь давайте погрузимся в суть проблемы. При использовании CMake на данный момент схема работы такая:

  1. В QML-файле заменяем все локализируемые тексты на вызов функций qsTr или qsTrId;
  2. Вызываем cmake для перегенерации сборочных файлов, т.к. все дополнительные активности вроде подготовки ts-файлов описаны в нём. Если пользуетесь Aurora IDE, для этого есть пункт меню Build -> Run CMake.
  3. Вручную открываем ts-файлы в редакторе и правим переводы (заставить нормально работать Qt Linguist у меня не получилось).

И вот тут на втором шаге будет проблема: в Qt 5.x нет поддержки id-based локализаций для CMake, она появилась только в Qt 6. Давайте посмотрим на файл CMakeLists.txt, который создаётся для нового проекта. В нём нам интересны пока только несколько строчек:

# Подключаем модуль поддержки локализации
pkg_search_module(AURORA auroraapp_i18n REQUIRED)

# Генерируем Qm-файлы из Ts-файлов
file(GLOB TsFiles "translations/*.ts")
qt5_add_translation(QmFiles ${TsFiles})

Как видите, тут многое спрятано под капот модуля локализации и работает как бы само. Но есть нюанс, что таким образом обнаруживаются только локализуемые строчки в старом формате, где сам ключ локализации уже является переводом.

Анатомия id-based локализации

Для начала давайте разберёмся как работает id-based локализация. В целом процесс генерации файлов локализации можно разделить на три этапа:

  1. Сбор записей о локализуемых строках и созданий ts-файлов.
  2. Редактирование ts-файлов – переводчик прописывает в них значения строк на требуемом языке.
  3. Генерация двоичных qm-файлов, которые будут поставляться вместе с бинарником приложения внутри RPM-пакета.

Для первого и третьего этапов используются две утилиты из библиотеки Qt: lupdate и lrelease. Второй этап можно выполнять в специальной утилите QtLinguist или просто в любом текстовом редакторе.

Утилита lupdate используется для сбора данных о локализуемых строках. Она пробегается по всем переданным в аргументах файлам и ищет там локализцемые строки, чтобы записать их в ts-файлы. Примечательно, что она не делает разделения между типами локализации и ищет все известные ей макросы и функции.

Утилита lrelease генерирует qm-файлы локализации, это двоичные файлы, которые поставляются вместе с приложением и ускоряют подстановку текстовых значений в требуемые места. Она имеет специальный ключ -idbased, который указывает ей какой используется тип локализации. Именно поэтому нельзя смешивать два варианта в одном проекте – работать будет только один, в зависимости от аргументов запуска утилиты.

Так в чём же тогда проблема, если все необходимые нам возможности в имеющихся утилитах есть? А проблема, внезапно, в расширениях фреймворка Qt 5 для CMake. Дело в том, что используемая для генерации qm-файлов функция qt5_add_translation(...) не умеет просить lrelease использовать ключ -idbased и просто не видит в ts-файлах тегов вида <message id="...">. В итоге генерируется пустой qm-файл. При этом, официальная документация говорит, что в эту функцию можно передать аргументы запуска lrelease, но в той версии, которая используется в Aurora SDK, это приводит к ошибке CMake:

ninja: error: '../../-idbased', needed by '-idbased.qm', missing and no known rule to make it

Расширяем возможности CMake

Хорошо, суть и причина проблемы понятна, а делать-то что? Отказываться от использования идентификаторов очень не хочется, так что выход только один – написать свою функцию генерации файлов локализации для CMake! Ведь богатые возможности для расширения CMake – это то, за что любят и ненавидят эту утилиту.

Итак, чтобы не разводить бардак в директории проекта, создадим в нём поддиректорию cmake и положим в неё файл QtTranslationWithID.cmake со следующим содержимым:

macro(ADD_TRANSLATION _qm_files)
    foreach (_current_FILE ${ARGN})
        get_filename_component(_abs_FILE ${_current_FILE} ABSOLUTE)
        get_filename_component(qm ${_abs_FILE} NAME_WE)
        get_source_file_property(output_location ${_abs_FILE} OUTPUT_LOCATION)

        if(output_location)
            file(MAKE_DIRECTORY "${output_location}")
            set(qm "${output_location}/${qm}.qm")
        else()
            set(qm "${CMAKE_CURRENT_BINARY_DIR}/${qm}.qm")
        endif()

        add_custom_command(OUTPUT ${qm}
            COMMAND ${Qt5_LRELEASE_EXECUTABLE}
            ARGS -idbased ${_abs_FILE} -qm ${qm}
            DEPENDS ${_abs_FILE} VERBATIM
            )
        list(APPEND ${_qm_files} ${qm})
    endforeach ()

    set(${_qm_files} ${${_qm_files}} PARENT_SCOPE)
endmacro()

function(CREATE_TRANSLATION _qm_files)
    set(options)
    set(oneValueArgs)
    set(multiValueArgs OPTIONS)

    cmake_parse_arguments(_LUPDATE "${options}" "${oneValueArgs}" "${multiValueArgs}" ${ARGN})
    set(_lupdate_files ${_LUPDATE_UNPARSED_ARGUMENTS})
    set(_lupdate_options ${_LUPDATE_OPTIONS})

    set(_my_sources)
    set(_my_tsfiles)

    foreach(_file ${_lupdate_files})
        get_filename_component(_ext ${_file} EXT)
        get_filename_component(_abs_FILE ${_file} ABSOLUTE)
        if(_ext MATCHES "ts")
            list(APPEND _my_tsfiles ${_abs_FILE})
        else()
            list(APPEND _my_sources ${_abs_FILE})
        endif()
    endforeach()

    foreach(_ts_file ${_my_tsfiles})
        if(_my_sources)
            # make a list file to call lupdate on, so we don't make our commands too long for some systems
            get_filename_component(_ts_name ${_ts_file} NAME_WE)
            set(_ts_lst_file "${CMAKE_CURRENT_BINARY_DIR}${CMAKE_FILES_DIRECTORY}/${_ts_name}_lst_file")
            set(_lst_file_srcs)
            foreach(_lst_file_src ${_my_sources})
                set(_lst_file_srcs "${_lst_file_src}\n${_lst_file_srcs}")
            endforeach()

            get_directory_property(_inc_DIRS INCLUDE_DIRECTORIES)
            foreach(_pro_include ${_inc_DIRS})
                get_filename_component(_abs_include "${_pro_include}" ABSOLUTE)
                set(_lst_file_srcs "-I${_pro_include}\n${_lst_file_srcs}")
            endforeach()

            file(WRITE ${_ts_lst_file} "${_lst_file_srcs}")
        endif()

        add_custom_command(OUTPUT ${_ts_file}
            COMMAND ${Qt5_LUPDATE_EXECUTABLE}
            ARGS ${_lupdate_options} "@${_ts_lst_file}" -ts ${_ts_file}
            DEPENDS ${_my_sources} ${_ts_lst_file} VERBATIM)
    endforeach()

    add_translation(${_qm_files} ${_my_tsfiles})
    set(${_qm_files} ${${_qm_files}} PARENT_SCOPE)
endfunction()

Не буду сильно вдаваться в подробности, т.к. у меня пока нет цели писать туториал по языку CMake. Кратко опишу основную суть происходящего в этом скрипте. Расширение состоит из макроса add_translation и функции create_translation. Использовать мы будем функцию, а макрос сделан больше для удобства чтения кода и хоть какого-то разделения ответственностей. Функция create_translation помимо выходной переменной принимает в качестве параметров два списка:

  1. перечень ts-файлов, чтобы понимать какие генерировать qm-файлы;
  2. перечень файлов-исходников, в которых искать локализуемые строки.

Внутри себя функция вызывает сначала lupdate, а затем lrelease и в переданную первым параметром переменную возвращает список подготовленных qm-файлов.

Если хотите более детально поразбираться с работой этого расширения или у вас есть предложения по его улучшению (я пока не самый большой эксперт в CMake), не стесняйтесь написать мне любым удобным способом – мои контакты для связи есть внизу каждой страницы этого сайта.

Дорабатываем CMakeLists.txt

Итак, теперь CMake умеет генерировать правильные файлы локализации, осталось только поправить наш файл проекта (CMakeLists.txt). Учтите, что при сохранении файла Aurora IDE попытается пересобрать проект, так что рекомендую сначала сделать все необходимые изменения.

Первым делом нам нужно добавить пакет поддержки id-based локализации и подключить наше расширение для CMake:

pkg_search_module(AURORA auroraapp_i18n_idbased REQUIRED)

include("cmake/QtTranslationWithID.cmake")

Теперь нужно подготовить необходимые переменные и вызвать нашу функцию create_translations:

file(GLOB_RECURSE QmlFiles "qml/*.qml")
file(GLOB TsFiles "translations/*.ts")

# Вместо стандартной функции qt5_add_translation вызываем наше расширение:
create_translation(QmFiles ${TsFiles} ${SOURCES} ${QmlFiles})

Обратите внимание на два важных момента:

  1. в аргументах используется переменная SOURCES, так что важно разместить этот код после её определения;
  2. списки TsFiles и QmlFiles создаются в стандартном файле проекта, но разбросаны по всему файлу – нужно собрать их в одном месте.

Вот теперь можно сохранить файл CMakeLists.txt и убедиться, что cmake не возвращает никаких ошибок. Самый простой путь убедиться в результате – это, конечно же, запустить приложение на устройстве или в эмуляторе. Но также можно заглянуть в сборочную директорию проекта – там должны быть qm-файлы весом в несколько килобайт. Если что-то пошло не так и локализации не подхватились, эти файлы будут весить около 100 байт.

Заключение

В этом туториале я не стал детально останавливаться на процессе самой локализации и работе с ts-файлами. Мне кажется, это довольно тривиальная задача и с форматом ts-файлов нетрудно разобраться самостоятельно.

Для демонстрации описанного в статье подхода я сделал небольшое приложение, код которого доступен на GitVerse.