• 6 heures
  • Difficile

Ce cours est visible gratuitement en ligne.

course.header.alt.is_certifying

J'ai tout compris !

Mis à jour le 30/11/2016

Créez et utilisez vos propres annotations

Connectez-vous ou inscrivez-vous gratuitement pour bénéficier de toutes les fonctionnalités de ce cours !

Nous avons vu, dans le chapitre précédent, les différentes annotations vous permettant de créer vos propres annotations ainsi que les annotations standards.
Nous avons aussi vu les règles de création et d'utilisation des annotations et nous avons même créé nous-mêmes quelques annotations.

Dans ce chapitre je vous propose de voir plus en profondeur comment créer des annotations personnalisées.

Règles de création d'annotations

Nous allons voir quelles sont les règles qui régissent la création d'annotations personnalisées.
Comme nous l'avons vu, une annotation est définie par une interface notée @interface qui se construit ainsi :

Portée @interface NomDeLAnnotation{

}

Nous avons déjà construit quelques annotations dans le chapitre précédent, comme celle-ci :

public @interface AnnotationZ { }

Pour vous entraîner, je vous propose de travailler dans ce chapitre avec une annotation qui va être utilisée pour lister les choses à faire dans un code source, ce qu'on appelle les TODO... Nous allons donc créer et utiliser l'annotation suivante tout au long du chapitre et du chapitre suivant :

public @interface Todo{ }

Nous nous en servirons pour illustrer l'utilisation de l'outil de gestion des annotations fourni dans le JDK...

Nous allons maintenant voir qu'il est possible d'ajouter des éléments dans nos annotations. Ces éléments seront utilisés par la suite dans les différents outils ou codes source qui traiteront les annotations afin de déterminer les choses à faire ou à générer. Les éléments d'une annotation se représentent de la sorte :

Portée @interface NomDeLAnnotation{
   Déclaration de l'élément 1;
   Déclaration de l'élément 2;
   ...
   Déclaration de l'élément N;
}

Chaque déclaration correspond en fait à un type de donnée mais il y a une restriction sur les types utilisables. Les seuls types de données autorisés sont :

  • String;

  • primitif (int, double, boolean, char...);

  • enum;

  • Class;

  • d'autres annotations.

Voici un exemple de déclaration d'une annotation avec les différents types d'éléments :

public @interface MonAnnotation {
   //Définition d'un élément
   String monChamp1();
   
   //Définition d'un élément avec une valeur par défaut
   String monChamp2() default "valeur par défaut du champ";
   
   //Utilisation d'une énumération
   NIVEAU niveau() default NIVEAU.MINEUR;
   
   //Déclaration d'un tableau
   int[] tableauEntier();
   
   //Déclaration d'un tableau
   char [] tableauCaractere() default {'A', 'D'};
   
   //Utilisation d'une annotation déjà créée
   AnnotationZ annotation();
   
   //Utilisation d'un objet Class
   Class uneClasse() default Double.class;
}

J'ai au préalable créé une annotation et une énumération (que nous allons réutiliser plus tard) :

public enum NIVEAU {
   MINEUR("Action mineure"), 
   AMELIORATION("Amélioration possible"),
   BUG("Bug à corriger rapidement"),
   CRITIQUE("Bug critique à corriger d'urgence !");
   
   private String description;
   NIVEAU(String desc){
      description = desc;
   }
   
   public String toString(){
      return description;
   }
}

Je pense que la syntaxe utilisée ne vous aura pas échappée... Nous déclarons des éléments comme des méthodes d'interfaces...

Maintenant que vous avez vu comment déclarer des éléments dans nos annotations, voyons comment les spécifier dans des classes. Voici donc une classe d'exemple de classe précisant les détails d'une annotation :

public class TestMonAnnotation {

   //Seul les champs qui n'ont pas de valeurs par défaut sont renseignés ici
   @MonAnnotation(
         annotation = @AnnotationZ, 
         monChamp1 = "Valeur quelconque", 
         tableauEntier = { 0 }         
   )   
   public String sayHello(){
      return "Hello ! ";
   }
   
   
   //Cette fois nous avons renseigné tous les champs de l'annotation
   @MonAnnotation(
         niveau = NIVEAU.AMELIORATION,
         annotation = @AnnotationZ,
         tableauCaractere = {'Z', 'c'}, 
         monChamp1 = "Valeur de champ 1", 
         tableauEntier = { 0 },
         monChamp2 = "Mon champ 2",
         uneClasse = String.class
   )
   public void doNothing(){}
   
}

