Onze langues, un seul bouton

Apple part du principe qu’on garde une seule langue par session. L’API Bundle n’est pas de cet avis.

Toutes les applications Mac localisées que j’ai utilisées exigent un redémarrage pour changer de langue. Le framework d’Apple part de ce principe : le système choisit une langue au lancement, charge le bundle correspondant, et c’est cette langue jusqu’au prochain lancement.

Renala change instantanément. On choisit une langue dans les préférences, l’interface se met à jour sur place. Ce n’est pas le comportement par défaut d’Apple, c’est un choix architectural délibéré : un point d’extension que le framework fournit sans le mettre en avant.

J’ai anticipé ce choix. L’internationalisation (i18n) et la localisation (l10n) étaient des contraintes de conception au même titre que l’accessibilité. Ajouter la localisation après coup, c’est faire le travail deux fois : retrouver les chaînes, les extraire, vérifier que rien n’a cassé. Le faire dès le départ exige de la rigueur. Le greffer après coup coûte bien plus cher.

Le système de chaînes

Le mécanisme derrière la bascule instantanée : les API de chaînes localisées de Swift permettent de contrôler où s’effectue la résolution. Si on passe à String(localized:bundle:) un Bundle personnalisé (le paquetage de ressources d’Apple pour une langue donnée) chargé pour la langue cible, la chaîne vient de la table de localisation de ce bundle plutôt que de la sélection par défaut. Même principe que le rechargement d’un fichier de traduction dans une application web, sauf que le framework résout les chaînes via le bundle au moment de l’appel.

La localisation standard d’Apple fixe la langue au lancement et n’en change plus. La permutation de Bundle contourne cette limite : on charge un autre paquetage de ressources à l’exécution.

Les valeurs formatées relèvent d’un autre sujet : l’application maintient aussi l’environnement de locale de SwiftUI en phase avec la langue choisie, pour que les formats de date, les séparateurs de nombres et les symboles monétaires suivent le même choix.

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 un état observable. Les vues qui lisent cet état sont invalidées via le suivi d’observation de SwiftUI : ni redémarrage, ni rafraîchissement de fenêtre, ni 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, et on laisse les environnements de locale et de direction de mise en page suivre le même choix. 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 mais reste lié à l’interface. 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.

Choisissez une langue : l’interface se met à jour instantanément via le même pipeline Bundle-swap + @Observable que Renala utilise. L’arabe bascule en mise en page droite-à-gauche, chevron de retour compris.

xcstrings

Apple a introduit .xcstrings comme format moderne d’édition pour les chaînes localisées. Le format est JSON : un seul fichier regroupe toutes les langues pour une table de chaînes donnée. Un net progrès par rapport au jonglage manuel entre fichiers .strings, même si la résolution à l’exécution repose toujours sur la mécanique habituelle du bundle.

Avec .xcstrings, ajouter une nouvelle chaîne revient à ajouter une entrée dans un seul fichier : l’éditeur Xcode affiche toutes les traductions côte à côte, signale les traductions manquantes, et les diffs sont plus propres qu’avec l’ancienne approche éclatée.

L’éditeur convient pour une poignée de chaînes. À l’échelle de Renala, avec 11 langues et des centaines de clés, les fichiers .xcstrings ne sont pas édités directement.

Le pipeline de traduction

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é : mettre à jour le fichier Python, lancer le générateur, commiter le résultat.

flowchart LR
    accTitle: Translation generation and validation pipeline
    accDescr: A Python script generates .xcstrings files for the app bundle. Three validation tools run against the script output: a translation lint for quality rules, a pseudo-localization check for placeholder integrity, and a completeness check for coverage per language.
    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/>règles de qualité"]
    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 ; la modifier impose de mettre à jour le script et toutes les traductions concernées. Les fichiers .xcstrings sont des sorties, pas des entrées. La mémoire de traduction d’Xcode, l’export xcloc (format d’échange de localisation Xcode) et le workflow « mark as reviewed » ne servent plus : c’est le prix d’un pipeline entièrement diffable et scriptable.

Le pipeline gère le texte. Il ne gère pas la mise en page.

RTL et arabe

L’arabe s’écrit de droite à gauche. Quand la langue active est l’arabe, SwiftUI dérive layoutDirection de la locale. La navigation s’inverse, les chevrons changent de côté, et les parties de l’interface qui suivent la direction de mise en page se retournent avec elle.

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, où le sens de lecture est inversé : « retour » pointe vers la droite.

La solution : chevron.forward et chevron.backward. Sémantique, pas directionnel. SF Symbols affiche le bon glyphe en fonction de la direction de mise en page. Si c’est bien fait, les utilisateurs arabes voient des flèches qui pointent dans le bon sens. Si c’est mal fait, l’application est subtilement cassée pour 400 millions de locuteurs, dont aucun ne viendra signaler le problème dans une langue qu’on sait lire.

L’interactive ci-dessus le montre en direct : basculez vers l’arabe et observez le chevron passer de à . C’est chevron.forward et chevron.backward en action, résolus par SF Symbols en fonction de la direction de mise en page.

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 (Common Locale Data Repository). CLDR est la base de données de règles de locale maintenue par le consortium Unicode : la source commune sur laquelle Apple, Android et tous les navigateurs majeurs s’appuient pour décider du comportement des nombres, des dates et des pluriels dans chaque langue. Ces catégories ne sont pas là pour faire joli.

