Revisited i18n with CMake

With Qt 6.2, we introduced a new CMake API to handle internationalization (i18n) of Qt-based projects: qt_add_translations, qt_add_lupdate and qt_add_lrelease. These functions have shortcomings that we address in the upcoming Qt 6.7 release.

Update: In the Qt 6.7 API review we've renamed some parts of the i18n CMake API. This post has been updated accordingly.

What's the problem?

The main entry point to i18n with CMake is qt_add_translations. The function takes a CMake target as the first parameter. Source files from that target are then passed to lupdate, producing a .ts file.

Now, projects usually have more than one target. We didn't have a good way to pass multiple targets to qt_add_translations or qt_add_lupdate so far. Even if you created separate .ts files per target, there is no convenient way to merge the resulting .qm files. The lconvert tool can do that, but you'll have to do the setup on CMake level.

Then, there might be sources in a target that you don't want to pass to lupdate. You want to mark sources as "I don't want this to contribute to my project's .ts files." Our i18n functions didn't offer a way to exclude sources.

The only reliable workaround for these shortcomings was to explicitly pass the list of source files to qt_add_translations / qt_add_lupdate.

Further, on iOS, projects need to announce what languages they support. We had code that extracted the languages from the .ts files to write it into the Info.plist file. That works, but it's a bit fragile at the edges.

The revisited CMake i18n commands

The target-based view of i18n is way too fine-grained. We need a project-level view that allows us to collect translatable strings in the sources of the whole project. And there needs to be a way to exclude parts of the source tree conveniently.

QMake has a project-wide view with its TRANSLATIONS variable, and TR_EXCLUDE is used to exclude sources. Projects that use gettext usually operate directly on the source tree. With Qt 6.7, we'll offer a project-wide view for qt_add_translations too. We've kept compatibility with the "one target API" to avoid breaking existing projects.

Let's consider a medium-size project - a clone of the game classic frogger. The project consists of several parts:

  • frogger, the main executable target
  • game_logic, a library target with the meat of the game logic
  • jump_sim, a third-party public domain library to realistically simulate the frog jumps
  • a bunch of tests

The top-level project file looks like this:

cmake_minimum_required(VERSION 3.28)
project(frogger)
find_package(Qt6 COMPONENTS OpenGLWidgets)
qt_standard_project_setup()

add_subdirectory(3rdparty) # adds target jump_sim

qt_add_library(game_logic src/game_logic/stuff.cpp ...)
target_link_libraries(game_logic PRIVATE jump_sim)

qt_add_executable(frogger src/frogger/main.cpp ...)
target_link_libraries(frogger PRIVATE game_logic)

add_subdirectory(tests) # adds several targets

Our project has Norwegian and German translations, so we adjust the setup call as follows:

qt_standard_project_setup(I18N_TRANSLATED_LANGUAGES nb de)

Somewhere after the creation of the frogger target, we call qt_add_translations.

qt_add_translations(frogger)

And that's it! This will

  • collect all source files from all targets of the project
  • create frogger_nb.ts and frogger_de.ts from the project name and the list of languages we passed in the qt_standard_project_setup call
  • create frogger_en.ts that contains only plural forms. See the explanation of plural forms below.
  • create an update_translations target to extract the translatable strings from the collected source files
  • create a release_translations target to create the .qm files from the .ts files
  • create a Qt resource that contains the .qm files and embed it into the frogger target
  • when building for iOS, the app will contain the information that frogger supports the languages English, Norwegian and German

The example above shows the most straightforward use of qt_add_translations. Customizing various aspects of the automatisms or explicitly specifying targets, sources or .ts files is possible. To show how to do that is out of the scope of this post. Please take a look at the documentation if you're interested in more details.

Excluding targets and directories

As it happens, the jump_sim target and the tests of our project contain translatable strings that we don't need in frogger's .qm files.

To exclude the jump_sim target from translation, we set the target property QT_EXCLUDE_FROM_TRANSLATION to ON:

