Migration automatisée d'un site Web (HTML, PHP, CSS, JS...) de l'encodage ISO-8859-1 vers l'unicode UTF-8 avec Python 3

Introduction

Vous avez conçu un site Web qui a bien quelques années déjà. Les pages et scripts ont toujours été développés et créés avec l'encodage des caractères en Latin 1 / ISO-8859-1 (Europe Occidentale). Qu'il s'agisse des fichiers (*.html, *.php, *.css...) ou de l'encodage défini dans le code HTML,

<meta http-equiv="content-type"
          content="text/html; charset=iso-8859-1">

le public étant francophone, l'encodage ne posait pas de problèmes, la probabilité d'écrire des caractères en mandarin étant nulle.

Et pourtant des problèmes divers et variés d'encodage surgissent au fil des années et de l'évolution des technologies.

Un exemple, la récupération avec JSONP de la liste des articles les plus populaires avec les services AddThis.com (Distribution de données inter domaines avec JSONP, cas pratiques (AddThis...)) :

encoding addthis

La page, et donc son titre, est définie avec l'encodage ISO-8859-1, mais les services JSON d'AddThis sont en Unicode UTF-8 : des problèmes d'interprétation des caractères accentués se produisent en conséquence.

Autre exemple, et pas des moindres : à partir de la version 5.4 de PHP, les fonctions htmlentities et htmlspecialchars, utilisées pour la traduction des balises HTML dans une chaîne de caractères, ont pour encodage par défaut UTF-8 et non plus ISO-8859-1. Quand la page est définie avec l'encodage ISO-8859-1, ces fonctions ont donc un comportement inattendu dans les translations ISO-8859-1 - UTF-8 et retournent des chaînes null.

Pour retrouver un comportement normal avec PHP 5.4 et 5.5, il faut appeler ces fonctions avec l'encodage de la page en paramètre :

echo htmlspecialchars($string,ENT_COMPAT,'ISO-8859-1');
echo htmlentities($string,ENT_COMPAT,'ISO-8859-1');

Heureusement, PHP 5.6 autorise la définition de l'encodage par défaut avec la fonction ini_set, encodage qui s'applique alors automatiquement aux fonctions htmlentities et htmlspecialchars.

ini_set("default_charset","ISO-8859-1");

Deux exemples qui forcent "un tout petit peu beaucoup" la migration de son environnement de l'encodage ISO-8859-1 vers UTF-8, plus lourd en octets, mais on pourra écrire du mandarin au final même si la probabilité est nulle ! Alors c'est parti.

Contexte de la migration

Vous prenez votre courage à deux mains et commencez à recenser tous les fichiers à migrer dans son environnement Web local. Le site web est installé ici sous Windows avec Apache / PHP dans le répertoire D:\www\development.

Première étape, identifier les extensions de fichiers à migrer, la partie la plus simple :

Feuilles de styles *.css Fichiers XML *.xml
Scripts Javascript *.js Fichiers JSON *.json
Scripts PHP *.php, *.inc Fichiers HTML *.htm *.html

Recherche des fichiers à migrer avec les commandes find et file

À cette étape, le nombre de fichiers à migrer est encore inconnu et il est hors de question d'ouvrir Notepad pour chacun d'eux afin de les enregistrer en UTF-8.

Avec MingW pour Windows (Minimalist GNU for Windows) installé dans l'environnement, les commandes find et file vont donner une idée du volume des fichiers à migrer en UTF-8. Pour plus d'informations sur MingW : Installation de MinGW sous Windows - Minimalist GNU for Windows, l'installation est simple et vous retrouvez la puissance des principales commandes Linux/Unix qui vous sont familières si vous n'êtes pas du tout à l'aise avec PowerShell.

Le répertoire D:\www\development a pour point de montage /development dans l'environnement MinGW.

$ mount D:\\www\\development /development

$ cd /development

L'option -i de la commande file fournit de précieuses informations sur l'encodage des fichiers :

$ file -i *.html
...
oracle-client-package-10gR2.html:                              text/html; charset=us-ascii
sybase-ase-15.0-group-by-tri-traceflag-450.htm:                text/html; charset=iso-8859-1
mysql-replication-corruption-fichier-relai.json:               text/plain; charset=utf-8
...

L'encodage us-ascii et utf-8 correspondent à UTF-8. La nomenclature us-ascii est un peu perturbante mais il s'agit bien de l'UTF-8 : elle correspond au bloc Latin basique (aucun caractère accentué...) du jeu de caractères Unicode.