C'est très simple, n'est-ce pas ? Maintenant que vous êtes familier avec cette syntaxe, Java vous offre un raccourci possible pour des annotations n'ayant qu'un seul élément : l'utilisation d'un élément ayant comme nom "value" vous permet de ne pas spécifier son nom dans les classes utilisant cette annotation. Voici un exemple simple pour vous faire comprendre :

public @interface Test { 
   NIVEAU value();
}

public @interface TestBis {
   double value(); // Attention, pas l'objet Double ! !
}

public @interface TestTer {
   Class value(); 
}

Et voici une classe qui utilise ces trois annotations :

public class TestValue {

   //Annotation avec un élément value de type enum
   //----------------------------------------------
   @Test(value = NIVEAU.CRITIQUE)   
   public void methode1(){ }   
   
   @Test(NIVEAU.BUG)//Ecriture courte
   public void methode2(){ }

   //Annotation avec un élément value de type double
   //----------------------------------------------
   @TestBis(value = 51.45687d)
   public void methode3(){ }
   
   @TestBis(12.34d)//Ecriture courte     
   public void methode4(){ }
   
   //Annotation avec un élément value de type Class
   //----------------------------------------------
   @TestTer(value = Boolean.class)
   public void methode5(){ }
   
   @TestTer(Double.class)//Ecriture courte 
   public void methode6(){ }   
}

Ceci peut être très pratique...
Bon, il n'y a rien de compliqué à créer des annotations, mais ce qui devient plus intéressant, c'est de voir comment les exploiter. C'est ce que je vous propose de faire maintenant !

Utilisation de l'API Pluggable Annotation Processing

Dans ce chapitre, nous allons voir comment utiliser vos annotations avec l'outil Pluggable Annotation-Processing, fourni dans le JDK 6. Mais avant de faire ceci, nous devons avoir une annotation utilisable et utilisée... Je vous avais parlé de l'annotation @Todo, le moment est venu de compléter cette dernière et de l'utiliser.

Déjà, tout comme vous réfléchissez à ce que doivent faire vos objets avant de les créer, vous devez avoir ce même type de réflexion pour vos annotations.

Alors, concrètement, que devra faire l'annotation @Todo ?

  • Elle devra permettre la génération d'un document reprenant les différents éléments marqués;

  • elle permettra de spécifier l'auteur du TODO;

  • elle spécifiera aussi le destinataire du TODO ainsi qu'un commentaire;

  • elle définira un niveau de criticité (la fameuse énumération utilisée en peu plus tôt...).

Maintenant, vous devez vous demander sur quels éléments de vos codes sources cette annotations devra être utilisée : les méthodes, les champs, les constructeurs, tous ?
Ici, ce sera tous ! Nous nous passerons donc de la méta-annotation @Target...
À quel moment du cycle de vie de notre programme l'annotation sera utilisée ? Au niveau du code source, Pluggable Annotation-Processing utilise les code source, les analyse et traite les annotations qui y sont présentes. Nous aurons donc la méta-annotation @Retention(RetentionPolicy.SOURCE).
Nous souhaitons aussi que les éléments annotés se propagent aux enfants, nous utiliserons donc @Inherit.
Nous allons aussi la faire apparaître dans la JavaDoc, donc @Documented sera utilisée.

Voici donc notre annotation @Todo complète :

package com.sdz.annotation;

import java.lang.annotation.Documented;
import java.lang.annotation.Inherited;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;

@Documented
@Retention(RetentionPolicy.SOURCE)
@Inherited
public @interface Todo {
   NIVEAU niveau() default NIVEAU.BUG;
   String auteur() default "cysboy";
   String destinataire();
   String commentaire();
}

Voici maintenant quelques classes où j'ai utilisé cette annotation :

package com.sdz.classes;

import com.sdz.annotation.NIVEAU;
import com.sdz.annotation.Todo;

public class MaClasse1 {

   @Todo(
         auteur = "zozor",
         niveau = NIVEAU.AMELIORATION,
         commentaire = "Tu ferais mieux d'utiliser un double...", 
         destinataire = "cysboy"
   )
   private short entier = 0;
   
