|
| 1 | +/** |
| 2 | + |
| 3 | +\page I18nGuide Internationalization Guide |
| 4 | + |
| 5 | +\tableofcontents |
| 6 | + |
| 7 | +MeshLib uses a <a href="https://www.gnu.org/software/gettext/">gettext</a>-compatible localization system backed by |
| 8 | +<a href="https://www.boost.org/doc/libs/release/libs/locale/">Boost.Locale</a>. |
| 9 | +Translatable strings are marked in C++ source code, extracted to template files, translated per language, |
| 10 | +compiled to binary catalogs, and loaded at runtime. |
| 11 | + |
| 12 | +This guide explains how to integrate and use the i18n system in your own MeshLib-based project. |
| 13 | + |
| 14 | +# File formats and directory layout {#I18nFileFormats} |
| 15 | + |
| 16 | +The gettext toolchain uses three file types: |
| 17 | + |
| 18 | +- `.pot` - Master list of all extractable strings for a domain. Generated by `xgettext`. |
| 19 | +- `.po` - Per-language translation file. Each `msgid` has a corresponding `msgstr` filled in by a translator. |
| 20 | +- `.mo` - Compiled from `.po` files by `msgfmt`. Loaded at runtime by Boost.Locale. |
| 21 | + |
| 22 | +Source translations live under a `locale/` directory at the project root: |
| 23 | + |
| 24 | +\code |
| 25 | +locale/ |
| 26 | + MyPlugin.pot <- template (all extractable strings) |
| 27 | + MRRibbonMyMenu.pot <- template for ribbon JSON strings |
| 28 | + de/ |
| 29 | + MyPlugin.po <- German translations |
| 30 | + MRRibbonMyMenu.po |
| 31 | + fr/ |
| 32 | + MyPlugin.po <- French translations |
| 33 | + MRRibbonMyMenu.po |
| 34 | +\endcode |
| 35 | + |
| 36 | +At build time, `.po` files are compiled to `.mo` and placed in the output directory: |
| 37 | + |
| 38 | +\code |
| 39 | +<build>/locale/<lang>/LC_MESSAGES/<Domain>.mo |
| 40 | +\endcode |
| 41 | + |
| 42 | +For example: `build/Release/bin/locale/de/LC_MESSAGES/MyPlugin.mo`. |
| 43 | + |
| 44 | +\note The domain name is always derived from the filename stem: `MyPlugin.po` belongs to domain `"MyPlugin"`. |
| 45 | + |
| 46 | +# Gettext utilities and helper scripts {#I18nTooling} |
| 47 | + |
| 48 | +## Prerequisites |
| 49 | + |
| 50 | +- **gettext tools**: `xgettext`, `msgmerge`, `msgfmt`. Install via your OS package manager. On Windows, set the `GETTEXT_ROOT` environment variable to the install prefix. |
| 51 | +- **Python 3** for the helper scripts in `scripts/gettext/`. |
| 52 | + |
| 53 | +## Helper scripts |
| 54 | + |
| 55 | +All scripts are in `scripts/gettext/`: |
| 56 | + |
| 57 | +- `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. |
| 58 | +- `update_json_translations.py` - Extracts translatable fields from `.items.json` and `.ui.json` ribbon menu files into a `.pot`, then merges existing `.po` files. |
| 59 | +- `compile_translations.py` - Compiles every `.po` file under `locale/` to `.mo` using `msgfmt`. |
| 60 | + |
| 61 | +### Extracting C++ strings |
| 62 | + |
| 63 | +\code{.sh} |
| 64 | +python3 scripts/gettext/update_translations.py \ |
| 65 | + locale/MyPlugin.pot \ |
| 66 | + source/MyPlugin/ |
| 67 | +\endcode |
| 68 | + |
| 69 | +This scans all `.cpp`, `.h`, and `.hpp` files and extracts strings marked by the recognized macros. |
| 70 | + |
| 71 | +### Extracting JSON strings |
| 72 | + |
| 73 | +\code{.sh} |
| 74 | +python3 scripts/gettext/update_json_translations.py \ |
| 75 | + locale/MRRibbonMyMenu.pot \ |
| 76 | + source/MyPlugin/MRRibbonMyMenu.items.json |
| 77 | +\endcode |
| 78 | + |
| 79 | +### Compiling translations |
| 80 | + |
| 81 | +\code{.sh} |
| 82 | +python3 scripts/gettext/compile_translations.py \ |
| 83 | + locale/ \ |
| 84 | + build/Release/bin/locale/ |
| 85 | +\endcode |
| 86 | + |
| 87 | +This produces `build/Release/bin/locale/<lang>/LC_MESSAGES/<Domain>.mo` for every `.po` found. |
| 88 | + |
| 89 | +## CMake integration |
| 90 | + |
| 91 | +`cmake/Modules/I18nHelpers.cmake` provides the `mr_add_translations()` function: |
| 92 | + |
| 93 | +\code{.cmake} |
| 94 | +include(I18nHelpers) |
| 95 | + |
| 96 | +mr_add_translations(myplugin_translations |
| 97 | + DOMAINS |
| 98 | + MyPlugin |
| 99 | + MRRibbonMyMenu |
| 100 | + PATHS |
| 101 | + "${CMAKE_CURRENT_PROJECT_DIR}/locale" |
| 102 | +) |
| 103 | +if(TARGET myplugin_translations) |
| 104 | + add_dependencies(MyApp myplugin_translations) |
| 105 | +endif() |
| 106 | +\endcode |
| 107 | + |
| 108 | +The function: |
| 109 | +1. Finds `msgfmt` (respects `GETTEXT_ROOT` / `$ENV{GETTEXT_ROOT}`). |
| 110 | +2. For each domain and path, locates every `<path>/<lang>/<domain>.po` file. |
| 111 | +3. Creates custom commands that compile each `.po` to `.mo` at build time. |
| 112 | +4. Creates a custom target depending on all `.mo` files. |
| 113 | +5. Installs `.pot` templates and compiled `.mo` files to `${MR_RESOURCES_DIR}/locale/`. |
| 114 | + |
| 115 | +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`. |
| 116 | + |
| 117 | +# In-app initialization and configuration {#I18nInit} |
| 118 | + |
| 119 | +## Automatic initialization |
| 120 | + |
| 121 | +Some configuration is performed automatically during the MRViewer library loading: |
| 122 | +1. Registration of the default domain `"MeshLib"`. |
| 123 | +2. Adding the default catalog path: `SystemPath::getResourcesDirectory() / "locale"`. |
| 124 | + |
| 125 | +The initial locale is `"en"` (i.e. no translations applied). |
| 126 | + |
| 127 | +## Registering a custom domain |
| 128 | + |
| 129 | +Call `addDomain()` once and use a returned value from `findDomain()`, before any translations from that domain are needed: |
| 130 | + |
| 131 | +\code{.cpp} |
| 132 | +#include <MRViewer/MRLocale.h> |
| 133 | + |
| 134 | +MR::Locale::addDomain( "MyPlugin" ); |
| 135 | + |
| 136 | +static const int kMyDomainId = MR::Locale::findDomain( "MyPlugin" ); |
| 137 | +\endcode |
| 138 | + |
| 139 | +The returned integer is stable for the process lifetime. You can pass it to `translate()` via `MR::Locale::Domain{ kMyDomainId }`. |
| 140 | + |
| 141 | +## Registering a custom catalog path |
| 142 | + |
| 143 | +If your plugin stores `.mo` files outside the standard resources directory, register the path before any UI is shown: |
| 144 | + |
| 145 | +\code{.cpp} |
| 146 | +MR::Locale::addCatalogPath( myPlugin.resourceDir() / "locale" ); |
| 147 | +\endcode |
| 148 | + |
| 149 | +Paths are deduplicated. Every call to `addCatalogPath` or `addDomain` regenerates the active locale object, so new catalogs are available immediately. |
| 150 | + |
| 151 | +## Switching locale at runtime |
| 152 | + |
| 153 | +\code{.cpp} |
| 154 | +MR::Locale::set( "ko" ); // switch to Korean |
| 155 | +MR::Locale::set( "en" ); // back to English |
| 156 | +\endcode |
| 157 | + |
| 158 | +`set()` fires the `onChanged` signal synchronously before returning. |
| 159 | + |
| 160 | +## Reacting to locale changes |
| 161 | + |
| 162 | +\code{.cpp} |
| 163 | +boost::signals2::connection conn = MR::Locale::onChanged( [&]( const std::string& localeName ) |
| 164 | +{ |
| 165 | + // rebuild cached translated strings, tooltips, etc. |
| 166 | + rebuildUI(); |
| 167 | +} ); |
| 168 | +\endcode |
| 169 | + |
| 170 | +## Defining human-readable locale names |
| 171 | + |
| 172 | +Many common languages and regions already have a pre-loaded human-readable name provided by CLDR. |
| 173 | +If your locale is not in the list, you can add it in runtime. |
| 174 | + |
| 175 | +\code{.cpp} |
| 176 | +MR::Locale::setDisplayName( "my_variant", "My Language (Special)" ); |
| 177 | +\endcode |
| 178 | + |
| 179 | +# Translation functions and macros {#I18nAPI} |
| 180 | + |
| 181 | +## Macros |
| 182 | + |
| 183 | +All macros are defined in `MRViewer/MRI18n.h`, except `_t`, which is in `MRMesh/MRMeshFwd.h`: |
| 184 | + |
| 185 | +- `_tr("text")` - UI labels passed directly to ImGui/UI functions. |
| 186 | +- `s_tr("text")` - When you need an owning string (storage, concatenation). |
| 187 | +- `f_tr("text {}")` - Format strings for `fmt::format()`. |
| 188 | +- `_t("text")` - Marking strings for extraction in contexts where the original value must be returned (see an example below). |
| 189 | + |
| 190 | +\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. |
| 191 | + |
| 192 | +\code{.cpp} |
| 193 | +// CORRECT: consumed in the same expression |
| 194 | +UI::button( _tr( "Save" ), size ); |
| 195 | +ImGui::Text( "%s", _tr( "Label" ) ); |
| 196 | + |
| 197 | +// WRONG: dangling pointer |
| 198 | +const auto label = _tr( "Save" ); |
| 199 | +UI::button( label, size ); // undefined behavior |
| 200 | + |
| 201 | +// CORRECT: store as std::string |
| 202 | +const auto label = s_tr( "Save" ); |
| 203 | +UI::button( label.c_str(), size ); |
| 204 | +\endcode |
| 205 | + |
| 206 | +## Direct translate functions |
| 207 | + |
| 208 | +`MR::Locale::translate()` accepts an optional `Domain` parameter: |
| 209 | + |
| 210 | +\code{.cpp} |
| 211 | +// simple message (default MeshLib domain) |
| 212 | +std::string t = MR::Locale::translate( "Open" ); |
| 213 | + |
| 214 | +// with explicit domain |
| 215 | +std::string t = MR::Locale::translate( "Open", MR::Locale::Domain{ kMyDomainId } ); |
| 216 | + |
| 217 | +// context disambiguation |
| 218 | +std::string t = MR::Locale::translate( "Camera", "View" ); |
| 219 | + |
| 220 | +// plural form |
| 221 | +std::string t = MR::Locale::translate( "%d item", "%d items", count ); |
| 222 | + |
| 223 | +// batch translation for combo lists |
| 224 | +auto items = MR::Locale::translateAll( kModeNames ); |
| 225 | +auto items = MR::Locale::translateAll( "context", kModeNames ); |
| 226 | +\endcode |
| 227 | + |
| 228 | +## Static arrays and deferred translation |
| 229 | + |
| 230 | +Mark strings with `_t()` at declaration time so `xgettext` extracts them, then translate at display time: |
| 231 | + |
| 232 | +\code{.cpp} |
| 233 | +// declaration: _t() is a no-op, strings are stored untranslated |
| 234 | +static const std::vector<std::string> kModeNames { |
| 235 | + _t( "Append" ), |
| 236 | + _t( "Replace" ), |
| 237 | +}; |
| 238 | + |
| 239 | +// display: translate on every frame |
| 240 | +UI::combo( _tr( "Mode" ), &idx, MR::Locale::translateAll( kModeNames ) ); |
| 241 | +\endcode |
| 242 | + |
| 243 | +## Context disambiguation |
| 244 | + |
| 245 | +When the same English string has different meanings, use the context overload: |
| 246 | + |
| 247 | +\code{.cpp} |
| 248 | +// "View" as a noun (saved camera view) vs. verb (to view something) |
| 249 | +ImGui::Text( "%s", _tr( "Camera", "View" ) ); |
| 250 | +ImGui::Text( "%s", _tr( "Action", "View" ) ); |
| 251 | +\endcode |
| 252 | + |
| 253 | +In the `.po` file these appear as separate entries with different `msgctxt` values. |
| 254 | + |
| 255 | +## Plural forms |
| 256 | + |
| 257 | +\code{.cpp} |
| 258 | +auto label = MR::Locale::translate( "%d item", "%d items", count ); |
| 259 | +\endcode |
| 260 | + |
| 261 | +The correct form is selected by Boost.Locale based on the `Plural-Forms:` rule in the `.po` header. |
| 262 | + |
| 263 | +## TRANSLATORS comments |
| 264 | + |
| 265 | +Place a `// TRANSLATORS:` comment on the line immediately before a translatable string to give translators context. |
| 266 | +`xgettext` picks these up and includes them as `#.` comment lines in the `.pot` file: |
| 267 | + |
| 268 | +\code{.cpp} |
| 269 | +// TRANSLATORS: Shown when the mesh has no faces after repair |
| 270 | +ImGui::Text( "%s", _tr( "Empty result" ) ); |
| 271 | +\endcode |
| 272 | + |
| 273 | +## Custom header file for your domain {#I18nCustomHeader} |
| 274 | + |
| 275 | +When building a plugin with its own translation domain, create a project-local header file |
| 276 | +that redefines the macros to use your domain automatically. This way, every `_tr()` call in your plugin |
| 277 | +looks up the correct catalog without passing domain IDs explicitly. |
| 278 | + |
| 279 | +Copy and adapt the following template (also available at `examples/cpp-samples/MRI18nDomainExample.h`): |
| 280 | + |
| 281 | +\snippet MRI18nDomainExample.h custom-domain-header |
| 282 | + |
| 283 | +To use this header in your plugin: |
| 284 | + |
| 285 | +1. Include your local header file instead of `<MRViewer/MRI18n.h>` in every `.cpp` file. |
| 286 | +2. Register the domain at startup: `MR::Locale::addDomain( MY_PLUGIN_I18N_DOMAIN );` |
| 287 | +3. All `_tr()`, `s_tr()`, and `f_tr()` calls will now use your domain. |
| 288 | + |
| 289 | +\note `findDomain()` performs a cache lookup by pointer address for `const char*` literals, so the runtime cost is minimal. |
| 290 | + |
| 291 | +# Translating JSON menu files {#I18nJSON} |
| 292 | + |
| 293 | +MeshLib's ribbon menu system loads tool definitions from `.items.json` and UI layout from `.ui.json` files. |
| 294 | +Both formats contain translatable strings. |
| 295 | + |
| 296 | +## .items.json |
| 297 | + |
| 298 | +Each entry in the `"Items"` array may contain: |
| 299 | +- **"Caption"** - the display label (falls back to **"Name"** if absent). |
| 300 | +- **"Tooltip"** - the tooltip text. |
| 301 | + |
| 302 | +\code{.json} |
| 303 | +{ |
| 304 | + "Items": [ |
| 305 | + { |
| 306 | + "Name": "My Tool", |
| 307 | + "Caption": "My Custom Tool", |
| 308 | + "Tooltip": "Performs a custom operation on the mesh" |
| 309 | + } |
| 310 | + ] |
| 311 | +} |
| 312 | +\endcode |
| 313 | + |
| 314 | +See \ref ExamplePluginOverview for a complete plugin JSON example. |
| 315 | + |
| 316 | +## .ui.json |
| 317 | + |
| 318 | +Tab names in the `"Tabs"` array are extracted with the context `"Tab name"`: |
| 319 | + |
| 320 | +\code{.json} |
| 321 | +{ |
| 322 | + "Order": 10, |
| 323 | + "LibName": "MyPlugin", |
| 324 | + "Tabs": [ |
| 325 | + { |
| 326 | + "Name": "Analysis", |
| 327 | + "Groups": [ ... ] |
| 328 | + } |
| 329 | + ] |
| 330 | +} |
| 331 | +\endcode |
| 332 | + |
| 333 | +## Domain auto-registration |
| 334 | + |
| 335 | +When the ribbon schema loader reads a `.items.json` file, it automatically registers a domain from the filename stem. |
| 336 | +For example, loading `MRRibbonMyMenu.items.json` calls `Locale::addDomain( "MRRibbonMyMenu" )` and stores the |
| 337 | +returned domain ID in each `MenuItemInfo`. The matching `.ui.json` then uses `Locale::findDomain()` with the same stem. |
| 338 | + |
| 339 | +This means ribbon menu strings are translated from their own domain catalog, separate from your C++ code strings. |
| 340 | + |
| 341 | +## Extracting JSON strings |
| 342 | + |
| 343 | +Run `update_json_translations.py` for each JSON pair: |
| 344 | + |
| 345 | +\code{.sh} |
| 346 | +python3 scripts/gettext/update_json_translations.py \ |
| 347 | + locale/MRRibbonMyMenu.pot \ |
| 348 | + source/MyPlugin/MRRibbonMyMenu.items.json |
| 349 | +\endcode |
| 350 | + |
| 351 | +The script extracts `"Caption"` (or `"Name"`) and `"Tooltip"` from `.items.json`, and tab `"Name"` fields |
| 352 | +(with context `"Tab name"`) from the matching `.ui.json`. |
| 353 | + |
| 354 | +*/ |
0 commit comments