Sinon la détection de l'encodage ISO-8859-1 est limpide : charset=iso-8859-1

Par précaution, on peut rechercher tous les encodages existants pour les extensions de fichiers à migrer :

$ find . -regex '.*\.\(css\|htm\|html\|inc\|js\|json\|php\|xml\)' -exec file -i {} \; | awk '{print $3}'| sort -u
charset=binary
charset=iso-8859-1
charset=unknown-8bit
charset=us-ascii
charset=utf-8

Les résultats unknown-8bit et binary sont bizarres dans la sortie mais ont chacune une explication technique.

./css/html/css/print.css: application/x-empty; charset=binary
./delorie.htm: application/x-empty; charset=binary
./fmgr/prp_lst_cpt_ope.php: text/x-php; charset=unknown-8bit
./fmgr/prp_upd_crn.php: text/x-php; charset=unknown-8bit

Pour le cas charset=binary, il s'agit de fichiers vides (0 octets), c'est l'occasion de faire du ménage.

Pour l'encodage unknown-8bit détecté, le cas est un peu plus particulier, il provient de la présence du symbole euro € dans les fichiers, symbole faisant partie de l'encodage ISO-8859-15, contexte que la commande file n'a pas été en mesure de déterminer. Le symbole € est remplacé par Eur temporairement le temps de la migration, il sera réappliqué post migration UTF-8.

$ find . -regex '.*\.\(css\|htm\|html\|inc\|js\|json\|php\|xml\)' -exec file -i {} \; | egrep 'binary|unknown' 

Il est temps de mesurer le volume de fichiers à migrer :

$ find . -regex '.*\.\(css\|htm\|html\|inc\|js\|json\|php\|xml\)' -exec file -i {} \; | grep 'iso-8859-1' | wc -l
730

730 fichiers à migrer... Il faut touver un programme pour réaliser cette manipulation.

Il existe bien l'utilitaire enca (enca - Linux Ubuntu), qui serait capable de lister les encodages des fichiers et d'effectuer les migrations, cependant ce binaire n'est pas présent sur la plateforme MinGW et doit être compilé. Python 3.5 est là, il va réaliser ces opérations en un clin d'œuil.

Recherche des fichiers à migrer avec Python et chardet

Python 3.5 propose un package très intéressant pour détecter l'encodage des fichiers : chardet.

Installation du package chardet

Pour vérifier si le package chardet est installé, ouvrir une invite de commande DOS dans le répertoire d'installation de Python 3.5 (D:\software\scripts\pyhton-3.5 ici) et exécuter la commande pip show pour le package chardet:

% set PYTHON_ROOT="D:\software\scripts\python-3.5"
% cd /D %PYTHON_ROOT%
%PYTHON_ROOT%> .\scripts\pip show chardet

Pour installer le package chardet si il n'est pas présent dans l'environnement :

%PYTHON_ROOT%> .\scripts\pip install chardet
Collecting chardet
  Using cached chardet-2.3.0.tar.gz
Installing collected packages: chardet
  Running setup.py install for chardet ... done
Successfully installed chardet-2.3.0

Recherche des encodages avec chardet

Le package chardet est très simple à utiliser. Le fichier est ouvert avec la fonction read() et la méthode chardet.detect() est appliquée sur les données.

with open(fullname,"rb") as f:
  data = f.read()
  result=chardet.detect(data)
  f.close()

Le fichier doit être ouvert avec l'option rb et non r (b pour binary), la fonction chardet.detect a besoin de lire le format binaire, notamment par exemple pour que les retours chariots (\r\n, \n) ne soient pas dénaturés. Une erreur est levée si l'option r est utilisée :

ValueError: Expected a bytes object, not a unicode object
Ne pas oublier de fermer le fichier avec la méthode close() afin de libérer les ressources et la mémoire.

Le retour de la fonction chardet.detect sur un fichier est un tableau qui retourne le format détecté (encoding) et une échelle de confiance (confidence) de 0 à 1 (0 à 100 %).

{'encoding': 'ascii', 'confidence': 1.0}
{'encoding': 'ISO-8859-2', 'confidence': 0.8522810653129587}

Le code python (chardet-site.py) proposé ci-dessous détecte récursivement l'encodage des fichiers *.htm, *.html, *.js, *.json, *.css, *.xml, *.php, *.inc dans un répertoire avec le package chardet. Veuillez excuser l'élégance du code, mais l'auteur fait ses premiers pas avec le langage Python :

