mardi 5 décembre 2017

Filtrer les guillements parasites d'un fichier CSV destiné à être importé dans une base de donnée

Les imports de bases de données à l'aide de fichiers CSV sont toujours critiques, car de nombreux facteurs peuvent déraper. Pour rappel, un fichier CSV est:
  • 1 fichier texte
  • dont chaque ligne correspond à un enregistrement d'une table
  • et dont les lignes sont découpées en colonne par un délimiteur
Pour importer correctement un fichier CSV, il faut se mettre d'accord sur de nombreux éléments: le caractère délimiteur de colonne (souvent le ;), le ou les délimiteurs de ligne (CR-LF sous DOS, LF sous unix, ou CR pour d'autre), et sur le "charset" (la table de correspondance ASCCI, UTF8, unicode, etc...).

Malheureusement, il faut également se préoccuper du caractère délimiteur des chaines de caractères que l'on trouve dans la plupart des colonnes d'un CSV, comme par exemple des libellés. Traditionnellement, on utilise les guillemets (") pour délimiter une colonne de type texte (chaine de caractère).  Ainsi, une ligne de fichier CSV peut se présenter comme cela:
"CODEnnn";"xxxxxxxxx xxxx xxxx"
Le problème est que très souvent, il y a des caractères guillemets dans ces colonnes textes. Voici un exemple d'un CSV de 2 colonnes, contenant des guillemets parasites:
"CODEnnn";"xxxxxxxxx "xxxx" x""xxx""
Dans ce cas, l'utilitaire d'import ne pourra pas fonctionner correctement. Le but de cet article est de filtrer ces caractères guillemets parasites, présents dans des colonnes d'un CSV.

Pour filtrer ces guillemets parasites, utilisons l'utilitaire SED, outils unix rapide et pratique pour ce genre d'actions. SED applique des actions ligne par ligne sur des fichier texte. L'action (la commande) SED qui nous intéresse est la substitution de caractère. On va demander à SED, de remplacer tous les guillemets parasites par une simple quotte ('). On demande à SED de réaliser cette substitution uniquement sur les guillemets précédés par un caractère différent de ";", et suivi par un caractère lui aussi différent de ";". Le ";" étant le délimiteur de colonne du fichier CSV. En résumé, tous les guillemets seront remplacés, sauf ceux en début de ligne, sauf ceux en fin de ligne, et sauf ceux qui se présente comme cela (";"), cad entourés par au moins 1 point-virgule.

La syntaxe SED que j'utilise est la suivante:
sed -f instructions.sed fichier_CSV
Je préfère utiliser la syntaxe permettant d'écrire les instructions (les commandes) SED dans un fichier, car il est plus facile à éditer, et permet d'insérer des commentaires salutaires pour une relecture quelques jours ou quelques mois plus tard.

La syntaxe de la commande de substitution que j'utilise est la suivante:
s/Exp_Reg_chaine_à_remplacer/nouvelle_chaine/g
Le dernier caractère "g" signifie que cette action de substitution se fera pour toutes les sous-chaines de la ligne respectant l'expression régulière.

L'expression régulière dans notre cas est relativement simple:
[^;]"[^;]
Cela correspond à une sous-chaine de 3 caractères, dont le premier et le dernier ne sont pas un ;, et dont le deuxième est obligatoirement un guillemet.
Cela donne une commande sed:
s/\([^;]\)"\([^;]\)/\1'\2/g

\1 et \2 correspondent respectivement au premier caractère et au 3ème caractère de la sous-chaine correspondante à l'expression régulière. Il suffit de mettre cette commande dans un fichier texte, et d’appeler la commande SED
sed -f instruction.sed fichier.csv > fichier_filtre.csv

Malheureusement, cette commande est inefficace sur certains caractères guillemet parasite.
Comme par exemple cette ligne CSV:
"CODEnnn";"xxxxxx"""""xxx "xxxx" x""xxx""

L'explication est simple: le parseur d'expression régulière ne s'applique pas 2 fois sur la même zone texte. Quand une expression régulière est trouvé par le parseur, la zone trouvée est supprimée pour les recherches suivante du parseur (pour la même instruction sed). Pour une chaine de 4 guillemets consécutifs (xx""""xx), le parseur détectera le premier guillemet, mais pas les deux suivants. Il détectera tout de même le 4 ième.

Pour résoudre le problème, il faut appliquer la commande de substitution 2 fois pour obliger le parseur à refaire la recherche. Il faut même relancer une 3ème fois pour supprimer le cas de guillemets parasites qui serait encore une fois espacés d'un seul caractère.
s/\([^;]\)"\([^;]\)/\1'\2/g  # 1er fois
s/\([^;]\)"\([^;]\)/\1'\2/g  # une 2ème fois
s/\([^;]\)"\([^;]\)/\1'\2/g  # 3ème et dernière fois