set_property(TARGET jump_sim PROPERTY QT_EXCLUDE_FROM_TRANSLATION ON)

For the tests we want to exclude every target below the tests directory. To do that, we set the directory property QT_EXCLUDE_FROM_TRANSLATIONS in tests/CMakeLists.txt to ON.

# tests/CMakeLists.txt
set_directory_properties(PROPERTIES QT_EXCLUDE_FROM_TRANSLATION ON)

With this setup, we don't pass source files from jump_sim or the tests.

Specifying source targets explicitly

For this simple project, it would be actually easier just to say that the translatable source files are in the two targets, frogger and game_logic. Instead of excluding parts of the project, we could call qt_add_translations like this:

qt_add_translations(frogger
SOURCE_TARGETS frogger game_logic
)

Remember to adjust the SOURCE_TARGETS list if you extend your project with more targets containing translatable strings! For more complex projects, automatically collecting targets and excluding unwanted parts would be advisable.

Handling plural forms in the source language

In the main game screen we display how many frogs already reached home. It's a string like this:

int numberOfFrogsAtHome = countFrogsAtHome();
tr("%n frog(s) are home", "", numberOfFrogsAtHome);

This translatable string is a plural form, and our Norwegian and German translations will display different strings depending on the number of frogs (e.g. "1 Frosch" vs. "2 Frösche" in German). It would also be nice to display "1 frog" and "2 frogs" in English. To do that, we need to create a translation from "source code English" to "human English" that only contains the plural forms.

The language our untranslated source strings are written in is called the source language of the project. Another commonly used term is development language. And we denote that fact in qt_standard_project_setup like so:

qt_standard_project_setup(
I18N_SOURCE_LANGUAGE en
I18N_TRANSLATED_LANGUAGES nb de
)

The qt_add_translations command will automatically create a frogger_en.ts file that only contains plural form strings. We fire up Qt Linguist to "translate" the handful of plural forms, and now frogger's game progress display can show "1 frog is home" and "2 frogs are home".

If your source language is English, you don't have to specify I18N_SOURCE_LANGUAGE, because that's the default. You can prevent the creation of a plurals-only file by passing NO_GENERATE_PLURALS_TS_FILE to qt_add_translations.

Conclusion

The full example listing looks like this:

cmake_minimum_required(VERSION 3.28)
project(frogger)
find_package(Qt6 COMPONENTS OpenGLWidgets)
qt_standard_project_setup(
  I18N_TRANSLATED_LANGUAGES nb de
)

add_subdirectory(3rdparty) # adds target jump_sim
set_property(TARGET jump_sim PROPERTY QT_EXCLUDE_FROM_TRANSLATION ON)

qt_add_library(game_logic src/game_logic/stuff.cpp ...)
target_link_libraries(game_logic PRIVATE jump_sim)

qt_add_executable(frogger src/frogger/main.cpp ...)
target_link_libraries(frogger PRIVATE game_logic)

# in tests/CMakeLists.txt we have
# set_directory_properties(PROPERTIES QT_EXCLUDE_FROM_TRANSLATION ON)
add_subdirectory(tests) # adds several targets

qt_add_translations()

We've discussed the shortcomings of the i18n CMake API we introduced in Qt 6.2 and how we addressed this with the revisited i18n CMake API in Qt 6.7. The key takeaways are

  • We now have a project-wide view of translations instead of a single-target view.
  • It's possible (and advisable) to specify the supported languages on the project level.
  • qt_add_translations can automatically collect targets from which source files are used as input for lupdate.
  • Targets and directories can be excluded from this automatic collection process.
  • If no automatic target collection is desired, targets can be explicitly passed.
  • qt_add_translations can automatically generate the names of .ts files. This can also be turned off.
  • There's now direct support for creating a plural-form .ts file for the native language of the project.

Please play with this new API. We're happy to receive your feedback.

 


Blog Topics:

Comments