Remontons à la base : Le caractère encoding.

Comme toute donnée, une chaine de caractères est "dans l'ordinateur" une suite d'octets de différentes valeurs. Dans son expression la plus simple, chaque valeur d'octet correspond à une lettre (ex: un octet de valeur 65 correspond à un "A"). Comme un octet peut prendre une valeur comprise entre 0 et 255, on a suffisement de valeurs pour décrire l'alphabet (majuscules + minuscules + chiffres + ponctuation). La correspondance (ou table ou charset) "standard" entre octet et caractère correspond à l'encodage ASCII, qui suffit à couvrir l'alphabet anglo-saxon avec des valeurs comprises entre 0 et 127.

En france on a aussi besoin des caractères accentués (éèê etc...); il existe donc un encodage normé avec le même principe 1 octet = 1 caractère qui décrit aussi les caractères français. C'est le codage ISO-8859-1 ou Latin-1, qui étend la table ASCII. Il existe aussi ANSI et ISO-8859-15 qui étendent ISO-8859-1 de quelques caractères comme €.

D'autres langues ne vont par avoir besoin des caractères accentués et vont utiliser les mêmes valeurs d'octet pour décrire d'autres caractères spécifiques (ex. ISO 8859-2 pour l'Europe centrale, ISO 8859-9 pour le turc, etc...). Si on utilise la table turque pour lire un texte français Latin-1, les accents vont être remplacés par des caractères turcs. Les problèmes sont encore pires avec les tables asiatiques...

Unicode encodé UTF-8.

On a donc créé une table "universelle" où tous les caractères existants ou ayant existé ont une valeur correspondante. C'est Unicode. Mais dans ce cas, 1 octet ne suffit plus à décrire l'eventail des valeurs : on doit alors utiliser plusieurs octets consécutifs pour décrire cette valeur. C'est ce à quoi sert l'encodage UTF-8.

Plutôt que de bêtement utiliser un nombre fixe d'octets pour chaque caractères (ex 4 octets par caractère, ce qui produirait des fichiers 4x plus lourds), en encodage UTF-8, un caractère sera codé sur 1 à 4 octets suivant sa valeur dans la table Unicode. Ainsi un "A" reste la valeur 65 codé sur 1 octet, un "é" sera codé sur 2 octets... C'est pour celà que quand vous regardez un "é" encodé en UTF-8 sur 2 octets avec un afficheur qui prend ça pour du Latin-1, vous verrez "é"

Au niveau d'un éditeur de texte

Il faut utiliser un éditeur qui connait UTF-8. L'éditeur doit être capable de lire un texte UTF-8 et de le comprendre comme tel (c'est à dire afficher des "é" et pas des "é"), et il doit être capable d'enregistrer le texte en UTF-8. Heureusement c'est le cas de tous les bons éditeurs. Même Notepad connait UTF-8. Le choix de l'encodage se fait générallement dans les Préférences ou au moment de l'enregistrement du document.

Comment l'éditeur reconnait-il que le texte est en UTF-8 ?
L'éditeur de texte peut marquer un fichier texte comme étant encodé en UTF grâce au BOM (byte order mark) sur les 3 premiers octets du fichiers. Ce BOM a pour but d'indiquer l'ordre des bits dans les encodages UTF-16 et UTF-32 et sert uniquement de "signature" en UTF-8.

Mais comme celà ajoute des octets (qui peuvent donc être pris aussi comme les caractères "ÿþ"), celà entraine à mon avis beaucoup plus de problèmes que de solutions (cf plus loin avec PHP). D'autant que ce BOM n'est pas une obligation ni dans la norme ni pour l'éditeur de texte. En effet un bon éditeur de texte est capable d'analyser le contenu du texte pour determiner s'il est en Latin-1 ou en UTF-8, et doit donner le choix d'enregistrer ou pas un BOM sur les documents.

Et Flash dans tout ça ?

On va commencer par Flash parce que c'est le plus simple car le plus bête :

  • En SWF version 5 et -, tous les textes envoyés et reçus par Flash (loadVariables, XML...) utilisent le codage "basique" du système sur lequel tourne le SWF, donc Latin-1 pour la France.
  • En SWF version 6 et +, tous les textes envoyés et reçus par Flash utilisent le codage UTF-8.
  • On peut forcer un SWF version 6 et + à utiliser le codage du système en utilisant System.useCodepage = true (mais pourquoi vouloir revenir en arrière ?)

Je dis que Flash est le plus bête car Flash applique ces règles absolument sans tenir compte des informations d'encodage contenues dans les fichiers eux-même ou dans les entêtes HTTP (voir plus loin).

