MeshLib uses a gettext-compatible localization system backed by Boost.Locale. Translatable strings are marked in C++ source code, extracted to template files, translated per language, compiled to binary catalogs, and loaded at runtime.
This guide explains how to integrate and use the i18n system in your own MeshLib-based project.
File formats and directory layout
The gettext toolchain uses three file types:
- .pot - Master list of all extractable strings for a domain. Generated by xgettext.
- .po - Per-language translation file. Each msgid has a corresponding msgstr filled in by a translator.
- .mo - Compiled from .po files by msgfmt. Loaded at runtime by Boost.Locale.
Source translations live under a locale/ directory at the project root:
locale/
MyPlugin.pot <- template (all extractable strings)
MRRibbonMyMenu.pot <- template for ribbon JSON strings
de/
MyPlugin.po <- German translations
MRRibbonMyMenu.po
fr/
MyPlugin.po <- French translations
MRRibbonMyMenu.po
At build time, .po files are compiled to .mo and placed in the output directory:
<build>/locale/<lang>/LC_MESSAGES/<Domain>.mo
For example: build/Release/bin/locale/de/LC_MESSAGES/MyPlugin.mo.
- Note
- The domain name is always derived from the filename stem: MyPlugin.po belongs to domain "MyPlugin".
Gettext utilities and helper scripts
Prerequisites
- gettext tools: xgettext, msgmerge, msgfmt. Install via your OS package manager or from vcpkg (vcpkg install gettext[tools]). Alternatively, set the GETTEXT_ROOT environment variable to a standalone gettext install path (e.g. gettext-tools-windows).
- Python 3 for the helper scripts in scripts/gettext/.
Helper scripts
All scripts are in scripts/gettext/:
- update_translations.py - Runs xgettext over C++ sources to produce or update a .pot template, then runs msgmerge to bring existing .po files in sync.
- update_json_translations.py - Extracts translatable fields from .items.json and .ui.json ribbon menu files into a .pot, then merges existing .po files.
- compile_translations.py - Compiles every .po file under locale/ to .mo using msgfmt.
Extracting C++ strings
python3 scripts/gettext/update_translations.py \
locale/MyPlugin.pot \
source/MyPlugin/
This scans all .cpp, .h, and .hpp files and extracts strings marked by the recognized macros.
Extracting JSON strings
python3 scripts/gettext/update_json_translations.py \
locale/MRRibbonMyMenu.pot \
source/MyPlugin/MRRibbonMyMenu.items.json
Compiling translations
python3 scripts/gettext/compile_translations.py \
locale/ \
build/Release/bin/locale/
This produces build/Release/bin/locale/<lang>/LC_MESSAGES/<Domain>.mo for every .po found.
CMake integration
cmake/Modules/I18nHelpers.cmake provides the mr_add_translations() function:
include(I18nHelpers)
mr_add_translations(myplugin_translations
DOMAINS
MyPlugin
MRRibbonMyMenu
PATHS
"${CMAKE_CURRENT_PROJECT_DIR}/locale"
)
if(TARGET myplugin_translations)
add_dependencies(MyApp myplugin_translations)
endif()
The function:
- Finds msgfmt (respects GETTEXT_ROOT / $ENV{GETTEXT_ROOT}).
- For each domain and path, locates every <path>/<lang>/<domain>.po file.
- Creates custom commands that compile each .po to .mo at build time.
- Creates a custom target depending on all .mo files.
- Installs .pot templates and compiled .mo files to ${MR_RESOURCES_DIR}/locale/.
If msgfmt is not found, the function silently does nothing and no target is created. This is why the if(TARGET ...) guard is needed before add_dependencies.
MSBuild integration
source/CompileTranslations.targets provides an equivalent build-time .po → .mo compilation step for Visual Studio projects. Import the targets file and declare one or more TranslationLocaleDir items pointing at your locale/ root directories:
<Project>
<ItemGroup>
<TranslationLocaleDir Include="$(SolutionDir)..\locale" />
</ItemGroup>
<Import Project="$(MeshLibDir)\source\CompileTranslations.targets" />
</Project>
MeshLib's own common.props already adds $(MeshLibDir)\locale to TranslationLocaleDir, so projects that import common.props inherit MeshLib's translations automatically.
The CompileTranslations target runs before Build and:
- Globs every *.po file in specified locale dirs.
- Creates $(OutDir)locale\<lang>\LC_MESSAGES\ output directories.
- Calls msgfmt --check on each .po to produce the corresponding .mo.
If msgfmt is not found, the target is silently skipped and the build continues without compiling translations.
In-app initialization and configuration
Automatic initialization
Some configuration is performed automatically during the MRViewer library loading:
- Registration of the default domain "MeshLib".
- Adding the default catalog path: SystemPath::getResourcesDirectory() / "locale".
The initial locale is "en" (i.e. no translations applied).
Registering a custom domain
Call addDomain() once and use a returned value from findDomain(), before any translations from that domain are needed:
#include <MRViewer/MRLocale.h>
MR::Locale::addDomain( "MyPlugin" );
static const int kMyDomainId = MR::Locale::findDomain( "MyPlugin" );
The returned integer is stable for the process lifetime. You can pass it to translate() via MR::Locale::Domain{ kMyDomainId }.
Registering a custom catalog path
If your plugin stores .mo files outside the standard resources directory, register the path before any UI is shown:
MR::Locale::addCatalogPath( myPlugin.resourceDir() / "locale" );
Paths are deduplicated. Every call to addCatalogPath or addDomain regenerates the active locale object, so new catalogs are available immediately.
Switching locale at runtime
MR::Locale::set( "ko" );
MR::Locale::set( "en" );
set() fires the onChanged signal synchronously before returning.
Reacting to locale changes
boost::signals2::connection conn = MR::Locale::onChanged( [&]( const std::string& localeName )
{
rebuildUI();
} );
Defining human-readable locale names
Many common languages and regions already have a pre-loaded human-readable name provided by CLDR. If your locale is not in the list, you can add it in runtime.
MR::Locale::setDisplayName( "my_variant", "My Language (Special)" );
Translation functions and macros
Macros
All macros are defined in MRViewer/MRI18n.h, except _t, which is in MRMesh/MRMeshFwd.h:
- _tr("text") - UI labels passed directly to ImGui/UI functions.
- s_tr("text") - When you need an owning string (storage, concatenation).
- f_tr("text {}") - Format strings for fmt::format().
- _t("text") - Marking strings for extraction in contexts where the original value must be returned (see an example below).
- Warning
- _tr() returns a pointer into a temporary std::string that is destroyed at the end of the full expression. Always consume _tr() in the same expression, or use s_tr() / MR::Locale::translate() to store the result.
UI::button( _tr( "Save" ), size );
ImGui::Text( "%s", _tr( "Label" ) );
const auto label = _tr( "Save" );
UI::button( label, size );
const auto label = s_tr( "Save" );
UI::button( label.c_str(), size );
Direct translate functions
MR::Locale::translate() accepts an optional Domain parameter:
std::string t = MR::Locale::translate( "Open" );
std::string t = MR::Locale::translate( "Open", MR::Locale::Domain{ kMyDomainId } );
std::string t = MR::Locale::translate( "Camera", "View" );
std::string t = MR::Locale::translate( "%d item", "%d items", count );
auto items = MR::Locale::translateAll( kModeNames );
auto items = MR::Locale::translateAll( "context", kModeNames );
Static arrays and deferred translation
Mark strings with _t() at declaration time so xgettext extracts them, then translate at display time:
static const std::vector<std::string> kModeNames {
_t( "Append" ),
_t( "Replace" ),
};
UI::combo( _tr( "Mode" ), &idx, MR::Locale::translateAll( kModeNames ) );
Context disambiguation
When the same English string has different meanings, use the context overload:
ImGui::Text( "%s", _tr( "Camera", "View" ) );
ImGui::Text( "%s", _tr( "Action", "View" ) );
In the .po file these appear as separate entries with different msgctxt values.
Plural forms
auto label = MR::Locale::translate( "%d item", "%d items", count );
The correct form is selected by Boost.Locale based on the Plural-Forms: rule in the .po header.
TRANSLATORS comments
Place a // TRANSLATORS: comment on the line immediately before a translatable string to give translators context. xgettext picks these up and includes them as #. comment lines in the .pot file:
ImGui::Text( "%s", _tr( "Empty result" ) );
Custom header file for your domain
When building a plugin with its own translation domain, create a project-local header file that redefines the macros to use your domain automatically. This way, every _tr() call in your plugin looks up the correct catalog without passing domain IDs explicitly.
Copy and adapt the following template (also available at examples/cpp-samples/MRI18nDomainExample.h):
#pragma once
#include <MRViewer/MRI18n.h>
namespace MR::Locale { MRVIEWER_API int findDomain( const char* domainName ); }
inline constexpr const char* MY_PLUGIN_I18N_DOMAIN = "MyPlugin";
#undef _tr
#undef s_tr
#undef f_tr
#define _tr( ... ) MR::Locale::translate( __VA_ARGS__, MR::Locale::Domain{ MR::Locale::findDomain( MY_PLUGIN_I18N_DOMAIN ) } ).c_str()
#define s_tr( ... ) MR::Locale::translate( __VA_ARGS__, MR::Locale::Domain{ MR::Locale::findDomain( MY_PLUGIN_I18N_DOMAIN ) } )
#define f_tr( ... ) fmt::runtime( MR::Locale::translate( __VA_ARGS__, MR::Locale::Domain{ MR::Locale::findDomain( MY_PLUGIN_I18N_DOMAIN ) } ) )
To use this header in your plugin:
- Include your local header file instead of <MRViewer/MRI18n.h> in every .cpp file.
- Register the domain at startup: MR::Locale::addDomain( MY_PLUGIN_I18N_DOMAIN );
- All _tr(), s_tr(), and f_tr() calls will now use your domain.
- Note
- findDomain() performs a cache lookup by pointer address for const char* literals, so the runtime cost is minimal.
Translating JSON menu files
MeshLib's ribbon menu system loads tool definitions from .items.json and UI layout from .ui.json files. Both formats contain translatable strings.
.items.json
Each entry in the "Items" array may contain:
- "Caption" - the display label (falls back to "Name" if absent).
- "Tooltip" - the tooltip text.
{
"Items": [
{
"Name": "My Tool",
"Caption": "My Custom Tool",
"Tooltip": "Performs a custom operation on the mesh"
}
]
}
See C++ Example Plugin Overview for a complete plugin JSON example.
.ui.json
Tab names in the "Tabs" array are extracted with the context "Tab name":
{
"Order": 10,
"LibName": "MyPlugin",
"Tabs": [
{
"Name": "Analysis",
"Groups": [ ... ]
}
]
}
Domain auto-registration
When the ribbon schema loader reads a .items.json file, it automatically registers a domain from the filename stem. For example, loading MRRibbonMyMenu.items.json calls Locale::addDomain( "MRRibbonMyMenu" ) and stores the returned domain ID in each MenuItemInfo. The matching .ui.json then uses Locale::findDomain() with the same stem.
This means ribbon menu strings are translated from their own domain catalog, separate from your C++ code strings.
Extracting JSON strings
Run update_json_translations.py for each JSON pair:
python3 scripts/gettext/update_json_translations.py \
locale/MRRibbonMyMenu.pot \
source/MyPlugin/MRRibbonMyMenu.items.json
The script extracts "Caption" (or "Name") and "Tooltip" from .items.json, and tab "Name" fields (with context "Tab name") from the matching .ui.json.