Skip to content

feat(i18n): Add Spanish localisation framework and translations#6973

Open
jonbragado wants to merge 2 commits into
GafferHQ:1.6_maintenancefrom
jonbragado:feature/i18n-spanish
Open

feat(i18n): Add Spanish localisation framework and translations#6973
jonbragado wants to merge 2 commits into
GafferHQ:1.6_maintenancefrom
jonbragado:feature/i18n-spanish

Conversation

@jonbragado

Copy link
Copy Markdown

Pull Request: Add Spanish localisation framework (i18n)

Repository: GafferHQ/gaffer
Base branch: 1.6_maintenance
Head branch: jonbragado/gaffer:feature/i18n-spanish
Title: feat(i18n): Add Spanish localisation framework and translations


Description

This PR introduces a complete internationalisation (i18n) framework for Gaffer's Python UI, along with a full Spanish translation covering ~4880 strings.

What's included

  • GafferUI/i18n.py — Core module providing _() (gettext wrapper), translateLabel() (word-by-word fallback for dynamic strings), getNodeLabel() (node name translation), and stripAccents() (IECoreGL rendering workaround).
  • startup/gui/language.py — Startup script that reads ~/gaffer/i18n.json to configure the active language and translation preferences.
  • GafferUI/locale/es/LC_MESSAGES/gaffer.po — Complete Spanish translation catalogue (4880 entries): menu items, node descriptions, plug labels, tooltips, and warning messages.
  • 405 modified .py files — All user-facing strings in Python UI modules wrapped with _() for gettext extraction.
  • GraphEditor.py enhancements — Translates node names and nodule labels in the Graph Editor canvas, using instance metadata to override shader-UI registrations.

Architecture

~/gaffer/i18n.json          ← User preference (language, translateNodeNames, translateTooltips)
        ↓
startup/gui/language.py     ← Sets GAFFER_LANG env var on startup
        ↓
GafferUI/i18n.py            ← gettext catalogue loading + helper functions
        ↓
_("string") in 405 .py     ← All UI-facing strings wrapped
        ↓
gaffer.po / gaffer.mo       ← Translation catalogue (es)

Limitations — IECoreGL Font rendering

The OpenGL text renderer (IECoreGL::Font) only supports ASCII characters (code points 0–127) in its glyph atlas. Characters from the Latin-1 Supplement range (ñ, á, é, ü, etc.) produce garbled output.

Workaround: All text destined for IECoreGL::Font::renderSprites() passes through stripAccents(), which removes diacritical marks and replaces ñ→n. This produces readable but linguistically imperfect text in the graph canvas.

Upstream fix required: This limitation is tracked as an issue in the Cortex repository:
ImageEngine/cortex#1538

Once Cortex expands the glyph atlas to 16×16 (Latin-1 coverage), the stripAccents() workaround can be removed and full accented text will render correctly.

What this PR does NOT change

  • No C++ modifications — purely Python UI layer
  • No changes to scene processing, rendering, or node evaluation
  • No breaking changes to existing scripts or node graphs
  • Translation is opt-in: English remains the default unless ~/gaffer/i18n.json specifies another language
  • The framework is language-agnostic: adding another language only requires a new .po file

Testing

  • Manually verified on Gaffer 1.6.18.0 (Windows) with full deployment
  • All menu items, node editor labels, descriptions, and graph canvas labels translate correctly
  • Verified that translation disabled ("language": "en") produces identical behaviour to upstream

Screenshots

(Add screenshots of translated UI here when submitting)


Related

- Add i18n.py module with gettext integration, translateLabel(), stripAccents()
- Add complete Spanish .po/.mo translation files (4878 entries)
- Add language.py startup script with GAFFER_LANG environment variable
- Wrap UI strings with _() in 398 Python files across all UI packages
- Add _TranslatedCellColumn for Light Editor cell value translation
- Add _WORD_MAP and _PHRASE_MAP for dynamic label translation
- Support for CamelCase expansion and metadata label translation
- Menu path disambiguation for translated menu items (menus.py)

Packages modified:
  GafferUI, GafferSceneUI, GafferImageUI, GafferDispatchUI,
  GafferVDBUI, GafferOSLUI, GafferArnoldUI, GafferCyclesUI,
  GafferRenderManUI

English UI impact: None when GAFFER_LANG is unset or 'en'.
All changes are transparent - identical behavior for English users.

@johnhaddon johnhaddon left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @jonbragado! This has clearly involved a lot of effort, and as the Gaffer user base continues to grow it is potentially a very valuable contribution.

I have to confess to being one of those lazy folks who hasn't spoken a foreign language since leaving school, and one of those developers with no experience in internationalisation. So please bear with me while I get up to speed with it all.

Rather than review all the changes at this stage (there are a lot!), I've tried to focus on the core decisions and architecture. My goal would be to have this system be as minimally intrusive as possible, and to fit in with Gaffer's existing conventions as closely as possible, so the majority of my comments are on those topics. It may be that I am completely off base, in which case please do educate me. But I'm hopeful that with some tweaks we can keep all the great work you've done while also minimising the footprint of the changes and reducing the future maintenance burden.

Cheers...
John

Comment thread python/GafferUI/i18n.py
Comment on lines +10 to +39
# ---------------------------------------------------------------------------
# i18n preferences config file
# ---------------------------------------------------------------------------
# Stored at ~/gaffer/i18n.json so it can be read *before* the full
# Preferences node is available. The file is a small JSON dict:
# { "language": "es", "translateNodeNames": true, "translateTooltips": true }

_I18N_CONF = pathlib.Path( "~/gaffer/i18n.json" ).expanduser()