   @Todo(
           commentaire = "Penser à faire les initialisations...", 
           destinataire = "zozor"
   )
   public MaClasse1(){ }   
}
package com.sdz.classes;

import com.sdz.annotation.NIVEAU;
import com.sdz.annotation.Todo;

@Todo(
      niveau = NIVEAU.CRITIQUE,
      commentaire = "Il faudrait penser à terminer la classe.", 
      destinataire = "zozor"
)
public class MaClasse2 extends MaClasse1{

}
package com.sdz.classes;

import com.sdz.annotation.NIVEAU;
import com.sdz.annotation.Todo;

public class MaClasse3 {

   public void doSomething( 
         @Todo(
               niveau = NIVEAU.BUG,
               commentaire = "Vérifier le contenu de ce paramètre", 
               destinataire = "cysboy"
         )
         String str){
      //....
   }   
}

Pour les besoins futurs, j'ai rajouté une notion de niveau via un entier dans l'énumération NIVEAU. Voici la dite énumération en entier :

package com.sdz.annotation;

public enum NIVEAU {
   MINEUR("Action mineure", 0), 
   AMELIORATION("Amélioration possible", 1),
   BUG("Bug à corriger rapidement", 2),
   CRITIQUE("Bug critique à corriger d'urgence !", 3);
   
   private int level = -1;
   private String description;   
   NIVEAU(String desc, int lev){
      description = desc;
      level = lev;
   }
   
   public String toString(){
      return description;
   }
   
   public int getLevel(){
      return level;
   }
}

Nous allons maintenant nous atteler à utiliser l'API dont je vous parlais plus tôt... C'est maintenant que les choses se corsent...
Cette API s'utilise avec le compilateur, via la commande "javac " : ceci signifie qu'il vous faut un JDK d'installé et que vous ayez ajouté le répertoire "bin" de ce JDK dans la variable d'environnement PATH de votre système d'exploitation. Ce que nous allons faire, à partir de maintenant, c'est définir un point d'accès pour le compilateur pour qu'il accède à une méthode qui va se charger de traiter les annotations que nous avons choisi de traiter. Ceci se fait grâce un objet qui hérite de la classe AbstractProcessor où nous allons redéfinir la méthode process().
Ceci fait, nous allons devoir créer une archive .jar contenant :

  • cet objet;

  • un fichier qui informe le compilateur de l'endroit où se trouve l'objet (ou les objets) à utiliser pour traiter les annotations;

  • la définition des annotations à traiter.

Cette archive va empaqueter les différents processeurs qui vont analyser nos codes sources, ceci va nous permettre de choisir le processeur que nous souhaitons utiliser. Je vous propose de créer deux objets héritant d'AbstractProcessor : un qui affiche les informations générées dans la console et un autre qui générera un fichier HTML simple.
Dès que nous aurons tout ceci, il ne nous restera plus qu'à lancer le compilateur avec, en argument, le processeur à utiliser et l'endroit où se trouvent les fichiers sources à analyser. Vous êtes prêt ?

Étape 1 : création de nos processeurs

Comme je vous l'ai dit, nous allons devoir créer un objet par type de traitement et nous devrons redéfinir la méthode process() de l'objet AbstractProcessor . Cette méthode est utilisée par le compilateur et reçoit toutes les annotations que vous souhaitez traiter mais aussi un objet de type RoundEnvironment qui contient certaines informations dont nous allons avoir besoin, notamment la liste des éléments annotés avec l'annotation que nous souhaitons traiter.

Voici les deux classes que j'ai utilisées pour utiliser pour ce chapitre (j'ai commenté le premier code pour que vous compreniez ce qu'il se passe) :

package com.sdz.processors;

import java.util.Set;
import javax.annotation.processing.AbstractProcessor;
import javax.annotation.processing.RoundEnvironment;
import javax.annotation.processing.SupportedAnnotationTypes;
import javax.annotation.processing.SupportedSourceVersion;
import javax.lang.model.SourceVersion;
import javax.lang.model.element.Element;
import javax.lang.model.element.TypeElement;

//Important, il faut que la (ou les) annotations traitées soient connues 
//et accessibles !
import com.sdz.annotation.Todo;

