Action disabled: revisions

Script bash internationalisé

Ou comment traduire un script bash.


Bash possède un mécanisme interne d'internationalisation. C'est pas bien difficile à trouver. Par exemple y'a ce how-to, mais il est en anglais, et puis après le forum part en quenouille sur une sombre histoire de locale par défaut…

En fait, la base est simple; Les chaines entre $" et " seront candidates à la traduction. On peut donc écrire des choses comme ça :

essai-001
echo $"Hello World !"

Et si on a fait tout ce qui est nécessaire pour la traduction, on aura :

Bonjour Monde !

Le principe

Pour deviner quel langue utiliser, bash consulte d'abord la variable d'environnement LANGUAGE. Si elle n'est pas définie, il consulte la variable d'environnement LANG. Les détails sont dans le how-to de Debian sur l'utilisation des applications en français. Pour un francophone, cette variable devrait être fixée à quelque chose qui commence par fr. J'ai la valeur fr_FR.UTF-8 car j'utilise le français tel qu'il est parlé en France. Les francophones d'autres régions du monde auront probablement autre chose, mais toujours commençant par fr.

bash cherche alors à mettre la main sur le catalogue des chaines traduites. Ainsi lorsqu'il rencontre une chaine de la forme $"chaine", il va tenter de trouver une correspondance dans le catalogue. Le catalogue est situé dans une arborescence dont le nom est dérivé de la valeur de la variable LANGUAGE ou LANG. À partir d'un répertoire de base, bash recherche dans un sous répertoire fr*/LC_MESSAGES/ s'il trouve un fichier catalogue. l'exemple ci dessous montre comment sont répartis les catalogues de chaines pour les langues tchèque de République tchèque (cz_CZ), anglais de Nouvelle Zélande (en_NZ) français de Belgique (fr_BE) et français de France (fr_FR).

locale
├── cz_CZ
│   └── LC_MESSAGES
│       └── multilingue.mo
├── en_NZ
│   └── LC_MESSAGES
│       └── multilingue.mo
├── fr_BE
│   └── LC_MESSAGES
│       └── multilingue.mo
└── fr_FR
    └── LC_MESSAGES
        └── multilingue.mo

Notez au passage que bash se donne vraiment beaucoup de mal pour trouver le catalogue de chaines traduites. Si on fait exprès de le taquiner un peu et qu'on regarde avec strace ce qu'il essaie de faire, on peut voir ceci :

open(".../locale/fr_FR.UTF-8/LC_MESSAGES/multilingue.mo", O_RDONLY) = -1 ENOENT (No such file or directory)
open(".../locale/fr_FR.utf8/LC_MESSAGES/multilingue.mo", O_RDONLY) = -1 ENOENT (No such file or directory)
open(".../locale/fr_FR/LC_MESSAGES/multilingue.mo", O_RDONLY) = -1 ENOENT (No such file or directory)
open(".../locale/fr.UTF-8/LC_MESSAGES/multilingue.mo", O_RDONLY) = -1 ENOENT (No such file or directory)
open(".../locale/fr.utf8/LC_MESSAGES/multilingue.mo", O_RDONLY) = -1 ENOENT (No such file or directory)
open(".../locale/fr/LC_MESSAGES/multilingue.mo", O_RDONLY) = 3

Un exemple

Pour comprendre, prenons un petit script simple appelé multilingue :

multilingue
#! /bin/bash
 
TEXTDOMAIN=multilingue
TEXTDOMAINDIR="$PWD/locale"
 
echo $"Hello wonderful World !"
read -p $"What is your name ? " nom
tput smso
echo $"Welcome $nom"
tput rmso

La variable TEXTDOMAINDIR indique le répertoire de base où on trouve toutes les traductions dans toutes les langues et variantes. Quant à TEXTDOMAIN, elle indique quel catalogue de chaines doit être utilisé. J'utilise ici un catalogue du nom du script. Le répertoire de base des traductions est, pour l'instant, le sous répertoire locale du répertoire courant. Si on installe le script dans /usr/bin, ce répertoire de base pourrait être /usr/share/locale.

Il faut maintenant traduire.

traduire en vitesse

Demandons à bash de nous donner les chaines à traduire qui sont dans notre script, et plaçons les dans un fichier .po que nous traduirons, puis transformerons en fichier .mo

langue=fr_FR
mkdir -p locale/$langue/LC_MESSAGES
bash --dump-po-strings multilingue >multilingue.po
editor multilingue.po
msgfmt -o locale/$langue/LC_MESSAGES/multilingue.mo multilingue.po