Et pour finir, il faut gérer également le problème des CR-LF dans SED. Si votre fichier CSV vient de DOS, SED va considérer le CR (code ASCII 13) comme un caractère comme un autre. Ce qui déclenchera la substitution de dernier guillemet. Il faut donc supprimer préalablement le CR:

s/\x0D$//g

Ne pas oublier si nécessaire de réaliser la commande inverse.
Pour être complet, penser à filtrer les ; en fin de ligne. Le CSV peut parfois avoir ce genre de colonne vide. 2 choix s'offre à vous: les supprimer
 s/;$//
ou les remplacer
 s/;$/; / # on ajoute un espace

Tout ça pour ça. Voici le fichier d’instruction SED pour filtrer nos guillemets parasites:
s/\x0D$//g    # conversion fichier DOS => unix (\r = 13 en ascii)
s/;$// # suppression des ; en fin de ligne
s/\([^;]\)"\([^;]\)/\1'\2/g # conversion des " en ' si le précédent n'est pas ;, ni le suivant
s/\([^;]\)"\([^;]\)/\1'\2/g # à refaire pour les "" contigus
s/\([^;]\)"\([^;]\)/\1'\2/g # à faire en tout 3 fois
s/$/\r/    #conversion fichier texte unix => DOS

La dernière ligne peut poser une soucis selon le SED que vous utilisez. Si votre sed est un exécutif unix, pas de soucis. En revanche, si c'est une version DOS, cela peut poser problème, car ce dernier ajoutera en fin de ligne des CR en plus du LF. Si c'est le cas, voici la commande pour supprimer les CR à postériori:
sed -i "s/\r//" fichier_filtré.csv


L'histoire serait belle si seulement, il n'y avait le cas des délimiteurs parasites.
Exemple de CSV avec 2 colonnes seulement:
"CODEnnnnn";"libellé ";" titre"
Dans ce cas, les 2 guillemets parasites ne seront pas filtrés par la commande sed. Concrètement, les sous-chaines de 2 caractères associant 1 guillemet et 1 point-virgule rendrons inefficace la commande sed conçu ci-dessus. Pour ce dernier cas, je n'ai pas de solution simple. D'ailleurs, comment humainement faire la différence entre un délimiteur et un simple caractère dans ce cas précis: la ligne précédente peut autant être interprétée comme une ligne de 2 colonnes ou une ligne de 3 colonnes. De quoi rendre fou un outil d'import de CSV. Ce cas particulier mérite un article à part entière.

Contentons-nous aujourd'hui de jouir de notre ignorance concernant ce cas perfide, et rappelons nous que dans tout voyage, le chemin est plus important que la destination ;)


PS :
Pour info, voici la commande SED permettant de vérifier la présence de caractère parasite:
/[^;]"[^;]/!d
Cette commande signifie "supprimez toutes les lignes qui ne contienne pas au moins une sous-chaines de 3 caractère décrite par l'expression régulière en question.

