Attention ! Etes vous sûrs d'avoir une bonne assurance emprunteur pour votre crédit immobilier ?


sept 06

Utiliser les “magic functions” de PHP 5 pour traduire son site

Tag: Codingnoreply @ 17:17

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 :

  1. 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.
  2. 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.
  3. 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 :

  1. Proprement les éléments non traduits gérés devront être (ouf)
  2. Pour chaque langue, un gros fichier pour le site ou un petit fichier par page proposer tu devras
  3. Des variables pourront être passées dans les traductions (oui j’ai arrêté le style Moïse)
  4. Les cas particuliers de pluriel du genre “pas de commentaire, un commentaire, N commentaires” devront être correctement gérés
  5. Les modifications dans les traductions devront pouvoir se faire aisément et se répercuter sur tout le site avec grâce
  6. 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.
  7. 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
class Lang
{
        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.

class LangPart extends Lang
{
        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 :

<?php
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.
<?xml version="1.0" encoding="UTF-8"?>
<!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 :

<language>
  <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&amp;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 = new LangControler(‘fr’);
$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 :

// mise à jour du fichier de langue global, et update de tous les autres ensuite :

$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) :

$lc = new LangControler(‘fr’);
$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)

One Response à “Utiliser les “magic functions” de PHP 5 pour traduire son site”

  1. Geoffroy dit :

    J’ai trouvé ça intéressant ! Je posterai un feedback mieux garni après utilisation. Merci :P

Poster un commentaire