//Permet de spécifier les annotations à traiter
@SupportedAnnotationTypes(value = { "com.sdz.annotation.Todo" })
//Définit quelle version de source gérer, ici je code en Java 7
@SupportedSourceVersion(SourceVersion.RELEASE_7)
public class TodoProcessor extends AbstractProcessor {
 
  //La fameuse méthode à redéfinir
  @Override
  public boolean process(
      Set<? extends TypeElement> annotations,
      RoundEnvironment roundEnv) { 
     
    System.out.println("Début du traitement console !");
     
    //Nous parcourons toutes les annotations concernées par ce processeur
    for (TypeElement te : annotations) {
     System.out.println("Traitement annotation " 
                          + te.getQualifiedName());
 
      //Permet de récupérer tous les éléments annotés avec l'annotation en cours
      for (Element element : roundEnv.getElementsAnnotatedWith(te)) {
        String name = element.getClass().toString(); 
        
        
        System.out.println("----------------------------------");
        //Permet de savoir quel type d'élément est annoté (constructeur, paramètre, classe...)
        System.out.println("\n Type d'élément annoté : " + element.getKind() + "\n");
        
        //retourne le nom de l'élément annoté, le nom de la variable, le nom de la classe...
        System.out.println("\t --> Traitement de l'élément : "+ element.getSimpleName() + "\n");
        
        //Différentes informations sur l'élément annoté
        System.out.println("enclosed elements : " + element.getEnclosedElements());
        System.out.println("as type : " + element.asType());
        System.out.println("enclosing element : " + element.getEnclosingElement() + "\n");
        
        //Nous récupérons notre annotation
        Todo todo = element.getAnnotation(Todo.class);
 
        //Si elle n'est pas null, on traite son contenu
        if (todo != null) {
           
           //On récupère le contenu de l'annotation comme n'importe quel objet Java
           System.out.println("\t\t Auteur : " + todo.auteur());
           System.out.println("\t\t Destinataire : " + todo.destinataire());
           System.out.println("\t\t Commentaire : " + todo.commentaire());
           System.out.println("\t\t Niveau : " + todo.niveau());           
        }
      }
    } 
    return true;
  }
}

Et voici le processeur qui génère un document HTML :

package com.sdz.processors;


import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.Iterator;
import java.util.List;
import java.util.Set;

import javax.annotation.processing.AbstractProcessor;
import javax.annotation.processing.RoundEnvironment;
import javax.annotation.processing.SupportedAnnotationTypes;
import javax.annotation.processing.SupportedSourceVersion;
import javax.lang.model.SourceVersion;
import javax.lang.model.element.Element;
import javax.lang.model.element.TypeElement;

import com.sdz.annotation.Todo;

//Permet de spécifier les annotations à traiter
@SupportedAnnotationTypes(value = { "com.sdz.annotation.Todo" })
//Défini quelle version de source gérer, ici je code en Java 7
@SupportedSourceVersion(SourceVersion.RELEASE_7)
public class TodoHTMLProcessor extends AbstractProcessor {
   
  List<Todo> list;
  FileOutputStream fw = null;
  
  @Override
  public boolean process(
      Set<? extends TypeElement> annotations,
      RoundEnvironment roundEnv) {
         
    list = new ArrayList<>();    
    
    System.out.println("Début du traitement HTML !");
    
    //tout ceci est identique à l'autre processeur
    for (TypeElement te : annotations) {
 
      for (Element element : roundEnv.getElementsAnnotatedWith(te)) {
        String name = element.getClass().toString(); 
        
        Todo todo = element.getAnnotation(Todo.class);
 
        if (todo != null) {
           //Sauf que nous ajoutons les annotations dans un liste 
           //que nous traiterons plus tard
           list.add(todo);       
        }
      }
    }
    System.out.println("Fin du traitement HTML");
    
    //Génération du fichier HTML
    genererHTML(list);
    return true;
  }
  