%PYTHON_ROOT%> python D:\projets\py\chardet-site.py > D:\projets\py\chardet-site.log
D:\projets\py\chardet-site.py
import os
import io
import chardet

filePathSrc = r'D:\www\development'
nbfiles=0
encodings=[]

def list_extensions(filename, extensions=['.css','.htm','.html','.inc','.js','.json','.php','.xml']):
    return any(filename.endswith(e) for e in extensions)

for root, dirs, files in os.walk(filePathSrc):
     
     for fn in filter(list_extensions, files):

        fullname = os.path.join(root,fn)
  
        with open(fullname,"rb") as f:
          data = f.read()
          result=chardet.detect(data)
          nbfiles += 1
          print("%s | %s | %s " % (fullname, result['encoding'], result['confidence']))
          f.close()
            
          if result['encoding'] not in encodings:
            encodings.append(result['encoding'])
            encodings.append(1)
          else:
            encodings[encodings.index(result['encoding']) + 1] += 1

print("==========================================")
print("Nombre de fichiers : %d" %(nbfiles))
print(encodings)
D:\projets\py\chardet-site.log
D:\www\development\top_extra.old.inc | ISO-8859-2 | 0.781425630184565
D:\www\development\tous_boiteaoutils.inc | windows-1252 | 0.73
D:\www\development\tous_moteur_recherche.inc | windows-1252 | 0.73
==========================================
Nombre de fichiers : 952
['ascii', 194, 'windows-1252', 214, 'ISO-8859-2', 379, 'windows-1255', 141, 'utf-8', 20,
'IBM855', 1, 'TIS-620', 2, 'IBM866', 1]

Le résultat est quelque peu différent par rapport aux retours de la commande file.

La commande file détecte correctement l'encodage ISO-8859-1 (721 fichiers) alors que le package chardet détecte les encodages ISO-8859-2 (Europe de l'Est), windows-1252, windows-1255, IBM855, IBM866 avec des taux de confiance oscillant entre 70 et 99%.

  • La détection de l'encodage ISO-8859-2 est surprenante, aucun caractère roumain, hongrois ou encore bulgare n'est utilisé dans les fichiers.
  • L'encodage TIS-620 correspond bien à de l'encodage UTF-8 : il est à priori détecté avec la présence du caractère û (août), mais il n'en demeure pas moins étrange que l'encodage UTF-8 spécifique à la langue Thaïlandaise soit remonté par chardet pour ce caractère.

Si on compare les données, le nombre de fichiers analysés est correct pour les deux méthodes (952), mais un delta de 6 fichiers apparaît dans les détections, ce qui n'est pas trop mal compte tenu du volume de fichiers.

Commande Apparenté au jeu ISO-8859-1 Apparenté au jeu UTF-8 Total
file -i
ISO-8859-1 :730 us-ascii, utf-8 : 222 952
python chardet
ISO-8859-2 : 379 windows-1252 : 214 windows-1255 : 141 IBM855 : 1 IBM866 : 1 ascii : 194 utf-8 : 20 TIS-620 : 2 952

736216

La commande comm dans l'environnement MinGW va rapidement donner les 6 fichiers en delta, en extrayant les données pour UTF-8 dans chacun des deux résultats obtenus par le package chardet et la commande file :

$ cd /d/projets/py
$ cat chardet-site.log | egrep -i 'utf-8|ascii|tis-620' | egrep -v '\[|^$' \
   | awk -F"|" '{print $1}' | sed -e"s/ //g" | sort > chardet-utf8.log
$ cd /d/projets/py
$ find /d/www/development -regex '.*\.\(css\|htm\|html\|inc\|js\|json\|php\|\|xml\)' -exec file -i {} \; \
   | egrep "us-ascii|utf-8" | awk -F":" '{print $1}' \
   | awk '{ gsub(/\/d\//,"D:\\"); gsub(/\//,"\\"); print }' | sort > "file-utf8.log"
$ comm -32 "file-utf8.log" chardet-utf8.log > result.log

D:\www\development\fwork\v2.2.4\js\progressbar\prototype.js
D:\www\development\fwork\v3.1.7\js\progressbar\prototype.js
D:\www\development\referentiel\css\1.0\style-print.css
D:\www\development\referentiel\docs\resources\oracle-9i-10g-reorganisation-fragmentation-tables-indexes.json
D:\www\development\referentiel\js\1.0\resources\common-en.json
D:\www\development\referentiel\resources\themes\oracle-tablespace-undo-management.json