« 1 fichier » et « 2 fichiers » ne se disent pas de la même façon en arabe, pas plus que « 11 fichiers » et « 100 fichiers ». Les règles sont précises et cohérentes en interne, mais de quoi donner le tournis à un francophone.

xcstrings prend en charge les catégories plurielles de CLDR. Pour les chaînes arabes qui dépendent vraiment du nombre, il faut avoir accès aux six. Si on se contente de one/other, l’arabe devient faux sur une large plage de nombres. C’est le genre d’erreur invisible en relecture de code pour un non-arabophone, et immédiatement flagrante pour un arabophone.

Un piège plus subtil se cache dans l’ordre des opérations. Si on formate un nombre en chaîne d’affichage (« 3,2 Go ») avant de le passer au système de localisation, la logique de pluriel ne voit jamais le nombre sous-jacent. En anglais, c’est sans conséquence : « 1 file » et « 42 files » ne demandent que one/other. En arabe, la grammaire est fausse pour la plupart des valeurs. La solution : passer la valeur numérique elle-même dans la chaîne localisée, via l’interpolation de String(localized:), pour que la logique de pluriel puisse s’appuyer directement sur le nombre.

L’application est aujourd’hui disponible en 11 langues, chacune avec son propre jeu de catégories de pluriel CLDR :1

Déplacez le curseur pour choisir un nombre. Chaque ligne montre quelle catégorie de pluriel CLDR s’active pour cette langue. L’interactive montre les règles de pluriel pour les entiers. Le français, l’espagnol, l’italien et le portugais possèdent aussi une catégorie « many » pour le formatage décimal compact (par ex. « 1 million »), non représentée ici.

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 clé de comptage dont la forme grammaticale varie selon le nombre doit pouvoir s’appuyer sur ces catégories, pas seulement sur one/other.

Vérifications CI

La revue de code ne détecte pas les bugs de localisation. Le relecteur lit l’anglais. Le bug est en arabe. Trois vérifications automatisées prennent le relais sur chaque push et pull request.

Le lint de traduction applique quelques règles de qualité qui m’ont évité de vraies régressions : clés obsolètes, remplacements obligatoires, couverture du glossaire et quelques formulations propres à certaines langues qu’on laisse facilement passer en revue.

La vérification de pseudo-localisation (injection de fausses traductions pour tester l’interface) contrôle la préservation des placeholders : une chaîne comme « %lld files » contient un placeholder que la version traduite doit conserver. Si ce placeholder disparaît ou se retrouve déplacé, le résultat va du texte visiblement faux à une chaîne formatée cassée au point d’appel.

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
    accTitle: CI checks for translation quality
    accDescr: On every git push, GitHub Actions runs three checks in parallel: translation lint for quality rules, pseudo-localization for missing placeholders, and completeness against a coverage threshold. Any failure blocks the build.
    COMMIT["git push"] --> CI["GitHub Actions"]
    CI --> LINT["Lint de traduction<br/>règles de qualité"]
    CI --> PSEUDO["Pseudo-localisation<br/>vérification des placeholders"]
    CI --> COMP["Vérification de complétude<br/>seuil de couverture"]
    LINT -->|"règle violée ?"| FAIL["Compilation échouée"]
    PSEUDO -->|"placeholder manquant ?"| FAIL
    COMP -->|"< seuil ?"| FAIL
    LINT & PSEUDO & COMP -->|"tout passe"| PASS["Compilation réussie"]

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 : pas de raccourci où l’on écrit en anglais en se promettant d’ajouter les traductions plus tard. Quand une chaîne change, les 11 traductions doivent suivre dans le même commit. Le script Python rend le travail visible, pas indolore.

Le résultat est là : l’arabe et le japonais fonctionnent aux côtés du reste de l’interface, avec la disposition RTL et la logique de pluriel dont l’arabe a besoin. Plus besoin de chasser les chaînes, de combler des traductions manquantes, ni de planifier un sprint de localisation post-lancement.

L’infrastructure est le vrai investissement : le pipeline, les vérifications CI, le script de génération. Mais une fois cette infrastructure en place, le coût marginal s’effondre. Une nouvelle chaîne, c’est l’affaire de quelques minutes ; une nouvelle langue, de quelques heures. Le meilleur travail de localisation, c’est celui qu’on fait en amont.

Si on construit une application en se disant que la localisation peut attendre : plus on attend, plus les chaînes s’accumulent dans les vues, et plus la migration coûte cher.

Références

Footnotes

  1. Nombre de catégories de pluriel selon la spécification Unicode CLDR v48. Le français, l’espagnol, l’italien et le portugais affichent 3 catégories parce que CLDR a ajouté une forme « many » pour le formatage décimal compact (par ex. « 1 million »). Pour les nombres entiers classiques comme les comptages de fichiers, ces langues utilisent en pratique 2 catégories (one/other). Les 4 catégories du russe (one, few, many, other) s’appliquent à tous les entiers.