  private void genererHTML(List<Todo> list){
     
     StringBuilder html = new StringBuilder();
     html.append("<html>");     
     html.append("<body>");
     html.append("<table>");
     
     html.append("<tr>");
     html.append("<td style=\"border:1px solid black\">Criticité</td>");
     html.append("<td style=\"border:1px solid black\">Auteur</td>");
     html.append("<td style=\"border:1px solid black\">Destinataire</td>");
     html.append("<td style=\"border:1px solid black\">Commentaire</td>");
     html.append("</tr>");
     
     Iterator<Todo>  it = list.iterator();
     
     if(list.isEmpty())return;
     
     File htmlFile = new File("Todo.html");
     
     try {
        fw = new FileOutputStream(htmlFile);
     } catch (IOException e) {
       e.printStackTrace();
     }
     
     while(it.hasNext()){
        
        Todo todo = it.next();
        html.append("<tr>");
        String style = "";
        
        //Voilà à quoi sert le nouveau champ de mon énumération
        switch(todo.niveau().getLevel()){
           case 0 : 
              style = "style=\"color:green;border:1px solid black\"";
           break;
           case 1:
              style = "style=\"color:purple;border:1px solid black\"";
           break;
           case 2:
              style = "style=\"color:orange;border:1px solid black\"";
           break;
           case 3:
              style = "style=\"color:red;border:1px solid black\"";
           break;
        }
        
        html.append("<td " + style + ">" + todo.niveau() + "</td>");
        html.append("<td " + style + ">" + todo.auteur() + "</td>");
        html.append("<td " + style + ">" + todo.destinataire() + "</td>");
        html.append("<td " + style + ">" + todo.commentaire() + "</td>");
        html.append("</tr>");
     }
     
     html.append("</table>");
     html.append("</body>");     
     html.append("</html>");

     //On écrit dans le fichier et voilà !
     try {
      fw.write(html.toString().getBytes());
     } catch (IOException e) { 
      e.printStackTrace();
     }finally{
        try{
           fw.close();
        } catch (IOException ex) { 
           ex.printStackTrace();
           
        }
     }
  }  
}

Passons à l'étape suivante.

Étape 2 : création de l'archive .jar

Maintenant que nous avons nos classes, nous devons créer une archive. Si vous n'avez jamais créé d'archive avec Eclipse, voici la marche à suivre.
Faites un clic-droit sur votre projet puis choisissez le menu "Export". Dans la popup ouverte, sélectionnez "JAR File" dans le dossier Java, comme ceci :

Menu Export

Sélectionnez les objets qui vont traiter les sources, dans mon projet ils sont dans le package com.sdz.processors, ainsi que les annotations que vous voulez traiter. Chez moi, elles sont dans le package com.sdz.annotation. Choisissez un chemin où vous voulez exporter cette archive puis cliquez sur le bouton Finish, comme ceci :

Création de l'archive

Pour des raisons de simplicité, j'ai décidé de créer l'archive dans le dossier contenant mon projet, ceci pour simplifier les lignes de commandes pour l'exécution.
Ceci fait, il ne nous reste plus qu'à l'utiliser...

Étape 3 : décollage !

Nous avons nos processeurs, notre archive, il ne reste plus qu'à traiter les codes sources...
Nous allons devoir faire une dernière manipulation avant de lancer la ligne de commande : récupérer tous les fichiers sources Java — enfin, leur chemin sur votre PC car la commande que nous allons utiliser ne traite pas de façon récursive le chemin que vous lui passez en paramètre : nous allons donc devoir l'aider un peu...
L'astuce que nous allons utiliser revient à lister tous les chemins de vos sources à analyser dans un fichier texte et à fournir ce fichier texte à notre commande finale.

Quoi ! J'ai codé 23 classes pour m'amuser, je ne vais pas renseigner le chemin de toutes ces classes ! en plus elles sont dans plein de packages différents !

Je n'allais pas vous demander de faire ceci à la main... Votre PC est là pour ça... :-°

Que vous soyez sous Linux ou Windows (désolé les personnes sous Mac, je ne connais pas du tout cet OS !), je vous invite à ouvrir une invite de commande.

Sous Windows, faites Windows + R (ou "Démarrer/exécuter") et tapez "cmd ".
Je ne ferai pas l'affront aux Linuxiens de donner les instructions pour ouvrir un shell... :p

Sous Windows, pour lister les fichiers sources, je vous demande d'aller dans le dossier contenant vos sources avec la commande "cd ".
Allez chercher l'endroit où se trouvent vos sources via l'explorateur Windows, comme ceci :

Image utilisateur

Copiez ce chemin en le sélectionnant et en en faisant Ctrl + C ou clic-droit/copier