Cela ne concerne que 6 fichiers sur 952, mais l'analyse montre que le package chardet est moins fiable que la commande file. Le fichier D:\www\development\referentiel\js\1.0\resources\common-en.json est détecté avec l'encodage ISO-8859-2 alors qu'il s'agit bien d'un fichier en UTF-8.

D:\www\development\referentiel\js\1.0\resources\common-en.json | ISO-8859-2 | 0.775280038551842

Même si cela ne concerne que 6 fichiers, on va s'abstenir d'utiliser chardet pour les migrations. Le programme Python qui va réaliser la migration va s'appuyer sur la liste des fichiers détectés en ISO-8859-1 par la commande file.

Migration avec Python et la commande file de MinGW

La migration peut démarrer.

Une sauvegarde est mise de côté par sécurité !

Étape 1. : la liste des fichiers détectés en ISO-8859-1 par la commande file de MinGW est générée dans le fichier D:\Projets\py\liste-iso88591.txt.

$ cd /d/projets/py

$  find /d/www/development -regex '.*\.\(css\|htm\|html\|inc\|js\|json\|php\|\|xml\)' -exec file -i {} \; \
   | egrep "iso-8859-1" | awk -F":" '{print $1}' \
   | awk '{ gsub(/\/d\//,"D:\\"); gsub(/\//,"\\"); print }'  > liste-iso88591.txt
D:\Projets\py\liste-iso88591.txt
D:\www\development\admpmgportal\config.inc
D:\www\development\admpmgportal\include\Cls_App.php
...

Étape 2. : le programme python migrate-utf8.py ci-dessous réalise la migration des fichiers à partir de la liste générée à l'étape précédente, le package io de Python est mis en œuvre dans ce programme.

%PYTHON_ROOT%> python D:\projets\py\migrate-utf8.py > D:\projets\py\migrate-utf8.log
D:\projets\py\migrate-utf8.py
import os
import io
import fnmatch
import csv

liste_iso_txt = r'D:\Projets\python\liste-iso88591.txt'

with open(liste_iso_txt, 'r') as csvfile:
    liste = csv.reader(csvfile)
    for filename in liste:
        
        print("Processing %s..." % (filename[0]))

        with io.open(filename[0]) as f:
           try:
              data = f.read()
              f.close()
           except:
              print("KO Opening file %s ..." % filename[0])
              
        with io.open(filename[0],'w',encoding='utf-8') as f:
            try:
                f.write(data)
                f.close()
            except:
                print("KO writing file %s in UTF 8..." % filename[0])
            else:
                print("OK writing file %s in UTF 8..." % filename[0])
Processing D:\www\development\sqlpacv2\prp_upd_subscribe.php...
OK writing file D:\www\development\sqlpacv2\prp_upd_subscribe.php in UTF 8...
Processing D:\www\development\sqlpacv2\prp_upd_suggest.php...
OK writing file D:\www\development\sqlpacv2\prp_upd_suggest.php in UTF 8...

Les données des fichiers sont lues et réécrites avec l'encodage UTF-8 (with io.open(filename[0],'w',encoding='utf-8')). Dans de nombreux forums sur ce sujet, vous trouverez souvent la syntaxe :

with io.open(filename[0],'w',encoding='utf-8-sig') as f:

Cet encodage utf-8-sig correspond bien à l'unicode UTF-8 mais avec une signature (ou BOM : Byte Order Mark). Il s'agit d'une petite signature non visible (quelques caractères) en début du fichier : la bibliographie du Web semble indiquer que Windows préfère les fichiers UTF-8 avec une signature, cependant cette signature peut être problèmatique lors de l'inclusion avec la fonction include en PHP de fichiers contenant cette signature. La signature est dans ce contexte interprétée en sauts de ligne par PHP. L'application d'une signature est à l'appréciation du concepteur selon l'environnement du site.

Vérifier la migration de tous les fichiers en UTF-8 avec la commande find :

find /d/www/development -regex '.*\.\(css\|htm\|html\|inc\|js\|json\|php\|xml\)' -exec file -i {} \;  |
grep 'iso-' | wc -l
0

Si des erreurs se sont produites, après investigations, répéter l'opération depuis l'étape 1 (génération de la liste). Pourquoi regénérer la liste ? Quelques tests ont montré que réécrire un fichier déjà au format UTF-8 plusieurs fois de suite avec ce programme a des effets de bord assez dramatiques.

Opérations Post Migration