def _readConf() :
"""Return the persisted i18n preferences dict, or empty dict."""
try :
with open( _I18N_CONF, "r", encoding = "utf-8" ) as f :
return json.load( f )
except Exception :
return {}

def saveConf( language, translateNodeNames, translateTooltips ) :
"""Persist the i18n preferences to ~/gaffer/i18n.json."""
_I18N_CONF.parent.mkdir( parents = True, exist_ok = True )
with open( _I18N_CONF, "w", encoding = "utf-8" ) as f :
json.dump(
{
"language" : language,
"translateNodeNames" : translateNodeNames,
"translateTooltips" : translateTooltips,
},
f, indent = 2
)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you explain the need for a separate JSON configuration format. I'm pretty keen that Gaffer configuration continues to use only one mechanism - the existing `.py files in GAFFER_STARTUP_PATHS.

Comment thread python/GafferUI/i18n.py
# ---------------------------------------------------------------------------
# Determine effective language
# ---------------------------------------------------------------------------
# Priority: stored preference > GAFFER_LANG env var > "en"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we be consulting the system locale as well as (or instead of) these? If the system is set to Spanish, then should Gaffer not operate in Spanish by default?

Comment thread python/GafferUI/i18n.py

# Common multi-word phrases that require Spanish noun-adjective order.
# Checked BEFORE word-by-word fallback in translateLabel().
_PHRASE_MAP = {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The framework is language-agnostic: adding another language only requires a new .po file

It seems like this might not apply here, and this bit is actually hardcoded to Spanish? Maintaining a language-agnostic approach throughout seems very worthwhile.


"description",
"""
_("""

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems unfortunate that every single metadata registration needs to be wrapped like this. As well as being a little ugly, it feels like the sort of thing that will be easily forgotten, and I wonder what the overhead of translating during startup is.

I wonder if it might be cleaner to store non-translated metadata, and then for the consumer of the metadata to perform the translation on the fly. Perhaps that might also open the door to being able to change language without restarting Gaffer?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From a code review perspective, it would have been really useful to have changes like this in a separate commit. With everything in one commit, it's hard to discern the important structural changes from the noise of the _( changes.

@@ -119,13 +121,13 @@ def addItems( spreadsheet ) :
)

if alreadyConnected and other :
menuDefinition.append( "/__ConnectedDivider__", { "divider" : True, "label" : "Connected" } )
menuDefinition.append( "/__ConnectedDivider__", { "divider" : True, "label" : _("Connected") } )

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to my comment on the metadata translation, I wonder how doable it would be to have the Menu class be the one that does the translation lookup for label etc. And I wonder if Window could do the lookup for title, and Label could do the lookup for it's text and so on. If that worked, then it would massively reduce the number of things wrapped in _(), and also allow folks to translate extensions that hadn't been built with internationisation in mind.

Comment thread python/GafferUI/i18n.py
single-line .po msgid entries."""
return " ".join( text.split() )

def _( text ) :

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No C++ modifications — purely Python UI layer

A large part of Gaffer is written in C++, so I think it's critical that any internationalisation system takes that into account. How hard would it be to refactor this stuff so that it's accessible from C++ too?

@murraystevenson murraystevenson left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @jonbragado, I think this effort will be a greatly appreciated improvement as Gaffer's international user-base grows. I've noted a couple of things inline while spot-checking these changes but to begin with my main questions are more conceptual (and maybe a little naiive as this is my first exposure to localisation) :

The use of the literal English text as keys in the .po files seems convenient to limit the amount of change in this already large PR, but I worry that it exposes us to easily breaking translations if we ever modify that text in the future. I suppose we could introduce some tests to ensure that .po files shipped with Gaffer are up to date, but we could still easily break external translations. Would using keys unrelated to the English text work better here? Are there more downsides to that approach than the current one?

The framework is language-agnostic: adding another language only requires a new .po file

Can you explain the general process for creating a new .po file as well as updating existing ones whenever we add more UI text that requires translation? How do you see the ongoing maintenance of the Spanish translation progressing as we continue to develop Gaffer?

Comment on lines +984 to +990
_channelLabelMap = {
"r" : "R", "g" : "V", "b" : "A", "a" : "\u03b1",
"h" : "T", "s" : "S", "v" : "V",
"t" : "T", "m" : "M", "i" : "I",
}
self.__channelLabels[component] = GafferUI.Label(
component.capitalize(),
_channelLabelMap.get( component, component.capitalize() ),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The framework is language-agnostic: adding another language only requires a new .po file

This seems to sidestep the .po file and instead hardcodes the translation of channel labels?

Comment on lines +819 to +825
updatedTabs[_(name)] = tab

if existingTabs.keys() != updatedTabs.keys() :
with Gaffer.Signals.BlockedConnection( self.__currentTabChangedConnection ) :
del self.__tabbedContainer[:]
for name, tab in updatedTabs.items() :
self.__tabbedContainer.append( tab, label = name )
for translatedName, tab in updatedTabs.items() :
self.__tabbedContainer.append( tab, label = translatedName )

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to use the translated name as the key here, or could we just translate the label?

Comment on lines +123 to +124
if _GAFFER_LANG != "en" :
return 195

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adjustments like this may be better specified as part of the language config?

else :
s = "Pinned to %d node%s." % ( n, "" if n == 1 else "s" )
s = _("Pinned to %d node(s).") % n

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are there established approaches to handling optional plural suffixes in translation, or is it better to simplify like this?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I understand correctly, the MO file (machine object) derives from the PO file. For security reasons, wouldn't it be better to generate the binary MO as part of the build process with msgfmt, especially when the PO source is fairly small?

(I can't speak for Team Gaffer, but just my two cents.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants