Internationalisation des sketchs Processing (et des programmes Java) - 28 août 2009
Suite à une question dans le forum de Processing (Localization [ i18n ] - go there if you can't read French, most of the info here is there), je me suis penché de plus près sur le système d'internationalisation de Java.
L'internationalisation (i18n en abrégé : 18 lettres entre i et n...) de logiciels est un point important pour une utilisation aisée dans tous les pays. Cela comprend non seulement la traduction des textes, mais aussi la prise en compte des particularités de chaque pays (localisation, ou l10n), comme les séparateurs de décimales et de milliers, l'écriture des dates, la gestion des pluriels, etc.
Je me suis aperçu que pour Processing, il fallait contourner certains obstacles pour parvenir au résultat. Je livre ici mon expérience, dont une partie est utilisable directement dans Java.
En effet, pour mémoire, Processing est du Java, avec juste certaines facilités et raccourcis pour simplifier la programmation. En Java, la pratique courante est d'utiliser les ResourceBundle pour gérer des fichiers de traduction. Un obstacle que j'ai rencontré est que ces liasses de ressources (en traduction littérale) vont chercher les traductions dans le classpath, ce qui ne convenait pas à l'environnement Processing. De plus, je voulais contourner d'emblée la limitation d'encodage de ces fichiers de traduction, pour utiliser l'UTF-8. Plus de détails plus bas...
Pourquoi utiliser ces bundles s'ils posent problème ? Après tout je pourrai lire un simple fichier de traduction et me débrouiller avec ça, non ? Oui, en quelque sorte, mais la classe ResourceBundle a quand même un avantage : elle sait gérer une traduction par défaut (souvent en anglais...), utilisée si une langue n'a pas tout traduit ; il gère les langues "standard" (anglais, français), mais aussi les dialectes par pays (USA vs. Grande Bretagne, Canada vs. France...), en ne fournissant que les variantes sans se préoccuper du tronc commun.
Pour cela, elle regarde des fichiers, utilisant un nom de bundle (liasse, paquet...) comme préfixe, puis le nom de la langue (en minuscules) et le nom du pays (en capitales), les deux étant optionnels.
Par exemple, pour le bundle "Interface", si on lui dit de traduire une chaîne de caractère en français de Belgique, il cherche d'abord la traduction dans le fichier Interface_fr_BE.properties, puis, si pas trouvé, dans Interface_fr.properties et en désespoir de cause, dans Interface.properties
Ces fichiers .properties contiennent juste une série de lignes composées de paires clé/traduction, du style question = Voulez-vous du café ?... Le programme demande la chaîne localisée (selon le Locale choisi) correspondant à la clé donnée.
Un des problèmes mentionné ci-dessus est que ces fichiers doivent être encodés en ISO-8859-1 (aussi connu sous le nom Latin1), ce qui est assez restrictif, particulièrement pour les langues moyen-orientales ou asiatiques, qui doivent utiliser des échappements du style \u2022 pour encoder les caractères Unicode. Même en français, le œ ayant été oublié de ce jeu de caractères, il faut l'encoder avec \u0153. Pas super lisible, ça...
Heureusement, j'ai trouvé une astuce sympa : UTF-8 utilisant uniquement des caractères ISO-8859-1, on peut encoder ces fichiers en UTF-8 en faisant croire à Java que c'est de l'ISO-8859-1, il acceptera de les lire sans problème, et on peut les transformer en Unicode par la suite. L'astuce vient de l'article Quick and Dirty Hack for UTF-8 Support in ResourceBundle, je l'ai un peu modifié pour autoriser le chargement hiérarchique décrit ci-dessus.
L'astuce consiste à encoder les fichiers en UTF-8, qui est composé de caractères valides en ISO-8859-1. On laisse le resource bundle récupérer la chaîne comme si c'était de l'ISO, et on convertit à la volée les données de ISO vers UTF-8, le résultat devenant de l'UTF-16, le format Unicode interne de Java.
import java.util.ResourceBundle; import java.util.PropertyResourceBundle; import java.util.Locale; import java.util.Enumeration; public abstract class UTF8ResourceBundle { // I keep the public API compatible with ResourceBundle public static final ResourceBundle getBundle(String baseName) { ResourceBundle bundle = ResourceBundle.getBundle(baseName); return CreateUTF8ResourceBundle(bundle); } public static final ResourceBundle getBundle(String baseName, Locale locale) { ResourceBundle bundle = ResourceBundle.getBundle(baseName, locale); return CreateUTF8ResourceBundle(bundle); } public static final ResourceBundle getBundle(String baseName, Locale locale, ClassLoader loader) { ResourceBundle bundle = ResourceBundle.getBundle(baseName, locale, loader); return CreateUTF8ResourceBundle(bundle); } private static ResourceBundle CreateUTF8ResourceBundle(ResourceBundle bundle) { // Trick is used only for property resource bundles, not for class resource bundles! if (!(bundle instanceof PropertyResourceBundle)) return bundle; return new UTF8PropertyResourceBundle((PropertyResourceBundle) bundle); } private static class UTF8PropertyResourceBundle extends ResourceBundle { PropertyResourceBundle m_bundle; private UTF8PropertyResourceBundle(PropertyResourceBundle bundle) { m_bundle = bundle; } @Override public Object handleGetObject(String key) { // Use getString (instead of handleGetObject) because: // 1) It is only a PropertyResourceBundle // 2) It allows fallback on parent bundle String value = (String) m_bundle.getString(key); if (value == null) return null; try { // The default resource bundle returns ISO-8859-1 strings. // We get the bytes using this encoding, // then transform them to string using UTF-8 encoding, // which is the real encoding of the files // (UTF-8 chars are valid ISO-8859-1 chars!) return new String(value.getBytes("ISO-8859-1"), "UTF-8"); } catch (java.io.UnsupportedEncodingException e) { return null; // Shouldn't go there if encoding strings above are OK... } } @Override public Enumeration<String> getKeys() { return m_bundle.getKeys(); } } }
L'autre problème avec Processing est que ResourceBundle va chercher ces fichiers dans le classpath, autrement dit là où sont les fichiers compilés (.class). Mais Processing (utilisé avec son PDE, son IDE quoi) masque ces fichiers, générant du Java dans un dossier à nom aléatoire dans le répertoire temporaire de votre système, et compilant le résultat dans un dossier tout aussi aléatoire. Pas moyen d'injecter les traductions là-dedans ! Il faut ruser et dire au resource bundle d'aller voir ailleurs, par exemple dans le classique dossier data du dossier du sketch (programme Processing, au sens anglais de 'croquis', pas de spectacle de comique !).
Pour cela, on peut utiliser un truc sympa, le fait qu'on puisse charger les ressources en spécifiant un chargeur de classe (class loader). Une petite recherche pour savoir comment on fait ces bêtes-là, et hop, un autre problème réglé !
C'est assez simple : je passe l'instance de PApplet (le 'this' dans le sketch) à ma classe, et je m'en sert pour appeler la fonction dataPath() qui retourne le chemin du fichier donné dans le dossier 'data' dans le dossier du sketch. Je transforme ça en URL que je retourne. Le tour est joué.
import java.net.URL; import java.io.File; import processing.core.PApplet; public class ProcessingClassLoader extends ClassLoader { private PApplet m_pa; public ProcessingClassLoader(PApplet pa) { super(); m_pa = pa; } @Override public URL getResource(String name) { String textURL = m_pa.dataPath(name); // System.out.println("getResource " + textURL); URL url = null; try { url = (new File(textURL)).toURI().toURL(); } catch (java.net.MalformedURLException e) { System.out.println("ProcessingClassLoader - Incorrect URL: " + textURL); } return url; } // Not necessary, mostly there to see if it is used... /* @Override public Class loadClass(String name, boolean resolve) throws ClassNotFoundException { System.out.println("loadClass: " + name); return findSystemClass(name); } */ }
Bon, je ne vous montre pas tout le code du sketch, il affiche juste quelques textes sur la fenêtre. J'utilise le clic sur certaines zones (genre bouton) en gérant l'appui sur Ctrl pour afficher les variantes locales des langues :
void mouseReleased() { // Alternative language boolean bAlt = keyPressed && key == CODED && keyCode == CONTROL; if (mouseY > menuPos - 20 && mouseY < menuPos + 20) { if (mouseX > 60 && mouseX < 140) { if (bAlt) { GetStrings(Locale.US); // Locale("en", "US") } else { GetStrings(Locale.ENGLISH); // Locale("en") } } else if (mouseX > 160 && mouseX < 240) { GetStrings(esLocale); } else if (mouseX > 260 && mouseX < 340) { if (bAlt) { GetStrings(Locale.CANADA_FRENCH); // Locale("fr", "CA") } else { GetStrings(Locale.FRENCH); // Locale("fr") } } else { GetStrings(Locale.getDefault()); } } }
GetStrings charge le resource bundle customisé avec le class loader spécial. Je garde res (ResourceBundle) global pour avoir un appel simplifié à GetString(), la méthode qui charge la chaîne. Cette dernière capte si la chaîne n'est pas définie dans les traductions. Dans ce cas, elle retourne la clé, substitut qui peut être suffisant, et est mieux que rien (peut aussi dire : « Traduisez-moi ! » dans l'interface...).
J'ai aussi joué avec les messages avec paramètres (substitution de tags avec indication de formatage par les valeurs données, formatées), en utilisant MessageFormat, une classe sympa (mais faut pas oublier de doubler les apostrophes dans les fichiers de traduction !).
Enfin, j'ai utilisé le ChoiceFormat pour gérer les pluriels (les anglais aiment bien mettre un pluriel quand on a zéro quantité...), mais si j'en crois le fichier PluralForm.jsm trouvé dans les dossiers d'installation de Firefox 3, certaines langues ont des règles plus complexes que ça... Ça ira bien pour une petite démo !
void GetStrings(Locale locale) { println(locale.getLanguage() + " / " + locale.getCountry()); res = UTF8ResourceBundle.getBundle(bundleName, locale, new ProcessingClassLoader(this)); // We distinguish between 0 items, 1 item and 2 or more items. // According to the PluralForm.jsm file I found in Firefox 3 folders, // this array should be dependent of the language: Latvian is different, so is Russian, etc. double[] pluralLimits = { 0, 1, 2 }; // Simple translations, no parameters appName = GetString("APP_NAME"); appAuth = GetString("APP_AUTH"); slogan = GetString("slogan"); title = GetString("Title"); artist = GetString("Artist"); album = GetString("Album"); genre = GetString("Genre"); en = GetString("en"); es = GetString("es"); fr = GetString("fr"); // Translations including parameters: the value order might depend on language // So we use MessageFormat to handle this order, and formatting information (date, decimal/thousand separators...) // depending on locale. // Generic, will give patterns later MessageFormat formatter = new MessageFormat("", locale); // Disk number, I have to use a choice format // to select the correct the plural form depending on the quantity. String diskNbMsgPat = GetString("disk number"); String [] diskNbPats = { GetString("DN.zero"), GetString("DN.one"), GetString("DN.more") }; ChoiceFormat choice = new ChoiceFormat(pluralLimits, diskNbPats); int diskNb = (int) random(0, 4); int diskNbMore = (int) (diskNb * 1E6 + random(1000, 1E6)); formatter.applyPattern(diskNbMsgPat); // Apply the choice on pattern {0} formatter.setFormatByArgumentIndex(0, choice); Object[] diskStats = { diskNb, diskNbMore }; String diskNbMsg = formatter.format(diskStats); // Release information String releaseInfoPat = GetString("release"); formatter.applyPattern(releaseInfoPat); // I just want to display data according to the chosen locale... String country = GetString(locale.getLanguage().toUpperCase()); // EN, ES, FR or other Object[] information = { country, // Some random date in late XXth century... new Date((long) random(1E10, 1E12)), diskNbMsg, diskNbMore / 1.42E5, }; releaseInfo = formatter.format(information); } String GetString(String key) { String value = null; try { value = res.getString(key); } catch (MissingResourceException e) { println("Key " + key + " not found"); value = key; // Poor substitute, but hey, might give an information anyway } return value; }
Voili, voilou, y'a plus de détails dans le code, commenté en anglais, mais rien de complexe. N'hésitez pas à poser des questions s'il y a un problème. Ah, je finis par mes fichiers de traduction, soigneusement conçus pour illustrer le système hiérarchique. Pour l'anecdote, j'ai fait la liste avec un petit fichier .cmd (équivalent de .bat sous XP) :
rm Loc.txt for %%f in (data/*.properties) do (echo ¤ && echo [b]%%f[/b] && cat data/%%f) >> Loc.txt
Oui, je mélange le shell XP et les commandes Unix (venues de UnxUtils)... Le ¤ est là pour être viré en laissant une ligne vide, j'ai pas trouvé comment faire un echo vide... J'ai laissé l'encodage ISO, d'où les accents bizarres... Résultat :
Localization.properties APP_NAME = PolygotProcessing APP_AUTH = PhiLho slogan = That's a fine software! fr = French es = Spanish en = English FR = France ES = Spain EN = England # Pas besoin des les définir, la clé est la valeur... # Seulement parce que mon code utilise la clé comme valeur # par défaut lors du chargement, sinon on a une exception #~ Title = Title #~ Artist = Artist #~ Album = Album #~ Genre = Genre # Gestion des messages composites et des pluriels # 0 = country name, 1 = date & time, 2 = disk number (choice below), 3 = disk number per day release = Released on {1,date,long} at {1,time,short} precisely, \ it sold {2} in {0}, ie. {3,number,0.##} per day. # 0 = million of disks (choice), 1 = exact number disk\ number = {0} of disks ({1,number,integer} exactly) DN.zero = below a million DN.one = one million DN.more = {0,number,integer} million Localization_en.properties # N'est là que pour attraper les requêtes anglaises génériques (sans pays) # et permettre de retomber sur les chaînes par défaut. Localization_en_US.properties en = American EN = United States of America slogan = That's a good software, boy! Localization_es.properties # app name and auth as in default translation slogan = ¡MagnÃfico software! fr = Francés es = Español en = Inglés FR = Francia ES = España EN = Inglaterra Title = Titulo Artist = Artista Album = Album Genre = Género # Traduction de Google... release = Liberado el {1,date,long} a las {1,time,short} precisamente, \ que vendió {2} en {0}, que es {3,number,0.##} por dÃa. disk\ number = {0} de discos ({1,number,integer} exactamente) DN.zero = debajo de un millón DN.one = un millón DN.more = {0,number,integer} millones Localization_fr.properties APP_NAME = Processing Polygotte slogan = C'est un bon logiciel ! fr = Français es = Espagnol en = Anglais FR = France ES = Espagne EN = Angleterre Title = Titre Artist = Artiste Album = Album Genre = Genre # Attention : avec MessageFormat qui utilise l'apostrophe comme échappement, # il faut les doubler. release = L''artiste a vendu {2} en {0} lors de la sortie de son album \ le {1,date,long} (à {1,time,short} précisément). \ Soit {3,number,0.##} par jour. disk\ number = {0} de disques ({1,number,integer} exactement) DN.zero = moins d'un million DN.one = un million DN.more = {0,number,integer} millions Localization_fr_CA.properties slogan = Tabernacle ! # J'ai volontairement laissé une incohérence en français, pour illustrer # quelques problèmes de traduction que certaines personnes ne connaissant pas # la langue peuvent ne pas gérer : # en anglais, on écrit juste 'in France', 'in Quebec', etc. # en français, on écrit 'en France', 'au Québec' (au Yémen, aux États-Unis, etc.) # Donc parfois le code doit être améliorer pour gérer les particularités linguistiques. # J'ai supposé ici que les clés FR, EN, etc. peuvent avoir un usage générique, # donc inclure l'article dans le nom du pays n'est pas une option... # Même problème avec le genre du pays. # la France, l'Espagne, le Japon, les États-Unis... # I18n n'est pas un problème simple ! # Bien sûr, on peut aussi contourner le problème dans la traduction en # tournant la phrase pour éviter l'usage des articles (pays entre parenthèses, etc.) FR = Québec (Canada)
Vous pouvez trouver tous ces fichiers dans mon mon dépôt Launchpad de sketches Processing.