
Onze langues, un seul bouton
Personne ne livre 11 langues dès le premier jour. Je l’ai fait quand même.
La plupart des applications ajoutent la localisation après coup. Le produit sort en anglais, les utilisateurs réclament d’autres langues, on planifie un sprint, et quelqu’un passe deux semaines à traquer des chaînes codées en dur au fond des fichiers de vues. Chaque chaîne retrouvée est une qui a échappé à la relecture. La plupart ne sont découvertes que le jour où un locuteur natif signale un bogue. C’est l’équivalent en localisation du « on écrira les tests plus tard ».
J’ai décidé de m’y prendre dès le départ, avant la première compilation publique. L’internationalisation (i18n) et la localisation (l10n) étaient des contraintes de conception au même titre que l’accessibilité. La logique est imparable : ajouter la localisation après coup, c’est faire le travail deux fois. On retrouve les chaînes, on les extrait, on vérifie que rien n’a cassé.
Le système de chaînes
La plupart des applications localisées exigent un redémarrage pour changer de langue. L’utilisateur modifie un réglage, quitte, relance, et espère que l’interface aura suivi. Renala change instantanément. On choisit une langue dans les préférences, l’interface se met à jour sur place. Pas de redémarrage, pas d’état résiduel.
Le mécanisme sous-jacent : String(localized:bundle:) de Swift accepte un paramètre Bundle. En passant un Bundle personnalisé chargé pour la langue cible, les chaînes sont résolues depuis la table de localisation de ce bundle plutôt que depuis celle du système. Le String(localized:) standard résout toujours depuis le bundle principal, dans la langue de macOS. Pas de paramètre bundle, pas de changement de langue. L’interface reste figée dans la langue du système, quoi que l’utilisateur ait choisi.
LocalizationManager
Le mécanisme de bascule en direct réside dans LocalizationManager, une classe @Observable isolée sur @MainActor. Quand l’utilisateur change de langue, elle charge un nouveau Bundle et met à jour une propriété publiée. Chaque vue qui lit LocalizationManager se redessine automatiquement via le suivi d’observation de SwiftUI. Pas de redémarrage, pas de rafraîchissement de fenêtre, pas d’invalidation manuelle.
Le motif dans les vues : on récupère le manager depuis l’environnement, on appelle String(localized:bundle:) avec le bundle actif du manager. L’observation fait le reste.
Le code en dehors des vues utilise LocalizationManager.shared, le singleton sur @MainActor. Éléments de menu, chaînes d’accessibilité, tout ce qui vit hors de la hiérarchie de vues. VoiceOver lit les labels d’accessibilité à voix haute ; « Analyse terminée, 3,2 Go examinés » doit être correct en arabe, pas seulement en anglais.
flowchart TD
A["L'utilisateur choisit une langue<br/>dans les Préférences"] --> B["LocalizationManager<br/>.currentLanguage change"]
B --> C["Nouveau Bundle chargé<br/>pour la langue cible"]
C --> D["@Observable propage<br/>le changement à toutes les vues"]
D --> E["Les vues se redessinent avec<br/>String(localized:bundle:)"]
E --> F["L'interface reflète la nouvelle locale<br/>sans redémarrage"]
xcstrings
Apple a introduit .xcstrings en remplacement moderne des fichiers .strings. Le format repose sur JSON. Un seul fichier regroupe toutes les langues pour une table de chaînes donnée. C’est un progrès net par rapport à l’ancienne approche, où chaque langue vivait dans un répertoire .lproj distinct et où la synchronisation était manuelle et fragile.
Avec .xcstrings, ajouter une nouvelle chaîne revient à ajouter une entrée dans un seul fichier. L’éditeur Xcode affiche toutes les traductions côté à côté. Les traductions manquantes sont signalées. Le fichier produit des diffs propres en contrôle de version.
Le pipeline de traduction
Les fichiers .xcstrings ne sont pas édités directement. Ils sont générés.
Scripts/Localization/generate_translations.py est la source de vérité. Il contient chaque chaîne et sa traduction dans les 11 langues. L’exécuter régénère les fichiers .xcstrings. Les données de traduction restent ainsi dans un format lisible, facile à comparer et à modifier sans Xcode. Le processus de traduction peut aussi être entièrement scripté : on met à jour le fichier Python, on lance le générateur, on commite le résultat.
flowchart LR
PY["generate_translations.py<br/>(source de vérité)"] -->|"python3"| XC["Fichiers .xcstrings<br/>(sortie générée)"]
XC --> APP["Bundle de l'application<br/>tables de localisation"]
PY -->|"lint"| LINT["lint_translations.py<br/>cohérence des clés"]
PY -->|"pseudo"| PSEUDO["pseudo_localization_check.py<br/>intégrité des placeholders"]
PY -->|"complétude"| COMP["check_completeness.py<br/>couverture par langue"]
Ajouter une nouvelle chaîne visible par l’utilisateur impose de l’ajouter d’abord au script Python. Modifier une chaîne impose de mettre à jour le script et toutes les traductions concernées. Les fichiers .xcstrings sont des sorties, pas des entrées.
RTL et arabe
L’arabe s’écrit de droite à gauche. Quand la langue active est l’arabe, toute la mise en page se retourne : la navigation va de droite à gauche, les chevrons pointent dans l’autre sens, l’alignement du texte s’inverse.
SwiftUI gère le gros du travail automatiquement via l’environnement layoutDirection. Là où ça se corse, c’est la direction des icônes. chevron.left pour revenir en arrière ? Faux en arabe. Le sens de lecture est inversé, donc « retour » pointe vers la droite.
La solution : chevron.forward et chevron.backward. Sémantique, pas directionnel. SwiftUI les retourne automatiquement en fonction de la direction de mise en page. Si c’est bien fait, les utilisateurs arabes voient des flèches de navigation qui pointent dans le bon sens. Si c’est mal fait, l’application est subtilement cassée pour 400 millions de locuteurs natifs, dont aucun ne viendra déposer un rapport de bogue dans une langue que vous savez lire.
graph LR
subgraph GaucheDroite["Gauche à droite (anglais, français...)"]
L1["← retour"] --- L2["contenu"] --- L3["suivant →"]
end
subgraph DroiteGauche["Droite à gauche (arabe)"]
R1["suivant ←"] --- R2["محتوى"] --- R3["retour →"]
end
GaucheDroite -.->|"chevron.forward / .backward<br/>retournement automatique"| DroiteGauche
Formes plurielles en arabe
L’anglais connaît deux catégories de pluriel : un élément, et tout le reste. L’arabe en connaît six : zéro, un, deux, quelques-uns, beaucoup, autre. Ce sont les catégories CLDR, et elles ne sont pas là pour faire joli.
« 1 fichier » et « 2 fichiers » sont des mots différents en arabe. Tout comme « 11 fichiers » et « 100 fichiers ». Les règles sont précises, cohérentes en interne, et de quoi donner le tournis à un francophone.
xcstrings prend en charge les six catégories CLDR par langue. Il faut renseigner les six pour chaque chaîne arabe qui implique un compteur. Si on se contente de deux, l’arabe est grammaticalement faux pour la majorité des nombres. C’est le genre d’erreur invisible pour un non-arabophone en relecture de code, et immédiatement flagrante pour tout arabophone qui ouvre l’application.
L’implémentation utilise l’interpolation d’entiers plutôt que .formatted() pour les chaînes de comptage. .formatted() applique un formatage numérique propre à la locale, ce qui peut produire des résultats inattendus pour la sélection de la règle de pluriel. Un entier passé directement laisse la machinerie des pluriels fonctionner correctement.
Les 11 langues prises en charge et leur nombre de catégories de pluriel CLDR :
| Langue | Écriture | Catégories de pluriel |
|---|---|---|
| Anglais | Latin | 2 |
| Français | Latin | 2 |
| Allemand | Latin | 2 |
| Espagnol | Latin | 2 |
| Italien | Latin | 2 |
| Portugais | Latin | 2 |
| Néerlandais | Latin | 2 |
| Japonais | Hiragana/Katakana/Kanji | 1 |
| Chinois simplifié | Han | 1 |
| Arabe | Arabe | 6 |
| Coréen | Hangul | 1 |
Les six catégories de l’arabe (zéro, un, deux, quelques-uns, beaucoup, autre) expliquent pourquoi cette langue demande le plus de soin dans le pipeline de traduction. Chaque chaîne de comptage nécessite six formes distinctes, pas deux.
Vérifications CI
Trois vérifications se déclenchent à chaque commit qui touche aux fichiers de localisation.
Le lint de traduction contrôle la cohérence des clés : chaque clé présente dans la table anglaise doit exister dans toutes les autres tables. Les clés présentes dans une traduction mais absentes de l’anglais sont signalées comme orphelines.
La vérification de pseudo-localisation contrôle la préservation des placeholders. Une chaîne comme « Scanning %d files » contient un placeholder de format. La version traduite doit conserver le %d. Une traduction qui le supprime ou le déplace provoquera un plantage à l’exécution au moment du formatage.
La vérification de complétude mesure la couverture : quel pourcentage de clés dispose d’une traduction dans chaque langue. Une langue qui passe sous le seuil bloque la compilation.
flowchart TD
COMMIT["git push"] --> CI["GitHub Actions"]
CI --> LINT["Lint de traduction<br/>cohérence des clés"]
CI --> PSEUDO["Pseudo-localisation<br/>vérification des placeholders"]
CI --> COMP["Vérification de complétude<br/>seuil de couverture"]
LINT -->|"clé orpheline ?"| FAIL["Compilation échouée"]
PSEUDO -->|"%d manquant ?"| FAIL
COMP -->|"< seuil ?"| FAIL
LINT & PSEUDO & COMP -->|"tout passe"| PASS["Compilation réussie"]
Ces vérifications tournent en CI via GitHub Actions. Elles interceptent les modes de défaillance courants avant qu’ils n’atteignent la production : clés manquantes, placeholders cassés, langues incomplètes.
Le prix à payer
Chaque chaîne visible par l’utilisateur s’écrit deux fois : une fois en anglais dans le code, une fois dans le script de traduction avec les 11 variantes. La discipline est réelle. Il n’y a pas de raccourci où l’on écrit en anglais en se promettant d’ajouter les traductions plus tard.
Le retour sur investissement : l’application a été lancée avec l’arabe et le japonais fonctionnels, disposition RTL et formes de pluriel à six catégories incluses. Pas de chasse aux chaînes, pas de texte de repli non traduit, pas de sprint de localisation post-lancement.
L’infrastructure est le vrai investissement. Une fois le pipeline en place, ajouter une nouvelle chaîne prend quelques minutes. Ajouter une nouvelle langue prend quelques heures. Le meilleur travail de localisation, c’est celui qu’on fait en amont.
Quiconque construit une application en se disant que la localisation peut attendre doit savoir : plus on attend, plus les chaînes s’accumulent dans les vues, et plus la migration coûte cher.
Références
- Apple: Localizing and varying text with a String Catalog: xcstrings format and Xcode editor documentation.
- WWDC 2023: Discover String Catalogs: Introduction to the xcstrings format and migration from .strings files.
- Unicode CLDR plural rules: Specification for all CLDR plural categories, including Arabic’s six forms.
- Apple HIG: Localization: Human Interface Guidelines on localizing Mac and iOS apps.
- Supporting right-to-left languages in SwiftUI: Layout direction, mirroring, and RTL-aware symbol usage.