La migration de centaines de fichiers s'est déroulée sans erreur et est à présent terminée. Pourtant, lors des tests : surprises, des caractères bizarres sont affichés. C'est normal, les fichiers sont en UTF-8 mais le code HTML ou PHP référence encore l'encodage ISO-8858-1.

<meta content="text/html; charset=iso-8859-1" http-equiv="content-type" >
resultats migration

Plus que 2 opérations et c'est terminé.

  • Remplacement de l'encodage ISO-8859-1 par UTF-8 dans les balises meta des fichiers *.htm et *.html.
  • Remplacement de l'encodage ISO-8859-1 par UTF-8 dans les scripts PHP qui génèrent dynamiquement des balises meta et xml (pour la création de fichiers XML, RSS, Sitemaps...).

Exemples de code PHP qui définit un encodage pour la création de fichiers XML, RSS, Sitemap :

<?php
   ...
   echo "  <meta http-equiv=\"Content-Type\" content=\"text/html; charset=iso-8859-1\">\n";
   ...
   $xmlString  = "<?xml version=\"1.0\" encoding=\"iso-8859-1\" ?>\n";
   ...
   $xmlrss  = "<?xml version=\"1.0\" encoding=\"iso-8859-1\"?>\r\n";
   ...
?>

En fonction du volume à migrer, plusieurs options pour remplacer ces ocurrences :

  • Avec votre éditeur de code préféré (NotePad++ ou Komodo Edit).
  • Avec les utilitaires de MingW.

La deuxième option est réalisée ici car plus de 230 fichiers sont concernés.

Un script shell assez simple, migrate-uft8.sh, s'occupe de migrer dans les fichiers *.htm, *.html, *.php et *.inc les balises meta et <?xml qui implémentent le jeu de caractères ISO-8859-1 pour les remplacer par UTF-8.

migrate-utf8.sh
#!/bin/bash

dir="/d/www/development"

files=`find ${dir} -regex '.*\.\(htm\|html\|php\|inc\)' -print  | xargs egrep -Hi "<.*iso-8859-1" | awk -F":" '{print $1}' | sort -u`

for file in ${files}
do
        echo "Processing "$file"..."
        cat $file | awk '{ if (/<\?xml|<meta.*http-equiv/) { gsub("iso-8859-1","utf-8"); } print; }' > $file.$$.tmp
        rm $file
        mv $file.$$.tmp $file
done

Dernière étape optionnelle, si la migration vers PHP 5.6 avait été réalisée avant la migration vers UTF-8 et que la fonction ini_set avait du être appliquée pour forcer l'encodage ISO-8859-1 avec les fonctions htmlentities et htmlspecialchars, rebasculer vers l'encodage UTF-8. Dans les règles de l'art, cette fonction est appelée dans un script général inclus par tous les scripts PHP, son appel devrait être centralisé.

<? php
...
   ini_set("utf-8");
...
?>

Migrations UTF-8 et MySQL

La migration est terminée et pourtant des caractères bizarres persistent dans les pages. Vérifier alors l'encodage de la base de données utilisée (ici MySQL), il y a fort à parier d'une incompatibilité avec l'encodage de la base de données :

show variables like 'collation%';
+----------------------+-------------------+
| Variable_name        | Value             |
+----------------------+-------------------+
| collation_connection | latin1_swedish_ci |
| collation_database   | latin1_swedish_ci |
| collation_server     | latin1_swedish_ci |
+----------------------+-------------------+
3 rows in set (0.00 sec)

C'est le cas ici, l'encodage par défaut de la base de données est défini à latin1_swedish_ci (proche de l'encodage ISO-8859-1) mais les pages en UTF-8.

L'encodage UTF-8 pour les échanges doit être spécifié lors de la connexion à la base de données :

<?php
  $objRessource = mysqli_connect(....);
  mysqli_set_charset($objRessource,"utf8");
?>

Cette connexion est normalement centralisée dans un script PHP unique, si ce n'est malheureusement pas le cas, ajouter mysqli_set_charset dans tous les scripts PHP qui effectuent des connexions à la base.

Conclusion

Il ne reste plus que les tests de non régression.

Migrer des centaines de fichiers en ANSI/ISO-8859-1 d'un site Web vers l'unicode UTF-8, la tâche pouvait paraître ardue au départ, mais les utilitaires de MingW (find, file...) couplés avec le package io de Python réalisent la migration en un rien de temps. Le package chardet de Python pour la détection des encodages est très intéressant, mais il apparaît à travers cette étude ici qu'il ne permet pas d'atteindre les 100% contrairement à la commande file.