From 30d3fe2d4ddd9464089811abca6f80716143cdca Mon Sep 17 00:00:00 2001 From: Hugo Locurcio Date: Tue, 17 Mar 2020 22:47:49 +0100 Subject: [PATCH] Add internationalization support A Python script is included to create new translation files or update existing translations. --- Assets/locale/fr.json | 303 +++++++++++++++++++++++++++++++ Sources/arm/App.hx | 65 ++++++- Sources/arm/Config.hx | 2 + Sources/arm/ui/BoxPreferences.hx | 11 ++ extract_locales.py | 99 ++++++++++ khafile.js | 1 + 6 files changed, 477 insertions(+), 4 deletions(-) create mode 100644 Assets/locale/fr.json create mode 100755 extract_locales.py diff --git a/Assets/locale/fr.json b/Assets/locale/fr.json new file mode 100644 index 00000000..513debf4 --- /dev/null +++ b/Assets/locale/fr.json @@ -0,0 +1,303 @@ +{ + "New Layer": "Nouveau calque", + "Delete Layer": "Supprimer le calque", + "Duplicate Layer": "Dupliquer le calque", + "Order Layers": "Ordonner les calques", + "Merge Layers": "Fusionner les calques", + "New Mask": "Nouveau masque", + "Delete Mask": "Supprimer le masque", + "Apply Mask": "Appliquer le masque", + "To Fill Layer": "Vers calque de remplissage", + "To Paint Layer": "Vers calque de peinture", + "Layer Opacity": "Opacité du calque", + "Layer Blending": "Mode de fusion calque", + "Edit Nodes": "Modifier nœuds", + "New": "Nouveau", + "untitled": "sans titre", + "New Project": "Nouveau projet", + "Template": "Modèle", + "OK": "OK", + "Import Mesh": "Importer le maillage", + "Object": "Objet", + "Group": "Groupe", + "Material": "Matériau", + "UDIM Tile": "Tuile UDIM", + "Split By": "Séparer par", + "Split .obj mesh into objects": "Séparer le maillage .obj en objets", + "Parse Transforms": "Charger transformations", + "Load per-object transforms from .fbx": "Charger les transformations par objet des fichiers .fbx", + "Parse Vertex Colors": "Charger les couleurs vertex", + "Import vertex color data": "Importer les données de couleur vertex", + "Cancel": "Annuler", + "Import": "Importer", + "Export Textures": "Exporter les textures", + "Resolution": "Résolution", + "Color": "Couleur", + "Format": "Format", + "Quality": "Qualité", + "Visible": "Visible", + "Selected": "Sélectionnés", + "Layers": "Calques", + "Preset": "Préréglage", + "Export": "Exporter", + "Export texture files": "Exporter des fichiers de textures", + "Presets": "Préréglages", + "New Preset": "Nouveau préréglage", + "Name": "Nom", + "Texture": "Texture", + "R": "R", + "G": "V", + "B": "B", + "A": "A", + "Delete": "Supprimer", + "Add": "Ajouter", + "Export Mesh": "Exporter le maillage", + "Apply Displacement": "Appliquer déplacement", + "Interface": "Interface", + "Language (restart to apply changes)": "Langue (redémarrer pour appliquer)", + "UI Scale": "Échelle UI", + "Theme": "Thème", + "Native File Browser": "Explorateur de fichiers natif", + "Cache UI Draws": "Mettre l'UI en cache", + "Enabling may reduce GPU usage": "Activer cette option peut réduire l'utilisation du GPU", + "Show Asset Names": "Montrer les noms des assets", + "Restore": "Rétablir", + "Restore defaults?": "Rétablir les paramètres par défaut ?", + "Confirm": "Confirmer", + "Usage": "Utilisation", + "Undo Steps": "Étapes d'annulation", + "Paint Bleed": "Débordement de peinture", + "Stretch brush strokes on the uv map to prevent seams": "Étirer les coups de brosse sur la carte UV afin d'éviter les joints visibles", + "Dilate Radius": "Rayon de dilatation", + "Dilate baked textures to prevent seams": "Dilater les textures rendues pour éviter les joints visibles", + "Live Brush Preview": "Aperçu de la brosse en direct", + "Draw live brush preview in viewport": "Afficher un aperçu de la brosse en direct sur la vue 3D", + "3D Cursor": "Curseur 3D", + "Depth Reject": "Rejet par profondeur", + "Angle Reject": "Rejet par angle", + "Angle": "Angle", + "Pen": "Stylo", + "Brush Radius": "Rayon de la brosse", + "Brush Hardness": "Dureté de la brosse", + "Brush Opacity": "Opacité de la brosse", + "Viewport": "Vue 3D", + "Super Sample": "Superéchantillonage", + "VSync": "Synchronisation verticale", + "Voxel AO": "Occlusion ambiante voxel", + "Cone-traced AO and shadows": "Occlusion ambiente et ombres par cone tracing", + "Cone Offset": "Décalage des cônes", + "Aperture": "Ouverture", + "SSAO": "SSAO", + "Bloom": "Éblouissement", + "SSR": "SSR", + "Vignette": "Vignette", + "VXAO Ext": "Extension VXAO", + "Keymap": "Raccourcis clavier", + "Plugins": "Plugins", + "New Plugin": "Nouveau plugin", + "Edit in Text Editor": "Modifier dans l'éditeur de texte", + "Edit in Script Tab": "Modifier dans l'onglet Scripts", + "Browser": "Navigateur", + "Path": "Chemin", + "Brushes": "Brosses", + "Nodes": "Nœuds", + "Console": "Console", + "Clear": "Effacer", + "Fonts": "Polices", + "Import font file": "Importer le fichier de police", + "History": "Historique", + "2D View": "Vue 2D", + "Show 2D View": "Afficher la vue 2D", + "All": "Tout", + "Filter": "Filtrer", + "Mask": "Masque", + "Apply": "Appliquer", + "To Group": "Vers groupe", + "Move Up": "Déplacer vers le haut", + "Move Down": "Déplacer vers le bas", + "Merge Group": "Fusionner le groupe", + "Merge Down": "Fusionner vers le bas", + "Duplicate": "Dupliquer", + "Black Mask": "Masque noir", + "White Mask": "Masque blanc", + "Select Material": "Sélectionner matériau", + "Base Color": "Couleur de base", + "Opacity": "Opacité", + "Normal": "Normale", + "Occlusion": "Occlusion", + "Roughness": "Rugosité", + "Metallic": "Métallique", + "Height": "Hauteur", + "Emission": "Émission", + "Subsurface": "Sous-surface", + "Mix": "Mix", + "Darken": "Assombrir", + "Multiply": "Multiplier", + "Burn": "Brûler", + "Lighten": "Éclaircir", + "Screen": "Écran", + "Dodge": "Dodge", + "Overlay": "Superposer", + "Soft Light": "Lumière douce", + "Linear Light": "Lumière linéaire", + "Difference": "Différence", + "Subtract": "Soustraire", + "Divide": "Division", + "Hue": "Teinte", + "Saturation": "Saturation", + "Value": "Valeur", + "Blending": "Fusion", + "Shared": "Partagé", + "Res": "Rés.", + "Scale": "Échelle", + "UV Map": "Carte UV", + "Triplanar": "Triplane", + "TexCoord": "TexCoord", + "Materials": "Matériaux", + "Show Node Editor": "Afficher l'éditeur de nœuds", + "Meshes": "Maillages", + "Import mesh file": "Importer fichier de maillage", + "Tools...": "Outils...", + "Tools": "Outils", + "Flip Normals": "Inverser les normales", + "Calculate Normals": "Calculer les normales", + "Rotate X": "Rotation X", + "Rotate Y": "Rotation Y", + "Rotate Z": "Rotation Z", + "Scene": "Scène", + "Outliner": "Aperçu", + "Particles": "Particules", + "Manager": "Gestionnaire", + "Properties": "Propriétés", + "Location": "Endroit", + "Script": "Script", + "Run": "Exécuter", + "Textures": "Textures", + "Import texture file": "Importer fichier de texture", + "To Mask": "Vers masque", + "Drag and drop files here": "Glissez-déposez des fichiers ici", + "Traits": "Traits", + "File Browser": "Explorateur de fichiers", + "File": "Fichier", + "Save": "Enregistrer", + "Open": "Ouvrir", + "New Project...": "Nouveau projet...", + "Open...": "Ouvrir", + "Save As...": "Enregistrer sous...", + "Import Texture...": "Importer une texture...", + "Import Font...": "Importer une police...", + "Import Material...": "Importer un matériau...", + "Import Mesh...": "Importer un maillage...", + "Reimport Mesh": "Réimporter le maillage", + "Export Textures...": "Exporter les textures...", + "Export Mesh...": "Exporter le maillage...", + "Exit": "Quitter", + "Undo {step}": "Annuler {step}", + "Redo {step}": "Refaire {step}", + "Preferences...": "Paramètres...", + "Import Envmap...": "Importer carte d'environnement...", + "Distract Free": "Mode sans distraction", + "Environment": "Environnement", + "Light": "Lumière", + "Light Size": "Taille de la lumière", + "Displace": "Displace", + "Split View": "Vue partagée", + "Cull Backfaces": "Éliminer les faces arrière", + "Filter Textures": "Filtrer les textures", + "Wireframe": "Fil de fer", + "Texels": "Texels", + "Compass": "Boussole", + "Envmap": "Carte d'environnement", + "Blurred": "Floue", + "Viewport Color": "Couleur de fond", + "Water": "Eau", + "Render": "Rendu", + "Normal (Object)": "Normale (objet)", + "Material ID": "ID matériau", + "Object ID": "ID objet", + "Path Trace": "Traçage de chemin", + "Reset": "Réinitialiser", + "Front": "Avant", + "Back": "Arrière", + "Right": "Droite", + "Left": "Gauche", + "Top": "Dessus", + "Bottom": "Dessous", + "Orbit Left": "Orbite gauche", + "Orbit Right": "Orbite droite", + "Orbit Up": "Orbite haut", + "Orbit Down": "Orbite bas", + "Orbit Opposite": "Orbite opposée", + "Zoom In": "Zoomer", + "Zoom Out": "Dézoomer", + "Clip Start": "Début du clip", + "Clip End": "Fin du clip", + "FoV": "Champ de vision", + "Orbit": "Orbite", + "Rotate": "Rotation", + "Fly": "Voler", + "Perspective": "Perspective", + "Orthographic": "Orthographique", + "Camera Type": "Type de caméra", + "Manual": "Manuelle", + "Issue Tracker": "Traqueur de demandes", + "Report Bug": "Signaler un bug", + "Check for Updates...": "Vérifier les mises à jour...", + "Update": "Mettre à jour", + "Update is available!\nPlease visit armorpaint.org to download.": "Une mise à jour est disponible !\nVeuillez visiter armorpaint.org pour la télécharger.", + "You are up to date!": "Vous utilisez déjà la dernière version.", + "Unable to check for updates.\nPlease visit armorpaint.org.": "Impossible de vérifier les mises à jour.\nVeuillez visiter armorpaint.org.", + "About...": "À propos...", + "About": "À propos", + "Search": "Rechercher", + "Search for nodes": "Rechercher des nœuds", + "Text": "Texte", + "Brush": "Brosse", + "Eraser": "Gomme", + "Fill": "Remplissage", + "Decal": "Décalque", + "Clone": "Clonage", + "Blur": "Flou", + "Particle": "Particules", + "Bake": "Rendu", + "ColorID": "ColorID", + "Picker": "Sélectionneur", + "Hold ALT to set source": "Maintenez Alt pour définir la source", + "Gizmo": "Gizmo", + "Edit": "Modifier", + "Mode": "Mode", + "Camera": "Caméra", + "Help": "Aide", + "Paint": "Peindre", + "Picked Color": "Couleur sélectionnée", + "Color ID Map": "Carte de Color ID", + "Color ID": "Color ID", + "Base": "Base", + "Nor": "Nor", + "None": "Aucun", + "AO": "AO", + "Curvature": "Courbure", + "Derivative": "Dérivée", + "Position": "Position", + "Vertex Color": "Couleur vertex", + "Lightmap": "Carte de lumière", + "Bent Normal": "Bent Normal", + "Thickness": "Épaisseur", + "Up Axis": "Axe vers le haut", + "Axis": "Axe", + "Strength": "Force", + "Radius": "Rayon", + "Offset": "Décalage", + "Rays/pix:": "Rayons/pixel :", + "Rays/sec:": "Rayons/sec :", + "Smooth": "Adoucir", + "High Poly": "High Poly", + "Scale X": "Échelle X", + "Hardness": "Dureté", + "Project": "Projeter", + "Font": "Police", + "Face": "Face", + "Fill Mode": "Mode de remplissage", + "X-Ray": "Rayon X", + "Normal Map": "Carte de normales" +} diff --git a/Sources/arm/App.hx b/Sources/arm/App.hx index 81523adf..57bef4f5 100644 --- a/Sources/arm/App.hx +++ b/Sources/arm/App.hx @@ -1,5 +1,6 @@ package arm; +import haxe.io.Bytes; import kha.graphics2.truetype.StbTruetype; import kha.Image; import kha.Font; @@ -18,6 +19,7 @@ import arm.ui.UIBox; import arm.ui.UIFiles; import arm.ui.TabLayers; import arm.io.ImportAsset; +import arm.sys.File; import arm.sys.Path; import arm.util.RenderUtil; import arm.util.ViewportUtil; @@ -39,6 +41,10 @@ class App { static var appy = 0; static var winw = 0; static var winh = 0; + // The locale should be specified in ISO 639-1 format: https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes + // "system" is a special case that will use the system locale + public static var locale = "system"; + static var translations: Map = []; public static var uienabled = true; public static var isDragging = false; public static var isResizing = false; @@ -65,6 +71,12 @@ class App { public function new() { Log.init(); + + if (Config.raw.locale != null) { + locale = Config.raw.locale; + }; + loadTranslations(locale); + winw = System.windowWidth(); winh = System.windowHeight(); @@ -177,22 +189,67 @@ class App { }); } + // (Re)loads translations for the specified locale. + public static function loadTranslations(newLocale: String) { + if (newLocale == "system") { + locale = Krom.language(); + } + + // Check whether the requested or detected locale is available + if (getSupportedLocales().indexOf(newLocale) == -1) { + // Fall back to English + locale = "en"; + } + + if (locale == "en") { + // No translations to load, as source strings are in English. + // Clear existing translations if switching languages at runtime. + translations.clear(); + return; + } + + // Load the translation file + var translationJson = Bytes.ofData(Krom.loadBlob('data/locale/${locale}.json')).toString(); + var data: haxe.DynamicAccess = haxe.Json.parse(translationJson); + for (key => value in data) { + translations[Std.string(key)] = value; + } + } + + // Returns a list of supported locales (plus English and the automatically detected system locale). + public static function getSupportedLocales(): Array { + var locales = ["system", "en"]; + for (localeFilename in File.readDirectory(Path.data() + Path.sep + "locale")) { + // Trim the `.json` file extension from file names + locales.push(localeFilename.substr(0, -5)); + } + + return locales; + } + static function saveAndQuitCallback(save: Bool) { saveWindowRect(); if (save) Project.projectSave(true); else System.stop(); } - // Localize a string with the given placeholders replaced (format is `{placeholderName}`). - // TODO: Implement localization support. + // Localizes a string with the given placeholders replaced (format is `{placeholderName}`). + // If the string isn't available in the translation, this method will return the source English string. public static function tr(id: String, ?vars: Map): String { + var translation = id; + + // English is the source language + if (locale != "en" && translations != null && translations.exists(id)) { + translation = translations[id]; + } + if (vars != null) { for (key => value in vars) { - id = id.replace('{$key}', Std.string(value)); + translation = translation.replace('{$key}', Std.string(value)); } } - return id; + return translation; } public static function w(): Int { diff --git a/Sources/arm/Config.hx b/Sources/arm/Config.hx index f907d36f..13e88fa1 100644 --- a/Sources/arm/Config.hx +++ b/Sources/arm/Config.hx @@ -37,6 +37,7 @@ class Config { public static function init() { if (!configLoaded || raw == null) { raw = {}; + raw.locale = "system"; raw.window_mode = 0; raw.window_resizable = true; raw.window_minimizable = true; @@ -182,6 +183,7 @@ class Config { } typedef TConfig = { + @:optional var locale: String; // ISO 639-1 locale code or "system" to use the system locale automatically @:optional var window_mode: Null; // window, fullscreen @:optional var window_w: Null; @:optional var window_h: Null; diff --git a/Sources/arm/ui/BoxPreferences.hx b/Sources/arm/ui/BoxPreferences.hx index 5e735099..a7cb8864 100644 --- a/Sources/arm/ui/BoxPreferences.hx +++ b/Sources/arm/ui/BoxPreferences.hx @@ -25,6 +25,17 @@ class BoxPreferences { UIBox.showCustom(function(ui: Zui) { if (ui.tab(htab, tr("Interface"), true)) { + var locales = App.getSupportedLocales(); + var localeHandle = Id.handle({position: locales.indexOf(App.locale)}); + ui.combo(localeHandle, locales, tr("Language (restart to apply changes)"), true); + if (localeHandle.changed) { + var localeCode = locales[localeHandle.position]; + Config.raw.locale = localeCode; + Config.save(); + App.loadTranslations(localeCode); + UITrait.inst.tagUIRedraw(); + } + var hscale = Id.handle({value: Config.raw.window_scale}); ui.slider(hscale, tr("UI Scale"), 1.0, 4.0, false, 10); if (!hscale.changed && UITrait.inst.hscaleWasChanged) { diff --git a/extract_locales.py b/extract_locales.py new file mode 100755 index 00000000..5894401b --- /dev/null +++ b/extract_locales.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +# Extracts localizable strings from a set of source files and writes them to JSON files. +# This script can create new translations or update existing ones. +# Usage: ./extract_locales.py + +import fnmatch +import json +import os +import sys +from typing import Any, Dict, IO, List +from typing_extensions import Final + +unique_str: List[str] = [] + + +def process_file(f: IO[Any], fname: str, template_data: Dict[str, str]) -> None: + line = f.readline() + while line: + patterns = ['tr("'] + idx = 0 + pos = 0 + while pos >= 0: + pos = line.find(patterns[idx], pos) + if pos == -1: + if idx < len(patterns) - 1: + idx += 1 + pos = 0 + continue + pos += len(patterns[idx]) + + msg = "" + while pos < len(line) and (line[pos] != '"' or line[pos - 1] == "\\"): + msg += line[pos] + pos += 1 + + # Only add each unique string once. + if msg not in unique_str: + # Empty keys are considered untranslated by the i18n library. + # Fix newlines so they're not escaped anymore. Otherwise, + # they won't match the source strings. + template_data[msg.replace("\\n", "\n")] = "" + unique_str.append(msg) + + line = f.readline() + + +def main() -> None: + if len(sys.argv) != 2: + sys.exit(f"Usage: {sys.argv[0]} ") + + # Change to the directory where the script is located, + # so that the script can be run from any location. + os.chdir(os.path.dirname(os.path.realpath(__file__))) + + output_path: Final = f"Assets/locale/{sys.argv[1]}.json" + + if not os.path.exists("Sources"): + sys.exit( + "ERROR: Couldn't find the Sources folder in the folder where this script is located." + ) + + matches: List[str] = [] + for root, dirnames, filenames in os.walk("Sources"): + dirnames[:] = [d for d in dirnames] + for filename in fnmatch.filter(filenames, "*.hx"): + matches.append(os.path.join(root, filename)) + matches.sort() + + template_data: Dict[str, str] = {} + for filename in matches: + with open(filename, "r") as f: + # Read source files for localizable strings. + process_file(f, filename, template_data) + + if os.path.exists(output_path): + print(f'Updating the translation at "{output_path}"...') + with open(output_path, "r") as f: + existing_data = json.loads(f.read()) + # Remove obsolete translations (i.e. translations that are no longer + # present in the generated data). + existing_data_no_obsolete = { + key: value + for key, value in existing_data.items() + if key in template_data + } + # Merge existing data with the generated template data (so we keep + # existing translations). + template_data = {**template_data, **existing_data_no_obsolete} + + with open(output_path, "w") as f: + json.dump(template_data, f, ensure_ascii=False, indent=4) + else: + print(f'Creating new translation template at "{output_path}"...') + with open(output_path, "w") as f: + json.dump(template_data, f, ensure_ascii=False, indent=4) + + +if __name__ == "__main__": + main() diff --git a/khafile.js b/khafile.js index b5731e64..fec10f42 100644 --- a/khafile.js +++ b/khafile.js @@ -8,6 +8,7 @@ project.addLibrary("zui"); project.addShaders("Shaders/common/*.glsl", { noembed: false}); project.addAssets("Assets/common/*", { notinlist: true, destination: "data/{name}" }); project.addAssets("Assets/fonts/*", { notinlist: true, destination: "data/{name}" }); +project.addAssets("Assets/locale/*", { notinlist: true, destination: "data/locale/{name}" }); project.addAssets("Assets/licenses/*", { notinlist: true, destination: "data/licenses/{name}" }); project.addAssets("Assets/plugins/*", { notinlist: true, destination: "data/plugins/{name}" }); project.addAssets("Assets/themes/*", { notinlist: true, destination: "data/themes/{name}" });