liens;
Exemples sed d'une grande clarté 
Introduction rapide à sed
Explication approfondie du fonctionnement de sed
Bases pour comprendre les Expressions Régulières
Boite à outils sed (liste d'exemples très variés)

vendredi 3 novembre 2017

HTML et le désespoir des TABLE en folie: IMG dont la hauteur ne veut pas s'adapter verticalement dans un TD

C'est le drame du HTML, langage qui se voulait simple à l'origine, et qui avec le temps est devenu complexe. Avant de pleurer sur le temps perdu et la nostalgie informatique, voici le drame qui me chagrine.

En ressortant un vieux développement PHP, j'ai découvert que le code HTML basé sur un tag TABLE classique provoquait un affichage aberrant. Voici le principe en pseudo HTML de cet affichage:
<TABLE>
<TR><TD> <IMG src="coin_0.gif"></TD>
<TD> <IMG src="barre_h_h.gif"> </TD>
<TD> <IMG src="coin_1.gif"></TD> </TR>
<TR><TD> <IMG src="barre_v_g.gif" /></TD>
<TD>...texte sur n lignes....</TD>
<TD> <IMG src="barre_v_d.gif" /></TD> </TR>
<TR><TD> <IMG src="coin_3.gif"></TD>
<TD> <IMG src="barre_h_b.gif"></TD>
<TD> <IMG src="coin_2.gif"></TD> </TR>
</TABLE>

Je sais que beaucoup vont parler de code archaïque (en insistant sur le caractère péjoratif), mais j'aime la simplicité, et la logique implacable d'un TABLE sur un DIV, ou autres éléments HTML servant pour les mises en page. Mais cela est une autre histoire. Le code ci dessus marchait à l'aide des attributs permettant de réduire le "border", le "spacing", ou autre "margin" ( border="0" cellspacing="0" cellpadding="0"). Pour que les images s'adaptent parfaitement à la taille dynamique des cellules du tableau, il suffisait de mettre des "height="100%" ou width="100%" sur les bons TD. Le principe est de mettre des dimensions fixes pour les 4 "coins", et de mettre ensuite 100% sur les witdh des images devant s'élargir horizontalement, et 100% sur les "height" des images devant s'élargir en hauteur. En revanche, la case du milieu contenant le texte sur plusieurs lignes, est sans dimensions.

Cela donnait tout simplement cela:
La même chose avec un "border=1" pour visualiser les 9 cases de mon tableau:

Pour la petite histoire, ce type de code HTML, me permettait d'afficher simplement des graphsets, de plusieurs types, avec des bords ronds, carrées ou autres. En final, j'ai amèrement découvert que mon code HTML après quelques années de mise en sommeil donnait ce résultat :

Le plus étonnant, c'est que les images s'adaptent parfaitement en largeur (sauf 0.2px sur la droite, Dieu sait pourquoi), mais seulement partiellement sur la hauteur. En utilisant l'inspecteur d'éléments dans le navigateur, on constate une ligne blanche en dessous de l'image, et une autre au dessus, interprétées par l'inspecteur comme un "padding" (en vert sur la copie d'écran ci-dessus).
Pour Tenter de résoudre mon problème, j'ai donc nettoyé le code en utilisant des propriétés CSS à la place des attributs (souvent devenu obsolètes, mais opérationnel théoriquement). Voici le code en question:

<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2//EN">
<HTML> <HEAD> <style type="text/css"> table { border-collapse: collapse; /* Colle les bordures entre elles */ border-spacing: 0; margin: 0 0 0 0 ; padding: 0 0 0 0 ;
border: 0; } td { margin: 0; padding: 0 ; border-spacing: 0; } tr { margin: 0; padding: 0 ; border-spacing: 0; } </style> <TITLE>Table avec images extensibles </TITLE> </HEAD> <BODY> <BR> <BR> <CENTER> <TABLE> <tbody> <TR><TD width="19" height="19"><IMG alt="" width="19" height="19" src="el/fin_coin_0.gif"></TD> <TD height="19"><IMG alt="" height="19" width="100%" src="el/fin_barre_h_h.gif"></TD> <TD width="19" height="19"><IMG alt="" width="19" height="19" src="el/fin_coin_1.gif"></TD> </TR> <TR><TD width="19" height="100%"><IMG alt="" width="19" height="100%" src="el/fin_barre_v_g.gif" /></TD> <TD>Les plus désespérés sont les chants les plus beaux,<BR>Et j'en sais d'immortels qui sont de purs sanglots.<BR><BR>La muse<BR>Alfred de Musset (1810-1857)</TD> <TD width="19" height="100%" style="font-size: 0;line-height:0;"><IMG style="font-size: 0;line-height:0;" alt="" width="19" height="100%" src="el/fin_barre_v_d.gif" /></TD> </TR> <TR><TD width="19" height="19"><IMG alt="" width="19" height="19" src="el/fin_coin_3.gif"></TD> <TD height="19"> <IMG alt="" height="19" width="100%" src="el/fin_barre_h_b.gif"></TD> <TD width="19" height="19"><IMG alt="" width="19" height="19" src="el/fin_coin_2.gif"></TD> </TR> </tbody> </TABLE> </CENTER> </BODY>


Je peux vous assurer avoir essayé toutes les propriétés CSS possibles sur les TD et IMG pour faire disparaitre ces deux lignes blanches non désirées, sans succès. (display, box-sizing, line-height, font-size, ...). J'ai tenté également de mettre un DIV pour encapsuler l'IMG, avec un "height" à 100%, sans succès.

Après quelques heures éprouvantes, j'ai testé le même code HTML sur un linux, les tests précédents ont été réalisés sous Windows 7 avec Opera, Chrome, IE. Et bingo, le code HTML s'affichait correctement avec firefox. De nouveau de retour sur W7, et bing encore: Firefox affiche correctement le code HTML. La malchance d'avoir testé sur 3 navigateurs, et d'oublier FF.

Maintenant j'ai une piste. Le problème vient donc de certains navigateurs, certainement racistes envers les tag TABLE. Je m'en doutais, car depuis l'inquisition espagnole, on a rarement vue un tel sectarisme religieux concernant ces pauvres TABLE parmi la communauté HTML.

Me voila donc reparti sur une recherche de solution pour tous les navigateurs.
Suite au prochain épisode (je l'espère, sinon cela veut dire que le sectarisme a gagné ;)

Zip contenant l'exemple