Perl, expressions régulières et Unicode : exemples d'utilisation

Cours GIM 2012-2013, n°6, 18 décembre 2012

Jean-François Perrot

version provisoire du 9/01/2013

  1. Préliminaires : chaînes et caractères, guillemets et échappements
    1. Distinction entre chaînes et caractères
    2. Chaînes constantes et chaînes évaluées
    3. Autres
    4. Besoin de s'échapper...

  2. Principes pour Perl
    1. Codage des caractères composant les textes de programmes
    2. Caractères individuels
    3. Classes de caractères
    4. Exemples simples

  3. Utilisation pour divers problèmes de transcodage
    1. Questions d'entrées/sorties
    2. Représentation en ASCII par le numéro Unicode
      1. Principe
      2. Emploi d'une expression régulière avec les flags 'eg'
    3. Transformation inverse : ASCII --> Unicode
    4. Représentation en ASCII par les octets UTF-8
      1. La fonction utf8::encode
      2. Application : obtention d'une forme simplifiée de la représentation quoted-printable :
      3. Application : une version simplifiée du codage URL
    5. Transformation inverse : ASCII-utf8 --> Unicode
      1. La fonction utf8::decode

  1. Préliminaires : chaînes et caractères, guillemets et échappements

  2. Principes pour Perl

    1. Codage des caractères composant les textes de programmes

      • En n'importe quel langage de programmation, les textes de programmes doivent être codés entièrement en ASCII.
        D'éventuelles relaxations de cette contrainte sont le fait de certains compilateurs et ne se généralisent pas.
        Seuls les commentaires peuvent (à la rigueur) comporter d'autres caractères.

        Cette règle s'applique notamment aux constantes de chaînes (entre guillemets) et aux expressions régulières.
        C'est pourquoi des transcriptions vers l'ASCII figurent dans les grammaires de tous les langages,
        comme
        • '\ule-numéro-Unicode-en-hexadécimal' en Java (guillemets simples pour un caractère isolé)
        • "\ule-numéro-Unicode-en-hexadécimal" en Javascript (guillemets doubles car Javascript ne connaît que les chaînes)
        • "\x{le-numéro-Unicode-en-hexadécimal}" en Perl (guillemets simples ou doubles suivant le cas)

      • Le texte d'un script Perl peut aussi être codé en UTF-8, à condition de déclarer en début de script "use utf8".
        Ceci permet d'employer tout le catalogue Unicode dans sa rédaction, y compris dans les constantes de chaînes et dans les expressions régulières,
    2. Caractères individuels

      • en ASCII (toujours possible)
        en Perl, un caractère quelconque peut toujours être désigné en ASCII par "\x{le-numéro-Unicode-en-hexadécimal}"
        Par exemple '\x{4E0D}\x{540C}' (avec les guillemets simples ou doubles) est l'écriture Perl-ASCII du mot chinois "不同" (en pinyin bùtóng = différent)

      • en UTF-8 : si on déclare en début de script "use utf8",
        on peut se passer de l'écriture ASCII et écrire directement "不同" (par exemple) dans une expression régulière

      • Exemple : deux versions du même script, qui choisit dans un texte les lignes où apparaît le mot "不同"

        ASCII
        UTF8
        use strict;
        use warnings;

        sub extrmotif
        ($$){ # 2 noms de fichiers
        my ($fichin, $fichout) = @_;

            open(ENTREE, "<:utf8", "$fichin");
            open(SORTIE, ">:utf8", "$fichout");

        #*************************************** 
            my $er = '\x{4e0d}\x{540c}';
        #*************************************** 
            my @tablignes = <ENTREE>;
            my @tabnb = grep(/$er/, @tablignes);
            foreach my $lgn ( @tabnb ){
                print(SORTIE $lgn);
            }
        }#extrmotif

        extrmotif($ARGV[0], $ARGV[1]);
        use strict;
        use warnings;
        use utf8;

        sub extrmotif($$){ # 2 noms de fichiers
        my ($fichin, $fichout) = @_;

            open(ENTREE, "<:utf8", "$fichin");
            open(SORTIE, ">:utf8", "$fichout");

        #***************************************
            my $er = '不同';
        #*************************************** 
            my @tablignes = <ENTREE>;
            my @tabnb = grep(/$er/, @tablignes);
            foreach my $lgn ( @tabnb ){
                print(SORTIE $lgn);
            }
        }#extrmotif

        extrmotif($ARGV[0], $ARGV[1]);



    3. Classes de caractères

      Références :

      Suite de l'exemple précédent : une exp. reg. qui sélectionne les chaînes de deux caractères chinois dont le premier est U+4e0d ''.

      • en ASCII : $er = '\x{4e0d}\p{Han}';

      • avec "use utf8" : $er = '不 \p{Han}';

      Détail technique :
      Si vous souhaitez écrire une classe de caractères comme \p{Han} dans une chaîne évaluée (entre guillemets doubles),
      n'oubliez pas de doubler la controblique ! et d'écrire \\p{Han}.

      Explication :
      Perl considère que tout caractère précédé d'une controblique est spécial, par exemple :
      • \x introduit un caractère donné par son numéro (en Java et en Javascript, c'est \u qui joue ce rôle),
      • \a déclenche une sonnerie (bell)
      • \n représente le saut de ligne, etc
      • et \\ est évalué en \ simple.
      Liste complète ici.
      Si le caractère en question ne figure pas dans la liste, Perl le transmet tel quel, et imprime un message d'avertissement.

      Or \p n'a pas de rôle spécial !
      Il sera donc évalué comme p tout nu, non sans avoir provoqué une protestation :
      Unrecognized escape \p passed through at monscript.pl line xxx.

      Doubler la controblique aura pour effet que lors de l'évaluation le digramme \\ sera remplacé par \ simple,
      et que le calcul se poursuivra avec le \p désiré.

      Donc, pour construire l'exp. reg. précédente en séquence, suivant la bonne méthode, on écrira :
      1. my $debut = '\x{4e0d}';
      2. my $er = "$debut\\p{Han}";

      Cette observation vaut pour tous les escapes qui entrent dans la composition des exp. reg. mais ne relèvent pas de la théorie générale des chaînes en Perl. Dans le doute, il est prudent de doubler toutes les controbliques dans les chaînes évaluées !
    4. Exemples simples

      On reprend les scripts ci-dessus, en modifiant seulement l'exp. reg. mise en jeu.

      1. Dans un texte comme celui-ci, trouver les lignes contenant des quadrigrammes chinois, ou des mots grecs :

        my $er = '\p{Han}{4}|\p{Greek}+';

        Notez que pour obenir les lignes contenant à la fois des quadrigrammes chinois, et des mots grecs, il faudrait s'y prendre à deux fois :
        filtrer d'abord l'un, ensuite l'autre.

      2. Dans un texte comme celui-là, trouver les lignes contenant des caractères qui s'écrivent de droite à gauche :

        my $er = '\p{BidiClass:R}+|\p{BidiClass:AL}+';

        On ne peut pas se contenter de BidiClass:R (contrairement à ce qu'on peut lire un peu partout)
        vu les multiples valeurs que peut prendre la propriété BidiClass : voyez Wikipedia.
        En fait, la valeur 'R' ne ne vise que l'hébreu !
        On lui ajoute BidiClass:AL pour avoir aussi l'arabe, le syriaque et le thaana (écriture en usage aux Maldives).
        Dans la mesure où nous négligeons les caractères de contrôle, et si nous ne nous intéressons pas aux chiffres,
        cela nous suffit.


  3. Utilisation pour divers problèmes de transcodage

    Rappelons que le codage des caractères concerne la manière de décoder sous forme de caractères les différents flux d'octets qui circulent :

    En revanche, lors de l'exécution d'un programme Perl, les chaînes sont représentées en mémoire d'une manière propre à Perl,
    qui ne dépend pas du codage qui, éventuellement, affectait les sources d'où elles proviennent.

    Cela dit, deux types de problèmes se posent :
    1. S'assurer que lorsque Perl lit une chaîne (provenant du disque, du réseau ou du clavier), il la décode correctement,
      et que réciproquement lorsque Perl écrit une chaîne (sur disque, vers le réseau, ou à l'écran), il emploie un codage adéquat.
      C'est un problème d'entrées/sorties (alias I/O, pour Input/Output)

    2. À partir d'une chaîne en mémoire, engendrer différentes manières de la représenter.
      Par exemple, obtenir sa représentation sous la forme ASCII rappelée ci-dessus : le mot chinois '不同' devenant '\x{4E0D}\x{540C}'
      ou sous forme d'entités HTML-XML : '&#x4E0D;&#x540C;'.
      Et réciproquement !

      On peut ranger aussi dans cette catégorie les problèmes de translittération.
      Par exemple, passer d'une chaîne en devanâgari comme 'इन्दिरा गांधी' à sa version "romanisée" 'indirā gāndhī' (et réciproquement).

    Les expressions régulières de Perl, associées à quelques fonctions standard, fournissent un moyen puissant pour traiter tous ces problèmes.

    1. Questions d'entrées/sorties

      Perl a plusieurs façons de traiter ces questions.
      Notamment, par des options sur la ligne de commande (cf. les options de perlrun) dont nous ne dirons rien ici.
      Nous nous limitons à des procédés entièrement écrits dans le texte du programme.

      • Entrées/sorties sur fichier :
        La question se règle à l'ouverture des fichiers :
          open(ENTREE, "<:utf8", "$fichin");
          open(SORTIE, ">:utf8", "$fichout");

      • Entrées/sorties standard (clavier/écran) :
        Comme STDIN et STDOUT sont toujours ouverts, la technique ci-dessus est inapplicable. À sa place :
        binmode(STDIN, ":utf8");
        binmode(STDOUT, ":utf8");
        évitera les avertissements "Wide character in print..."

        Mais attention ! Le réglage de STDIN ne concerne que les octets qui sont obtenus par des ordres de lecture explicites,
        et non pas ceux qui viennent de la ligne de commande !

      • Ligne de commande : tableau @ARGV :
        Les arguments @ARGV[0], @ARGV[1] etc sont lus comme des chaînes d'octets.
        Si ces octets ne sont pas de l'ASCII, il faut les décoder avant toute utilisation !
        Pour cela on utilise la fonction decode_utf8 du module Encode.

        Voici une réalisation possible :
        en supposant que le script extrmotif de la section précédente doive s'appliquer à des noms de fichiers non-ASCII,
        on remplacera la commande finale par les quatre lignes :

        use Encode 'decode_utf8';
        $ARGV[0] = decode_utf8($ARGV[0], 1);
        $ARGV[1] = decode_utf8($ARGV[1], 1);
        extrmotif($ARGV[0], $ARGV[1]);

        Le second argument des appels à la fonction decode_utf8 a pour effet de provoquer un arrêt immédiat si par malheur la chaîne d'octets est mal codée.
        Si vous êtes plus savant, vous pouvez résumer les deux décodages en un décodage collectif, valable quel que soit le nombre d'arguments :
        @ARGV = map { decode_utf8($_, 1) } @ARGV;

        Attention ! Pour observer les phénomènes en jeu sans vous tromper, ne vous contentez pas de demander l'impression des données lues.
        Car la version non-modifiée du script vous dira sans hésiter :
        jfp% perl extrmotif.pl ἀνέχου.txt ἀπέχου.txt
        ἀνέχου.txt, ἀπέχου.txt

        mais c'est simplement parce que les octets lus en entrée sont fidèlement restitués en sortie...
        D'ailleurs, l'absence d'avertissement "Wide character in print..." devrait vous mettre la puce à l'oreille !

        La différence se verra si vous demandez binmode(STDOUT, ":utf8");
        jfp%  perl extrmotif.pl ἀνέχου.txt ἀπέχου.txt
        ἀνέχου.txt, ἀπέχου.txt

        ou si vous avez la curiosité d'imprimer aussi la longueur des prétendues chaînes de caractères, qui ne sont en fait que des chaînes d'octets...

    2. Représentation en ASCII par le numéro Unicode

      type : '不同' transformé en
      • '\x{4E0D}\x{540C}' (Perl)
      • "\u4e0d\u540c" (Java ou Javascript)
      • '&#x4E0D;&#x540C;' (HTML-XML hexa)
      • '&#19981;&#21516;' (HTML-XML décimal)

      Principe
      La transformation consiste à répéter pour chaque caractère de la chaîne la tansformation élémentaire qui associe à chaque caractère sa représentation ASCII sous forme échappée ou sous forme d'entité.

      L'ingrédient de base de la transformation élémentaire est la fonction ord qui à un caractère quelconque c associe son n° dans la catalogue Unicode.
      Mais il faut lui ajouter l'écriture de l'entier ord(c) en hexadécimal (le plus souvent) ou en décimal.
      Cette écriture s'obtient en Perl par la fonction sprintf, avec comme chaîne de format '%x' pour de l'hexa, '%X' pour de l'hexa en majuscules, ou '%d' pour du décimal.
      Pour nos quatre exemples, les transformations élémentaires s'écrivent donc
      • c --> '\x{'.sprintf('%X', ord(c)).'}'
      • c --> '\u'.sprintf('%x', ord(c))
      • c --> '&#x'.sprintf('%X', ord(c)).';'
      • c --> '&#'.sprintf('%d', ord(c)).';' 

      On peut donc programmer la chose de manière systématique.
      Voici comment traiter notre premier exemple (script string2Perl0.pl)

      use strict;
      use warnings;

      sub car2chn($){ #caractère --> chaîne
      my( $c ) = @_;
          return ('\x{'.sprintf('%X', ord($c)).'}');
      }#car2chn

      sub chn2chn($){ #chaîne --> chaîne
      my( $chn ) = @_;

          my @tabcar = split(//, $chn);
          my $res = '';
           foreach my $c ( @tabcar ){
              $res .= car2chn($c)
          }
          return $res;
      }#chn2chn

      use Encode 'decode_utf8';
      $ARGV[0] = decode_utf8($ARGV[0], 1);
      print(chn2chn($ARGV[0]));



      Même procédé pour les trois autres exemples dont nous sommes partis !
      Emploi d'une expression régulière avec les flags 'eg'
      La réalisation ci-desssus a l'avantage d'être limpide,
      mais il est plus élégant - et plus efficace - de recourir au mécanisme d'évaluation des exp. reg. de Perl :

      sub chn2chn($){ #chaîne --> chaîne
      my( $chn ) = @_;
          $chn =~ s/(.)/car2chn($1)/eg;
          return $chn;
      }#chn2chn


      La combinaison des flags 'e' et 'g' permet de réaliser une boucle "sur toutes les occurrences des sous-chaînes filtrées par l'exp.reg.".
      Ici, les occurrences en question sont "tous les caractères", mais nous verrons bientôt des situations plus complexes,
      pour lesquelles le recours à l'expression régulière est indispensable.
      Il est donc important de bien comprendre le fonctionnement de ce mécanisme !
      Voyez-le à l'œuvre dans le script string2Perl.pl.

    3. Transformation inverse : ASCII --> Unicode

      Par exemple, à partir de la chaîne '&#x4E0D;&#x540C;', retrouver le mot chinois '不同'.

      Il s'agit d'extraire les numéros Unicode logés au sein des entités, puis de convertir les chaînes extraites en entiers, puis de convertir les entiers en caractères.
      La dernière étape est assurée par la fonction chr, inverse de ord : pour un entier n donné, chr(n) est le caractère dont n est le n° Unicode. On a donc, pour tout caractère c, c = chr(ord(c)), et pour tout entier n < 1 114 112, n = ord(chr(n)).

      Mais comment convertir en nombre une chaîne représentant un nombre ?
      • Si la représentation est décimale, Perl convertit automatiquement en fonction du contexte ;
        en l'occurrence, le fait de se trouver en position d'argument de la fonction chr suffit à provoquer la conversion.
      • Si la représentation est hexadécimale, il faut recourir à la fonction hex qui effectue la conversion désirée.

      L'extraction des numéros Unicode est une application typique des parenthèses capturantes des exp. reg. de Perl !

      Notre projet se réalise donc en une ligne ! Voici pour les entités en hexa :

      sub chnHex2chn($){ #chaîne --> chaîne
      my( $chn ) = @_;
          $chn =~ s/&#x([0-9A-F]+);/chr(hex($1))/eg;
          return $chn;
      }#chnHex2chn



      Et pour les entités en décimal, c'est encore plus simple !

      sub chnDec2chn($){ #chaîne --> chaîne
      my( $chn ) = @_;
          $chn =~ s/&#(\d+);/chr($1)/eg;
          return $chn;
      }#chnDec2chn



    4. Représentation en ASCII par les octets UTF-8

      Il s'agit de codages utilisés  lors de l'envoi de chaînes à travers le réseau, en raison de la convention qui déclare que seuls des octets ASCII sont assurés de circuler sans dommage (cf. cours n°3).
      Nous connaissons le codage quoted-printable, où la chaîne
      "J'espère qu'il est conforme à la réglementation..."
      devient
      "J'esp=C3=A8re qu'il est conforme =C3=A0 la r=C3=A9glementation..."

      Le codage "URL" est de la même famille. Il est employé dans la confection des messages échangés par le protocole HTTP, notamment pour écrire des URLs entièrement en ASCII.
      Notre chaîne s'écrit dans ce système :
      "J%27esp%C3%A8re+qu%27il+est+conforme+%C3%A0+la+r%C3%A9glementation..."
      On voit que, comme en quoted-printable, les octets non-ASCII sont représentés par leur valeur en hexa, mais précédée de '%' et non par '='.
      En outre, certains caractères ASCII sont traités comme spéciaux, notamment le blanc remplacé par '+', le guillemet simple codé '%27', etc.

      La fonction utf8::encode
      Dans les deux cas le problème principal est de trouver les octets UTF-8 à partir de la chaîne telle que la connaît Perl.
      C'est le rôle de la fonction Perl utf8::encode, dont voici le principe :
      puisqu'on ne peut pas manipuler en Perl des octets en tant que tels (blocs de 8bits), on va les remplacer par des caractères assimilables à des octets,
      c'est-à-dire par des caractères dont le numéro Unicode s'écrit en un seul octet, à savoir le jeu de caractères Latin-1.
      La fonction utf8::encode,
      • prend comme argument est une chaîne de caractères
      • la transforme en la suite de caractères d'un seul octet chacun (Latin-1) correspondant aux octets du codage UTF-8.
      • cette transformation se fait in situ, c'est-à-dire que la chaîne-argument elle-même est modifiée.

      Observons la chose : (script demo_encode.pl)
      use strict;
      use warnings;

      sub montrer($){ #chaîne
      my( $chn ) = @_;

          my @tab = split(//, $chn);
          foreach my $c ( @tab ){
              print ("$c - ".sprintf('%X', ord($c))."\n");
          }
          print "-------\n";
      }#montrer

      sub observer($){ #chaîne
      my( $chn ) = @_;

          montrer($chn);
          utf8::encode($chn);# Hic jacet lepus !
          montrer($chn);
      }#observer

      binmode(STDOUT, ":utf8");
      use Encode 'decode_utf8';
      $ARGV[0] = decode_utf8($ARGV[0], 1);
      observer($ARGV[0]);



      Exécution : le mot grec καὶ en UTF-8 = CEBA CEB1 E1BDB6.
      jfp% perl demo_encode.pl καὶ
      κ - 3BA
      α - 3B1
      ὶ - 1F76
      -------
      Î - CE
      º - BA
      Î - CE
      ± - B1
      á - E1
      ½ - BD
      ¶ - B6
      -------


      Pour en savoir davantage, voyez encode/decode de la doc. Perl.
      Application : obtention d'une forme simplifiée de la représentation quoted-printable :
      script string2QP.pl

      use strict;
      use warnings;

      sub traiter_octet($){ #caractère d'un octet
      my( $c ) = @_;

          my $val = ord($c); # entier
          if( $val < 128 ){ #ASCII
              return chr($val);
          }else{
              return '='.sprintf('%X', $val);
          }
      }#traiter_octet

      sub toQP($){ #chaîne
      my( $chn ) = @_;

          utf8::encode($chn);
          $chn =~ s/(.)/traiter_octet($1)/eg;
          return $chn;
      }#observer

      use Encode 'decode_utf8';
      $ARGV[0] = decode_utf8($ARGV[0], 1);
      print toQP($ARGV[0]);
      print "\n";



      Exécution :
      jfp% perl string2QP.pl "J'espère qu'il est conforme à la réglementation..."
      J'esp=C3=A8re qu'il est conforme =C3=A0 la r=C3=A9glementation...


      Application : une version simplifiée du codage URL
      Une version simplifiée du "codage URL" s'obtient sur le même modèle, en réécrivant la fonction traiter_octet :
      script string2URL.pl

      sub ordinaire($){ #entier
      my( $val ) = @_;

          return($val<128         #ASCII
              && $val != 0x27     #simple quote
              && $val != 0x22     #double quote
              #etc
          )
      }#ordinaire

      sub traiter_octet($){ #caractère d'un octet
      my( $c ) = @_;

          my $val = ord($c); # entier
          if( $val == 0x20 ){
              return '+';
          }else{
              if( ordinaire($val) ){ #ASCII non spécial
                  return chr($val);
              }else{
                  return '%'.sprintf('%X', $val);
              }
          }
      }#traiter_octet

      Le reste comme ci-dessus



      Exécution :
      jfp% perl string2URL.pl "J'espère qu'il est conforme à la réglementation..."
      J%27esp%C3%A8re+qu%27il+est+conforme+%C3%A0+la+r%C3%A9glementation...


    5. Transformation inverse : ASCII-utf8 --> Unicode

      De la même manière que pour la transformation inverse ASCII --> Unicode, nous obtiendrons facilement la chaîne de caractères Perl d'un seul octet représentée par notre chaîne-source en ASCII.
      La fonction utf8::decode
      Le passage aux caractères Unicode supposés ainsi codés en UTF-8 se fait grâce à la fonction Perl utf8::decode,
      qui est l'inverse de utf8::encode, dont nous avons fait nos choux gras au paragraphe précédent.
      On peut aussi la voir comme une variante de la fonction decode_utf8 que nous employons  pour passer une chaîne utf8 comme argument sur la ligne de commande (ce genre de doublons est courant en Perl).

      Comme utf8::encode, son inverse utf8::decode opère in situ et transforme son argument, remplaçant les séquences d'un, deux, trois ou quatre caractères d'un seul octet par les caractères ainsi codés en UTF-8 eux-mêmes.
      Pour l'observer en acte, il suffit de remplacer dans le script demo_encode.pl l'appel à utf8::encode par un appel à utf8::decode :
      jfp% perl demo_decode.pl καὶ
      Î - CE
      º - BA
      Î - CE
      ± - B1
      á - E1
      ½ - BD
      ¶ - B6
      -------
      κ - 3BA
      α - 3B1
      ὶ - 1F76
      -------

      Comme prévu, la chaîne de caractères absconse 'καὶ' représente le mot grec 'καὶ' codé en UTF-8.

      En application directe, voici une fonction Perl qui décode une chaîne répondant aux spécifications :
      Content-Type: text/plain; charset="utf-8"
      Content-Transfer-Encoding: quoted-printable



      sub qp2utf8($){ #chaine en quoted-printable
      my ( $chn ) = @_;

          my $er = '=([0-9A-F]{2})';
          $chn =~s/$er/chr(hex($1))/eg;
          utf8::decode($chn);
         
          return $chn;
      }#qp2utf8