sept 06
Utiliser les “magic functions” de PHP 5 pour traduire son site
Avant tout je tiens à préciser que cet article ne traite pas de Google Translate. Désolé amis spammeurs mais je garde mes fonctions de traduction automatique pour moi tout seul encore quelque temps.
Aujourd’hui je m’adresse donc aux vrais gens, qui font des vrais sites, et en particulier à tous ceux qui ont à gérer un site internet multi langues, qu’ils soient cravatés ou non. Ceux qui savent à quel point les soucis liés à l’internationalisation peuvent être handicapants dans le développement d’un projet ambitieux qui avait pourtant mobilisé toutes les synérgies de l’entreprise, hein mémé (je l’entends mal mais je crois qu’elle a répondu “oui oui” depuis la cuisine).
Les solutions existantes pour localiser un site Internet
Comme à chaque fois que je développe un site Internet multilingue donc (c’est à dire environ une fois tous les 33 ans), j’essaye d’abord de faire un peu le tour des solutions existantes pour savoir laquelle éventuellement dépouiller intégrer à mon projet. Et le constat est à chaque fois le même, je distingue en général trois types de solutions :
- gettext qui semble une solution solide et répandue, ceux qui ne connaissent pas, je vous laisse découvrir tout ça sur leur site super accueillant.
- définir des pages et des pages de constantes à traduire ensuite dans chaque langue, je n’ai pas d’exemple en tête mais il doit bien y avoir des CMS pourris sur le marché du genre de Joomla qui doivent fonctionner comme ça.
- se taper à la main autant de versions du site qu’il y a de langues ; c’est souvent comme ça que ça finit !
Je signale au passage que pour une fois je suis tombé sur un article sympa et en français sur l’internationalisation (i18n) en PHP et XML qui m’a bien inspiré pour coder mon système de traduction. Merci à l’auteur si il me lit.
Et sinon pour ceux qui reviennent, les sextoys c’est plus bas.
La traduction de site dans l’idéal
Vous l’aurez compris, le but de la manoeuvre est de trouver une alternative à gettext qui soit moins chiante à mettre en place et si possible un peu mieux. Je me suis donc mis à la tâche en m’imposant les points suivants :
- Proprement les éléments non traduits gérés devront être (ouf)
- Pour chaque langue, un gros fichier pour le site ou un petit fichier par page proposer tu devras
- Des variables pourront être passées dans les traductions (oui j’ai arrêté le style Moïse)
- Les cas particuliers de pluriel du genre “pas de commentaire, un commentaire, N commentaires” devront être correctement gérés
- Les modifications dans les traductions devront pouvoir se faire aisément et se répercuter sur tout le site avec grâce
- Tant qu’on y est, il ne faudra pas oublier non plus les autres problèmes liés à la localisation : encodages, dates, poids et mesures etc.
- Et j’ajoute enfin (oui le patron est devenu fou !) que l’ensemble des traductions pourra s’importer et s’exporter facilement, genre via un fichier XML (ça tombe bien c’est justement ce que j’ai choisi !)
Voilà, on a fait le tour du problème, maintenant on passe à la solution !
La solution : les fonctions magiques de PHP 5 !
Je vous préviens tout de suite : rien à voir avec Garcimore. Pour vous j’ai installé un plugin spécial et chichement commenté mon code, et en français de plus !
Voici donc dans un premier temps les 3 petites classes que j’ai codées, présentées dans un ordre arbitraire qui correspond grosso modo à l’ordre dans lequel je les ai pensées. Attention cependant, n’essayez pas de tout comprendre tout de suite, ça peut causer un choc. De même, épileptiques, migraineux ou simplement mal comprenant, passez votre chemin ; les autres on se retrouve plus bas pour causer…
La classe Lang, le coeur de la bête
{
const _maxArguments = 10; // les traductions peuvent être formatées façon sprintf() et pour éviter d’avoir des erreurs si un nombre insuffisant de paramètres est passé, des paramètres supplémentaires vides sont peuvent être envoyés, ou des messages indiquant qu’il manque un paramètre.
static $_emptyArguments; // utilisé pour remplacer les paramètres manquant par des chaines vides donc
static $_arguments; // utilisé pour indiquer le ou les paramètres manquant
protected $_oe = ‘UTF-8′; // oe = output encoding : il s’agit donc de l’encodage dans lequel doivent être envoyés les résultats
protected $lang; // code ISO-je-sais-pas-combien de la langue, sur deux caractères en minuscule quoi (fr, en, jp)
protected $path; // le chemin du répertoire dans lequel seront stockés les fichiers de langue, le répertoire doit être accéssible au serveur http en lecture et écriture
function __construct($lang,$path)
{
// constructeur de la classe
$this->lang = $lang;
$this->path = $path;
// valeurs par défaut au premier lancement
$this->_noTranslation = ‘?’; // remplacer les éléments non traduit par un "?"
$this->_argumentMissing = ‘x’; // remplacer les arguments manquant (pour les traductions formatées à la sprintf) par un "x"
$this->_dateFormat = ‘m/d/Y’; // le même format que celui utilisé pour la commande date() de php
$this->_timeFormat = ‘H:i’; // idem, mais pour l’heure
$this->_numberFormat = ‘%d’; // le format utilisé pour la commande number_format()
$this->_encoding = ‘UTF-8′; // doit correspondre au charset dans lequel sont encodées les traductions
}
function __wakeup()
{
// cette méthode est appelée à chaque unserialize() et permet de bien regénéré quelques variables
$this->_emptyArguments = array_fill(0,self::_maxArguments,”);
$this->_arguments = array();
for ($i = 0; $i < self::_maxArguments; $i++) $this->_arguments[$i] = sprintf($this->_argumentMissing,$i+1);
}
function __get($key)
{
// cette méthode est appelée quand une propriété de la classe n’a pas été trouvée, ce qui doit être toujours le cas
// elle assure ensuite l’affichage de la traduction correspondante, éventuellement modifiée par des paramètres de formatage
// ou renvoit le message à afficher en cas de traduction manquante
return $this->out(key_exists($key . ‘_’,$this) ? vsprintf($this->{$key . ‘_’},$this->_emptyArguments) : sprintf($this->_noTranslation,$key));
}
function __call($method,$args)
{
// méthode appelée quand une méthode de la classe n’a pas été trouvée, fonctionne un peu comme le __get
// cette astuce nous permet d’appeler les éléments à traduire qui ont des paramètres comme une fonction
$method .= ‘_’;
$n = count($args);
if ($n)
{
$p = $method . implode(‘_’,$args);
if (key_exists($p,$this)) return trim($this->$p);
for ($i = $n; $i < self::_maxArguments; $i++) $args[$i] = $this->_arguments[$i];
}
else
{
$args = $this->_arguments;
}
return $this->out(vsprintf($this->$method,$args));
}
protected function out($string)
{
// méthode qui gère la sortie, formatages éventuels et conversions pour l’encodage si besoin
return trim(mb_convert_encoding($string,$this->_oe,$this->_encoding));
}
function set_oe($oe)
{
// permet de redéfinir le jeu de caractère à utiliser pour la sortie
$this->_oe = $oe;
}
}
Class LangPart : pour splitter les fichiers de trads
On passe à la vitesse supérieure, cette classe est encore plus tordue que sa classe parente.
{
protected $id; // on ajoute la notion d’id qui va permettre de différencier les fichiers de traductions
private function learn($key)
{
// une méthode qui vient s’incruster entre les __get() et les __call() et qui va permettre de générer à la volée un fichier avec uniquement les traductions
// dont on a besoin en allant récupérer les valeurs dans le fichier de traduction global (ceci n’est fait qu’une seule fois)
$key_ = $key . (strpos($key,‘_’) === 0 ? ” : ‘_’);
if (!key_exists($key_,$this))
{
$lc = new LangControler($this->lang,$this->path); // la classe LangControler est détaillée plus bas
$l = $lc->get();
foreach (get_object_vars($l) as $k=>$v) if (strpos($k,$key_) === 0) $this->$k = $v;
if (!key_exists($key_,$this)) $this->$key_ = sprintf($this->_noTranslation,$key);
$lc->select($this->id);
$lc->save($this);
}
}
function __construct($lang,$path,$id)
{
// un constructeur classique
$this->lang = $lang;
$this->path = $path;
$this->id = $id;
parent::__wakeup();
}
function __get($key)
{
// la méthode __get() est un peu surchargée pour y inclure l’appel à learn()
$this->learn($key);
if ((strpos($key,‘_’) === 0) && key_exists($key,$this)) return $this->out($this->$key);
return parent::__get($key);
}
function __call($method,$args)
{
// idem pour la méthode __call()
$this->learn($method);
return parent::__call($method,$args);
}
}
Class LangControler pour vous simplifier la vie
J’ai pensé qu’il était préférable de laisser les classes de la famille Lang faire leur boulot sans se laisser distraire par les opérations d’écriture disque, d’interface avec l’environnement et autres cocktails… Donc pour l’implémentation, nous passerons par la class LangControler que voici :
class LangControler
{
private $filename = NULL;
const filenameMask = ‘lang-%s-%s.serialized.inc’; // un masque pour définir le nom des fichiers de traductions qui seront générés, ici donc nous aurons des fichiers du genre : lang-fr-identifiant.serialized.inc par exemple pour la langue fr et l’identifiant "identifiant" (en fait non car je passe l’identifiant en base64)
function __construct($lang,$path=‘./’)
{
// le constructeur, classique, on passe donc la langue et éventuellement le chemin pour le répertoire de stockage (je vous recommande un sous répertoire de votre dossier de cache)
$this->lang = strtolower($lang);
$this->path = $path;
$this->id = NULL;
}
private function error($errmsg)
{
// essentiellement pour faire joli, avec moi ya jamais d’erreurs
throw new Exception($errmsg);
return false;
}
private function make_filename($id)
{
// pour générer le nom de fichier qui va bien une fois pour toute
return $this->path . sprintf(self::filenameMask,$this->lang,$this->make_id($id));
}
private function make_id($id)
{
// il est préférable d’encoder l’id pour éviter les problèmes (espaces, accents non supportés, tentatives de hack)
return $id ? base64_encode($id) : ‘all’;
}
function select($id=NULL)
{
// cette méthode prépare un fichier pour l’écrasement (update)
$this->id = $id;
$this->filename = $this->make_filename($id);
}
function get($id=NULL)
{
// cette méthode va renvoyer l’objet lang demandé, et si il n’existe pas, le créer
// si on passe un id, on fonctionnera avec la classe LangPart et les fichiers de trad locaux, sinon directement sur le fichier global
$id = $id === NULL ? $this->id : $id;
$filename = $this->make_filename($id);
return file_exists($filename) ? unserialize(file_get_contents($filename)) : ($id ? new LangPart($this->lang,$this->path,$id) : new Lang($this->lang,$this->path));
}
function update(&$lang,$xml=NULL)
{
// cette méthode va mettre à jour un objet de langue passé en paramètre par référence
// si il s’agit de l’objet qui gère le fichier global des traductions, il faut passer avec le XML des traductions
// si c’est un fichier local, il se synchronisera sur le fichier global pour la langue qui va bien
if ($this->filename === NULL) return $this->error(‘filename is not defined’);
switch (get_class($lang))
{
case ‘Lang’:
try
{
$x = new SimpleXMLElement($xml);
foreach ($x->xmldata as $data)
{
$key = $data[‘id’] . (strpos($data[‘id’],‘_’) === 0 ? ” : ‘_’);
foreach ($data->translation as $translation)
{
if (preg_match(‘#^(’ . preg_quote($this->lang,‘#’) . ‘)|(\*)$#si’,$translation[‘lang’]))
{
if (@$translation[‘args’])
{
$args = str_replace(‘&’,‘_’,urldecode($translation[‘args’]));
$lang->{$key . $args} = (string)$translation;
}
else
{
$lang->$key = (string)$translation;
}
}
}
}
$lang->__wakeup();
}
catch (Exception $e)
{
return $this->error($e->getMessage());
}
break;
case ‘LangPart’:
$xc = new LangControler($this->lang,$this->path);
$x = $xc->get();
foreach (get_object_vars($lang) as $k=>$v) $lang->$k = $x->$k;
break;
default:
return $this->error(‘can not handle class ‘ . get_class($lang));
}
return $this->save($lang);
}
function update_all()
{
// cette méthode est pratique si vous avez mis à jour le XML des traductions, et le fichier global, mais que vous utilisez les fichiers locaux de traduction
// un simple appel et tous les fichiers locaux se mettront à jour par rapport au fichier principal
$Id = $this->id;
$mask = str_replace(‘%s’,‘(.*?)’,preg_quote(self::filenameMask,‘#’));
foreach (scandir($this->path) as $file) if (preg_match("#$mask#s",$file,$m))
{
$lang = $m[1];
$id = $m[2];
if (($lang == $this->lang) && $id && ($id != ‘all’))
{
$id = base64_decode($id);
$this->select($id);
$this->update($this->get($id));
}
}
$this->id = $Id;
}
function save(&$data)
{
// le nouvel objet de langue est sérialisé et écrit dans le fichier correspondant
if ($this->filename === NULL) return $this->error(‘filename is not defined’);
$saved = file_put_contents($this->filename,serialize($data));
$this->filename = NULL;
return $saved;
}
}
?>
Une DTD pour le fichier XML des traductions
Un XML de démonstration est proposé plus bas donc pas de panique. Rien de spécial à signaler sinon que :
- Vous pouvez utiliser “*” comme attribut lang dans translation, ce qui permet d’avoir un texte affiché par défaut quelque soit la langue (déclarez-le en premier si vous l’utilisez).
- Si vous passez plusieurs arguments dans l’attribut args de translation, ils doivent être urlencodés.
<!ELEMENT language (xmldata*) >
<!ELEMENT xmldata (translation+) >
<!ATTLIST xmldata id ID #REQUIRED >
<!ELEMENT translation (#PCDATA) >
<!ATTLIST translation
lang CDATA #REQUIRED
args CDATA #IMPLIED
>
Quelques explications
On va faire bref je commence à fatiguer ! En gros le principe général est que pour traduire le terme avec l’id “hello” par exemple, on va créer une propriété hello_ dans l’objet $lang : $lang->hello_ donc.
Lorsqu’on appelle $lang->hello pour avoir la traduction, la propriété n’est pas trouvée et c’est la méthode magique $lang->__get(’hello’) qui rentre en jeu.
De même, si vous autorisez des paramètres pour une traduction, par exemple la traduction en français de $lang->hello serait “Bonjour %s”, vous pouvez appeler $lang->hello(’boss’) et cette fois ce sera la méthode magique $lang->__call() qui sera appelée et convertira tout ça en un printf(’Bonjour %s’,'boss’) pour schématiser.
Je poursuivrais les explications dans les commentaires si y en a ^^
Exemples d’implémenation
Pour les exemples qui suivent, on considérera que le fichier XML des traductions est le suivant :
<xmldata id="_noTranslation">
<translation lang="fr">[traduction manquante pour : %s]</translation>
</xmldata>
<xmldata id="_argumentMissing">
<translation lang="fr">[argument %s manquant]</translation>
</xmldata>
<xmldata id="hasComment">
<translation lang="fr" args="0">il n’y a aucun commentaire</translation>
<translation lang="fr" args="1">il y a un commentaire</translation>
<translation lang="fr">il y a %s commentaires</translation>
</xmldata>
<xmldata id="test">
<translation lang="fr">Bonjour %s, ceci est un test à %s variables</translation>
<translation lang="fr" args="papa&1">Salut mec, ceci est un test à 1 variable</translation>
</xmldata>
<xmldata id="welcome">
<translation lang="fr">Bienvenue %s</translation>
</xmldata>
<xmldata id="hello">
<translation lang="fr">Salut</translation>
</xmldata>
</language>
…et que le fichier de langue global a été généré, comme ceci :
$lc->select();
$lc->update($lc->get(),$xml);
Voilà, déjà en fait vous pouvez avoir accès aux traductions. Avant de voir plus d’exemple, voici le code pour toutes les situations :
$lc = new LangControler(‘fr’);
$lc->select();
$lc->update($lc->get(),$xml);
$lc->update_all();
// mise à jour du fichier de langue partiel "test" :
$lc = new LangControler(‘fr’);
$lc->select(‘test’);
$lc->update($lc->get());
// appel normal du fichier de langue global
$lc = new LangControler(‘fr’);
$lang = $lc->get();
// appel normal d’un fichier de langue partiel (disons ‘test’) :
$lc = new LangControler(‘fr’);
$lang = $lc->get(‘test’);
Maintenant pour finir, quelques exemples de sorties par rapport au fichier XML (qu’on soit en fichier local ou global ne change rien, au premier appel à un élément inconnu on va de toutes façons chercher une traduction dans le fichier global) :
$lang = $lc->get();
echo $lang->salut; // [traduction manquante pour : salut]
echo $lang->comment(0); // Il n’y a aucun commentaire
echo $lang->comment(3); // Il y a 3 commentaires
echo $lang->comment(); // Il y a [argument 1 manquant] commentaires
echo $lang->test; // Bonjour , ceci est un test à variables
echo $lang->test(‘papa’,1); // Salut mec, ceci est un test à une variable
echo $lang->hello(‘boss’); // Salut
echo $lang->welcome(‘Tonton’,‘José’); // Bienvenue Tonton
Pour le reste je vous invite à tester par vous même !
Une dernière chose tant qu’on est dans les Magic functions de php, tant qu’à faire, utilisez la fonction magique __autoload pour être sûrs que toutes vos classes se chargent bien quand il faut.
Voilà, c’est tout (ouf) ! J’attends vos coms, il y a sans doute des choses à améliorer et c’est bien pour ça que je publie. Si j’ai pu aider ne serait-ce qu’une personne de gauche avec cet article, je me sens déjà amplement récompensé mais je ne vous cache pas que j’ai un hamster à nourrir et qu’on ne mange pas de la viande tous les jours (enfin surtout lui vu qu’il est granivore, il mange que des grany).
Donc si vous voulez me soutenir, achetez un sextoy ! (désolé j’avais pas d’autres affils sous la main :D)

13 octobre 2008 @ 9:52
J’ai trouvé ça intéressant ! Je posterai un feedback mieux garni après utilisation. Merci