Si vous utilisez (j'espère !) des fichiers .as externes pour vos développement Flash, il faut noter que Flash MX s'attend par défaut à avoir des fichiers encodés en Latin-1, ce qui est débile compte tenu des règles précédentes, mais on peut lui préciser que les fichiers sont en UTF-8 en rajoutant sur la première ligne du fichier "//!-- UTF8". Dans ce cas il ne faut pas utiliser de BOM sur le fichier ou Flash MX ne comprendra pas.

Pour Flash MX 2004 et Flash 8, le BOM ne pose plus problème :

  • soit vous utilisez un fichier UTF-8 avec BOM (AS1 et AS2),
  • soit vous utilisez un fichier UTF-8 sans BOM en mettant "//!-- UTF8" sur la première ligne, comme pour Flash MX (AS1 uniquement !),
  • soit vous utilisez un fichier Latin-1.

En (X)HTML/XML ...

Les navigateurs modernes comprennent très bien les différents encodages, l'essentiel est de bien spécifier au navigateur quel est l'encodage utilisé.
Il y a 2 endroits où est décrit l'encodage d'un document (X)HTML ou XML envoyé par un serveur web.

L'un est dans le fichier lui même :

  • c'est un tag méta dans un fichier HTML :
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
  • et c'est un attribut de la déclaration en XML :
<?xml version="1.0" encoding="utf-8"?>

L'autre est dans l'entête de la réponse HTTP que fait le serveur web quand il envoit le fichier, via l'information charset du Content-Type. C'est donc une information gérée au niveau du serveur. On peut la spécifier pour les scripts PHP avec

header ('Content-Type: text/html; charset=utf-8');

mais pour les autres fichiers (en particulier HTML), ça peut poser problème si jamais ce n'est pas la bonne information qui est envoyée, car ce charset a priorité sur celui décrit précedement. Il faut donc vérifier que le serveur Web n'est pas configuré pour renvoyer un charset Latin-1 systématiquement. Le mieux est que le serveur ne renvoie pas de charset, comme ça on peut gérer le charset au niveau du document. Heureusement c'est généralement le cas.

... avec PHP

Il arrive sur Apache que les fichiers PHP aient un charset HTTP déclaré iso-8859-1 par défaut : il est donc préférable de toujours spécifier :

<?php
header ('Content-Type: text/html; charset=utf-8');
...
?>

quand on travaille en UTF-8 avec PHP.

A part ça, il est naturel de travailler avec un fichier PHP lui-même encodé en UTF-8; ça permet d'utiliser des chaines de caractères avec accents directement dans le script. Attention là encore le BOM va nous e***rder car les caractères "ÿþ" vont se trouver avant le <?php et donc être envoyés avant le header, rendant la fonction header et les cookies inopérationnels ! Solution : pas de BOM ! Attention à vérifier l'absence de BOM y compris sur les fichiers inclus avec include ou require... d'où l'utilité d'un éditeur de texte explicite sur ce point.

Si on utilise un fichier PHP encodé en Latin-1 et que l'on veut sortir un document UTF-8, il faut encoder à la volée les chaines de caractères avec la fonction utf8_encode() avant de les afficher. C'est à la fois contraignant et pas forcément interressant puisqu'on perd la richesse d'Unicode en nous limitant aux caractères de Latin-1.

Pour les utilisateurs avancés qui ont accès à la configuration de PHP, 2 autres pistes pour contourner le problème du BOM :

  • modifier le paramètre output_buffering = On de PHP.INI, permettant de sortir la page en 1 seul bloc au lieu d'écrire au fur et à mesure de l'éxecution du script
  • compiler PHP avec l'option --enable-zend-multibyte

A noter cependant que cela ne supprime pas le BOM, qui peut rester problèmatique si vous vous servez de votre script pour sortir autre chose que du texte (une image par exemple)

La manipulation de chaines de caractères

Les vraies difficultés avec PHP commencent quand on va vouloir utiliser des fonctions de manipulation de chaines comme strlen, substr... Vu qu'un "é" en UTF-8 va être codé sur 2 octets (ou un "€" sur 3 octets), strlen("é") va retourner ... 2 (ou 3) ! Encore pire avec substr puisqu'on risque de couper la chaine au milieu d'un caractère multi-octets et se retrouver avec un caractère complètement faux. La solution s'appelle mbstring (multi-bytes string) : c'est un module de PHP fait pour gérer les chaines encodées sur plusieurs octets. Après avoir précisé l'encodage utilisé avec mb_internal_encoding("UTF-8"), on utilise alors les fonctions mb_strlen, mb_substr... Ex :

<?php
  header('Content-Type: text/plain; charset=utf-8');
  
  mb_internal_encoding("UTF-8");
  print ("strlen('€') = ".strlen('€')."\n");
  print ("mb_strlen('€') = ".mb_strlen('€')."\n");
?>

On obtient en sortie :

strlen('€') = 3
mb_strlen('€') = 1

Les variables des formulaires

Les données provenant d'un formulaire sont envoyées encodées dans le charset de la page contenant le formulaire, donc UTF-8 si votre page HTML est en UTF-8.

Le fait qu'elles soient url-encodées à l'envoi n'a pas d'incidence, simplement si vous rentrez un "€" dans un formulaire UTF-8, c'est "%E2%82%AC" qui va être envoyé, et PHP recevra correctement ça comme 3 octets qui, interprétés comme une chaine UTF-8, donneront bien "€".

Et MySQL ?

Jusqu'à MySQL 4.0, c'est assez simple car MySQL ne gère pas l'encodage. Donc si vous enregistrez une chaine encodée en UTF-8, comme "é" par exemple, vous enregistrez ça comme 2 caractères mais ça n'a pas d'importance : quand vous récupérerez ce champ, vous obtiendrez vos 2 caractères et votre PHP en UTF-8 retrouvera bien son "é".

Même avec phpMyAdmin (avant 2.7 en tous cas) : vous pouvez choisir quel est le charset d'affichage de phpMyAdmin. Si vous choisissez ISO-8859-1 et que vous regardez notre champ, vous verrez "é" et vous aurez des problèmes pour inserez ou modifier des champs; mais si vous choisissez UTF-8, vous verrez bien "é", et les formulaires insertion / modification, en UTF-8 eux aussi donc, inséreront bien des chaines UTF-8 : tout est cohérent.

En revanche les fonctions SQL comme CHAR_LENGTH(), etc... vous sortiront les mêmes résultats que strlen() de PHP, c'est à dire qu'elles prendront les chaines comme des chaines 1 octet = 1 caractère. Mais dans la mesure où on effectue moins souvent ce genre de manipulation de chaines directement sur la base, ça a moins d'influence que précedement avec PHP.

Depuis MySQL 4.1, gros changement : la base elle-même a un encodage (non seulement le server MySQL, mais chaque base, chaque table et même chaque colonne de table a un attribut qui définit quel est son encodage). Là il faut distinguer 2 choses (et phpMyAdmin n'est pas du tout clair sur ce point) :

  • Il y a l'encodage des données (ou charset ou character set), qui indique comment les données sont stockées.
  • Il y a l'interclassement (ou collation en anglais) qui définit les règles à appliquer lors des opérations, comme par exemple la sensibilité à la casse, l'ordre des lettres accentuées... Ces règles peuvent maintenant être spécifique à une langue. En reprenant l'exemple de la doc MySQL : "Muffler" < "Müller" si on utilise la collation latin1_german1_ci, mais c'est inverse si on utilise latin1_german2_ci.

Donc moi je me suis dit "Bah nickel, je vais tout mettre en utf-8 et ca va marcher !"... Ha ha ha ...

Déjà effectivement si on s'en tient à PHP, qu'on insère "comme avant" des données encodées et UTF-8, et qu'on les sort ensuite, on retrouve bien notre chaine qui s'affiche correctement en UTF-8. Ce qui est curieux en fait c'est qu'en regardant la base avec phpMyAdmin (2.8), il m'affichait "é" alors qu'il était bien en UTF-8 aussi ?...

Et en cherchant un peu, j'ai trouvé qu'il fallait éxecuter la requète "SET NAMES 'UTF8'" au moment du stockage avec PHP pour régler le problème.
Que fait cette fonction magique ? En réalité, en plus des encodages de la base et des données transmises par le client, il y a l'encodage déclaré des données transmises. On peut même distinguer : l'encodage dans lequel le client va envoyer les données, l'encodage dans lequel le client veut recevoir les données, et la collation a utiliser. La fonction SET NAMES fait les 3 d'un coup. Bien entendu il faut alors aussi éxecuter le SET NAMES quand on veut récuperer les données (il suffit de l'éxecuter une fois pour toute la connection du client SQL).

Exemple avec un script volontairement en ISO-8859-1 :

$chaine = 'é';
INSERT INTO test (test) VALUES ('".$chaine."') -> du point de vue de MySQL, j'ai inseré un "é" (MySQL l'a converti en UTF-8 pour le stocker)
INSERT INTO test (test) VALUES ('".utf8_encode($chaine)."') -> du point de vue de MySQL, j'ai inseré "é" (même remarque)

SET NAMES 'UTF8'
INSERT INTO test (test) VALUES ('".$chaine."') -> ca ne marche pas car mon "é" mono-octet ne correspond à rien en UTF-8
INSERT INTO test (test) VALUES ('".utf8_encode($chaine)."') -> du point de vue de MySQL, j'ai inseré un "é"

Donc au final dans la table, la 1ère insertion et la 4ième sont identiques. Si on veut insérer des chaines UTF-8, la meilleure solution est donc de toujours utiliser SET NAMES 'UTF-8' à chaque connection MySQL. Le format de la base et l'interclassement ont en fait moins d'importance.

Avec l'interclassement correctement déclaré, la fonction CHAR_LENGTH() retournera bien le nombre de caractères, les caractères multi-bytes étant maintenant reconnus comme 1 seul caractère. A noter que la fonction LENGTH() compte le nombre d'octets, contrairement à CHAR_LENGTH() qui compte le nombre de caractères (merci Tek).

Liens utiles