Tapez maintenant la commande cdsuivie d'un espace, puis collez le chemin d'accès en faisant clic-droit/coller directement dans l'invite de commande, puis validez en appuyant sur Entrée.

Nous sommes maintenant dans le dossier contenant nos sources. Sous Windows, pour lister les fichiers qui nous intéressent, nous allons utiliser cette commande :

dir /s /B *.java > sources.txt

Cette commande va chercher tous les fichiers qui se terminent par ".java" (donc nos fichiers sources) dans le dossier courant les sous-dossiers, et envoie tout les résultats trouvés dans le fichier sources.txt. Pratique non ?
Lancez la commande et ouvrez ledit fichier : il y a bien tous les fichiers source dedans !

Trop fort !

Attendez, nous devons encore travailler dessus... Il faut s'assurer qu'il n'y ait pas d'espaces dans les chemins : il faut donc entourer tous nos chemins avec des guillemets " et, une fois ceci fait, nous devons modifier tous les "\" par des "/" : avec un Ctrl + H sous Notepad++ par exemple.

Après tout ce que nous venons de faire, voici à quoi ressemble mon fichier sources.txt :

"E:/Mes Documents/JAVA API/ANNOT-2.2/src/com/sdz/annotation/NIVEAU.java"
"E:/Mes Documents/JAVA API/ANNOT-2.2/src/com/sdz/annotation/Todo.java"
"E:/Mes Documents/JAVA API/ANNOT-2.2/src/com/sdz/classes/MaClasse1.java"
"E:/Mes Documents/JAVA API/ANNOT-2.2/src/com/sdz/classes/MaClasse2.java"
"E:/Mes Documents/JAVA API/ANNOT-2.2/src/com/sdz/classes/MaClasse3.java"
"E:/Mes Documents/JAVA API/ANNOT-2.2/src/com/sdz/processors/TodoHTMLProcessor.java"
"E:/Mes Documents/JAVA API/ANNOT-2.2/src/com/sdz/processors/TodoProcessor.java"

Récapitulons :

  • nous sommes dans le dossier contenant notre projet ;

  • notre .jar contenant les processeurs est présent dans ce dossier ;

  • nous avons un fichier contenant le chemin vers tous les fichiers source à analyser.

Maintenant la commande à exécuter pour lancer le processeur :

javac -processorpath Processor.jar -processor com.sdz.processors.TodoProcessor @sources.txt

Et voici ce que vous obtenez :

Résultat de la commande javac

Voici le détail de la commande :

  • javac : commande lançant le compilateur Java;

  • processorpath Processor.jar : indique à la commande javac  que les processeurs sont dans l'archive Processor.jar;

  • -processor com.sdz.processors.TodoProcessor : informe le compilateur que la classe à utiliser pour traiter les annotations sera com.sdz.processors.TodoProcessor;

  • @sources.txt : fichier contenant le chemin de tous les fichiers source à traiter.

Maintenant, pour exécuter le deuxième processeur, il vous suffit de relancer la même commande en modifiant le nom du processeur à exécuter :

javac -processorpath Processor.jar -processor com.sdz.processors.TodoHTMLProcessor @sources.txt

Ce qui nous donne ceci dans la console :

Excution du processeur générant un fichier HTML

Et nous avons maintenant un fichier HTML dans notre dossier qui, lorsque vous l'ouvrez avec votre navigateur, devrait vous donner quelque chose comme ça :

Fichier HTML généré par le processeur

Et voilà ! Vous avez appris à créer et utiliser des annotations.
Mais l'API Pluggable Annotation Processing permet aussi, grâce à l'introspection, de créer à la volée de nouvelle classes, interfaces... Je ne détaillerai pas tout ce fonctionnement sinon cette partie se transformerait en cours complet mais vous connaissez un peu mieux les possibilités de cette API.

Je vous propose de voir une autre manière d'utiliser nos annotations mais avant cela, que diriez-vous d'un petit TP ?

Ce chapitre a été riche en nouveautés et assez complexe, je vous l'accorde mais, je vous rassure, vous n'aurez pas besoin de cette API tous les jours.
Il était tout de même intéressant de voir comment elle fonctionne et il était bon de savoir qu'elle vous permet aussi de générer autre chose que des fichiers HTML... :-°

Sur ce, vous êtes d'attaque pour un TP ?

Exemple de certificat de réussite
Exemple de certificat de réussite