traduire le fichier .po est très simple, il est composé de paires de lignes msgid, msgstr. msgid contenant le texte d'origine auquel il faut faire correspondre une traduction dans une ligne msgstr. Voilà pour notre exemple, les textes qu'on pourrait utiliser.

multilingue.po
#: multilingue:6
msgid "Hello wonderful World !"
msgstr "Bonjour Monde merveilleux !"
#: multilingue:7
msgid "What is your name ? "
msgstr "Comment vous appelez vous ? "
#: multilingue:9
msgid "Welcome $nom"
msgstr "$nom, soyez le bienvenu."

Maintenant, si on execute le script, on a ceci :

schplurtz@grumpfs (0) $ ./multilingue
Bonjour Monde mervilleux !
Comment vous appelez vous ? Schplurtz
Schplurtz, soyez le bienvenu.
schplurtz@grumpfs (0) $ 

C'est pas mal, non ?

Traduire plus sérieusement

Ben pourquoi, c'était pas sérieux ? Disons que c'était maladroit. D'abord, un fichier .po peut contenir plus d'informations que les seules chaines traduites, le nom et l'adresse de courriel du traducteur pour ne citer qu'eux. Ensuite, remarquons que notre procédure n'est pas adaptée aux changements qui peuvent intervenir dans le script. Si on rajoute une chaine dans le script, ou si on en change une, il faudrait tout retraduire puisque la méthode bash --dump... >toto.po va écrabouiller le précédent travail de traduction. Notons encore que si une chaine apparait plusieurs fois dans le script, elle se trouvera aussi plusieurs fois dans le fichier à traduire, et ce sera alors à vous d'éliminer les doublons de ce fichier, sinon la génération du catalogue échouera. Voilà, ces réponses suffisent ? pas tout a fait ? Allez, cerise sur la gâteau, si les modifications dans le script sont suffisamment légères, alors on peut conserver automatiquement l'ancienne traduction dans le fichier .po –celui qu'on édite pour y inclure les traductions– tout en ayant un commentaire qui indique qu'il y a eu un léger changement. Ça allège le travail de traduction.

La meilleure méthode est donc

  1. utiliser un fichier intermédiaire .pot issu des chaines du script et obtenu par xgettext,
  2. initialiser le fichier de chaines .po,
  3. traduire avec poedit,
  4. ça crée automatiuement le fichier .mo
    En cas de changement dans le script, alors on effectue les actions suivantes,
  5. recréer le fichier intermédiaire .pot,
  6. recréer le fichier .po en conservant autant que possible les précédentes traductions,
  7. générer un nouveau fichier .mo.

Voilà les commandes :

langue=fr_FR
mkdir -p locale/$langue/LC_MESSAGES
bash --dump-po-strings multilingue | xgettext -L PO -o multilingue.pot -
msginit -l  $langue -i multilingue.pot -o locale/$langue/LC_MESSAGES/multilingue.po
poedit locale/$langue/LC_MESSAGES/multilingue.po
# ca crée le fichier locale/$langue/LC_MESSAGES/multilingue.mo  

Après modification du script il faudra passer les commandes suivantes :

langue=fr_FR
bash --dump-po-strings multilingue | xgettext -L PO -o multilingue.pot -
msgmerge -U locale/$langue/LC_MESSAGES/multilingue.po multilingue.pot
poedit locale/$langue/LC_MESSAGES/multilingue.po

Il ne peut y en avoir qu'un

Un quoi, en fait ? Un niveau de déréférencement1) de variable.

Non, un quoi j'ai dit.

Ok, voilà un exemple. La chaine contient des variables mais ne cela n'a pas d'importance, et ça fonctionne on l'a déjà vu.

essai-002
firstname=toto
secondname='le heros'
echo $"Hello $firstname $secondname !"

Maintenant, un peu plus compliqué, on a plein de chaines de résultats dans un tableau, et on veut afficher le statut en fonction d'une autre valeur.

essai-003
status=(
  $"Perfect",
  $"almost correct",
  $"could be better",
  $"all wrong"
)
name=toto
result=3
echo  "$name ${status[$result]}"

Jusque là, on n'a qu'un niveau de variable dans la chaine à afficher. $name et ${status[$result]} sont au même niveau, donc tout va bien.

Imaginons maintenant qu'on veuille faire rentrer $name dans les chaines du tableau, ce qui permettra aux traducteurs de choisir la place que doit occuper le nom dans la phrase. Peut être au début dans une langue ("$name blablabla"), et à la fin dans une autre langue ("gnak gnak gnik $name"). Emporté par notre élan, on écrit le script naïf suivant :

