Skip to content

Commit 940b2e8

Browse files
authored
i18n: Add documentation article (#5866)
* i18n: Add documentation article * Fix sidebar * Fix typo * More consistent term usage
1 parent a09612e commit 940b2e8

4 files changed

Lines changed: 377 additions & 1 deletion

File tree

Lines changed: 354 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,354 @@
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+
*/

doxygen/general_pages/PackageOverview.dox

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,6 @@
1111
- \ref ExamplePluginOverview
1212
- \ref StatePluginsHelp
1313
- \ref HowtoAddPluginOverview
14+
- \ref I18nGuide
1415

15-
*/
16+
*/

doxygen/layout_templates/base_struct.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@
7272
<tab type="user" url="__BEGIN_URL__ExamplePluginOverview__END_URL__" title="How to use С++ Example Plugin"/>
7373
<tab type="user" url="__BEGIN_URL__StatePluginsHelp__END_URL__" title="State Plugins Overview"/>
7474
<tab type="user" url="__BEGIN_URL__HowtoAddPluginOverview__END_URL__" title="How to Add Plugin"/>
75+
<tab type="user" url="__BEGIN_URL__I18nGuide__END_URL__" title="Internationalization Guide"/>
7576
</tab>
7677
<tab type="usergroup" url="__BEGIN_URL__APIPage__END_URL__" title="API">
7778
<!-- API_CPP_PAGE -->
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
//! [custom-domain-header]
2+
#pragma once
3+
4+
#include <MRViewer/MRI18n.h>
5+
6+
// forward declaration from MRLocale.h
7+
namespace MR::Locale { MRVIEWER_API int findDomain( const char* domainName ); }
8+
9+
// replace "MyPlugin" with your actual domain name
10+
// (must match the .pot/.po filename stem)
11+
inline constexpr const char* MY_PLUGIN_I18N_DOMAIN = "MyPlugin";
12+
13+
// redefine the i18n macros to use your domain by default
14+
#undef _tr
15+
#undef s_tr
16+
#undef f_tr
17+
#define _tr( ... ) MR::Locale::translate( __VA_ARGS__, MR::Locale::Domain{ MR::Locale::findDomain( MY_PLUGIN_I18N_DOMAIN ) } ).c_str()
18+
#define s_tr( ... ) MR::Locale::translate( __VA_ARGS__, MR::Locale::Domain{ MR::Locale::findDomain( MY_PLUGIN_I18N_DOMAIN ) } )
19+
#define f_tr( ... ) fmt::runtime( MR::Locale::translate( __VA_ARGS__, MR::Locale::Domain{ MR::Locale::findDomain( MY_PLUGIN_I18N_DOMAIN ) } ) )
20+
//! [custom-domain-header]

0 commit comments

Comments
 (0)