essai-004
status=(
  $"$name Perfect",
  $"$name almost correct",
  $"$name could be better",
  $"$name all wrong"
)
name=toto
result=3
echo "${status[$result]}"

Et là, cata ! Rien ne va plus. $name semble complètement ignoré, disparu, effacé…

Ben oui, mais normal quoi. Si on prend pas à pas ce qui se passe lors de l'affichage, voilà ce qu'on obtient :

  • bash voit une chaine "${status[$result]}" qui n'est pas une chaine à traduire,
  • procède à l'expansion normale des variables et remplace "${status[$result]}" par la valeur idoine du tableau status; cette valeur est une traduction déjà réalisée,
  • et il s'arrête là, content de lui.

$name dans l'histoire ? il n'en est pas question au moment où on le souhaiterai. En fait, $name a été évalué bien avant, lors de la définition du tableau:

  • bash tombe sur la définition du tableau,
  • trouve des chaines à traduire
  • recherche un équivalent dans le catalogue,
  • il trouve des chaines du genre "parfait $name", "presque bien $name", "$name : tu peux faire mieux", "tout faux $name",
  • remplace les chaines d'origine par leur traduction,
  • puis, au moment où il exécute la commande, c'est à dire l'affectation au tableau,
    fait le remplacement de variable $name'' car à ce moment $name n'est pas encore définie…
  • range ce résultat dans la tableau et passe à la suite.

Si on essaie frénétiquement ceci :

echo $"${status[$result]}"

on a le même résultat, mais pour plus cher : bash voit une chaine à traduire : $"${status[$result]}" il va chercher dans le catalogue, il trouve …. la même chose : $"${status[$result]}"2). Il passe ensuite à l'expansion des variables, remplace ${status[$result]} par sa valeur qui se trouve être une traduction déjà réalisée et s'arrête là, content de son travail. Le reste est identique au cas précédent.

Si on essaie alors ceci :

essai-005
status=(
  "$name Perfect",
  "$name almost correct",
  "$name could be better",
  "$name all wrong"
)
name=toto
result=3
echo $"${status[$result]}"

C'est pas mieux. Voir l'explication ci dessus, mais en plus, les chaines du tableau sont même pas traduites…

Il n'y a pas de solution alors ? ben si. Y'a printf, une commande interne de bash. Cette fonction interne reprend et étend la commande externe printf. Cette commande étant un interne de bast (et aussi de dash et ash) est donc peu coûteuse. Un exemple correct pour ce script est :

essai-006
#! /bin/bash
 
TEXTDOMAIN=${0##*/}
TEXTDOMAINDIR="$PWD/locale"
 
status=(
 $"%s Perfect\\n",
 $"%s almost correct\\n",
 $"%s could be better\\n",
 $"%s all wrong\\n"
)
name=toto
result=$(( $RANDOM % 4 ))
printf "${status[$result]}" "$name"
read -p $"press the return key"
# ou alors, si on fait des boites de dialogue, on peut utiliser ceci :
result=$(( $RANDOM % 4 ))
dialog --msgbox "$( printf "${status[$result]}" "$name" )" 0 0
result=$(( $RANDOM % 4 ))
zenity --info --text="$( printf "${status[$result]}" "$name" )"

Les traducteurs pourront mettre les noms aux endroits où ils le veulent. Sauf qu'ils peuvent éventuellement ne même pas vraiment savoir à quoi correspond le %s dans la chaine à traduire.

Notez l'emploi de TEXTDOMAIN=${0##*/}. ${0##*/} est toujours égal au nom du script, quelque soit ce nom.

Voilà le fichier .po pour ceux qui voudraient essayer :

essai-006.po
#: essai-007:7
msgid "%s Perfect\\\\n"
msgstr "%s, C'est parfait !\\\\n"
#: essai-006:8
msgid "%s almost correct\\\\n"
msgstr "Presque correcte %s\\\\n"
#: essai-006:9
msgid "%s could be better\\\\n"
msgstr "%s : Peut mieux faire\\\\n"
#: essai-006:10
msgid "%s all wrong\\\\n"
msgstr "%s a tout faux !\\\\n"
#: essai-006:15
msgid "press the return key"
msgstr "Appuyez sur la touche « entrée »"

et voilà aussi les commandes à passer :

langue=fr_FR
mkdir -p locale/$langue/LC_MESSAGES/
msgfmt -o locale/$langue/LC_MESSAGES/essai-006.mo essai-006.po
bash essai-006

Téléchargement bonus

un script d'exemple et des Makefile pour traduire…

multilingue.tar.bz2

1)
putain, j'ai du mal à écrire ça
2)
sauf si vous avez pris des libertés de traduction