Génération de PDF par template HTML/CSS3 avec Pebble et Prince

Préambule

La génération de PDF à la volée est un sujet qui revient souvent dans le développement d’une application backend afin d’organiser et de présenter des données exploitables par des humains. La forme et la complexité des documents générés variant selon leur utilisation, il arrive parfois que leur design soit imposé afin de respecter une charte, un modèle administratif ou des contraintes liées au métier.

Dans un projet récent, notre équipe a été confrontée à plusieurs de ces problèmes, à savoir :
Une génération de documents PDF à la demande (contrainte de rapidité),
La structure d’un document peut changer radicalement en fonction du contenu (contenu modulaire),
Un design complexe (placement d’éléments relatifs, dimensions dynamiques...).

L’outil de génération historique à ce moment était Jaspersoft. Efficace pour des rapports simples, la rédaction des templates est souvent longue, et les templates les plus complexes sont difficiles à maintenir et à faire évoluer.
Conscient de ces problèmes, nous avons donc étudié des solutions alternatives, plus aptes à répondre aux contraintes citées précédemment, et avons finalement choisi PrinceXML (rebaptisé Prince). A titre d’information, nous avions également envisagé d’utiliser Jaspersoft (solution historique), PDFReactor et DocRaptor (solution en ligne).

Que peut vous apporter Prince ?

Prince est un outil de génération de PDF qui a la particularité d’utiliser du HTML5 et des règles CSS3 pour offrir un rendu proche de celui d’un navigateur web. Il permet donc de positionner et dimensionner des éléments dans le document facilement, de profiter d’effets de styles avancés simplement (border-radius, gradient, shadow, transform...), le tout en utilisant des langages connus.

Il est aussi important de préciser que Prince est payant, le détail des prix des licences se trouve à l’adresse suivante : www.princexml.com/purchase. Il est possible d’utiliser Prince gratuitement à des fins non commerciales, mais vous verrez alors apparaître un filigrane dans le coin supérieur droit des PDF générés.

Performance

Lorsque l’on doit générer des documents à la volée, le temps que prend ladite génération est un facteur important. Nous avons testé les temps de génération de la librairie avec un template HTML dans lequel on injectait du texte, pour un résultat de plus de 250 pages de texte, quelques images et une mise en page simple. Cette volumétrie étant largement au-dessus de nos cas d’utilisation standard. La transformation de HTML vers PDF ne prenait que quelques secondes (< 10s dans le pire des cas), nous étions bien plus performants que la génération en place, d’un facteur de 10 environ.

L’utilisation du CSS3

Nous en avons parlé plus haut, l’un des points forts de la librairie est l’utilisation de HTML5 et CSS3 pour styliser le contenu du document. Si vous ne voyez pas quels avantages cela représente, en voici quelques-uns :

  • Les prérequis à la création de template : La grande majorité des personnes impliquées dans le développement d’applications aujourd’hui connaissent le HTML et le CSS. S’affranchir d’un énième langage intermédiaire simplifie la maintenance tout en diminuant la courbe d'apprentissage.

  • Les fonctionnalités : vous pourrez appliquer à votre document tous les effets de style que vous pouvez faire en HTML/CSS (à quelques exceptions près), ainsi que les règles de positionnement et de dimensionnement dynamique (le conteneur s’adapte au contenu) récurrentes en CSS.

  • Le moteur de template : Il existe beaucoup de librairies de templating HTML Java qui vous permettront de produire votre contenu HTML en fonction de données fournies dynamiquement (velocity, mustache, FreeMarker, Thymeleaf, ...). Vous pourrez donc décorréler le traitement des données de la génération PDF.

  • Des templates réutilisables : la possibilité de générer un PDF à partir de fichiers HTML existants, dans certains cas, pourrait permettre de produire des PDF avec un effort et une surcouche technique minimes. Il faut toutefois garder en tête les limitations/contraintes apportées par Prince (gestion de la pagination, propriétés CSS non supportées...).

Les releases

Un des points noirs de la librairie est le rythme de parution des releases, qui est très irrégulier.
A la rédaction de cet article (septembre 2018) voici les dernières releases en date :

  • 12 (Juin 2018)
  • 11.4 (Juin 2018)
  • 11.3 (Août 2017)
  • 11.2 (Juillet 2017)
  • 11.1 (Février 2017)
  • 11 (Novembre 2016)

Il se peut que vous soyez contraint, comme nous l’avons été, à prendre le build “latest” pour profiter de fonctionnalités développées mais pas encore présentes dans une release.
Dans notre cas, nous avions besoin du support des flexbox, mais qui est maintenant disponible dans la release v12 de Prince. Vous pouvez retrouver la roadmap des évolutions incluses dans les builds datés à cette adresse.

Autre point gênant, les wrappers fournis par Prince, qui servent à faire l’interface entre votre code et les binaires installés sur la machine, ne sont pas disponibles sur un dépôt, et ne peuvent donc pas être simplement inclus dans les dépendances de votre projet.
Nous avons contourné ce problème en uploadant l’artefact téléchargeable depuis leur site sur le dépôt privé de l’entreprise, résolvant le problème d’intégration au build, mais nécessitant une action manuelle pour toute mise à jour du package.

Génération dynamique grâce aux templates

Afin de mettre en pratique ce que nous avons vu plus haut, nous utiliserons une application Java pour générer nos PDF, et le moteur de template Pebble pour générer le fichier HTML que nous fournirons à Prince.
Vous pouvez retrouver le code source de cette application à cette adresse. Le but de l’application est de produire un “weather dashboard” simple. Vous pourrez y retrouver tous les snippets présents dans cet article.

Il nous semble intéressant de faire un aparté sur Pebble, un moteur de template Java puissant et léger, que nous avons utilisé en lieu et place du moteur historique Velocity. La documentation et les exemples utilisant ce moteur se font encore rares, c’est donc l’occasion d’illustrer son utilisation.

Commençons par importer la dépendance dans notre projet. Voici un extrait de notre fichier build.gradle :

repositories {
    mavenCentral()
}

dependencies {
    ...
    compile 'io.pebbletemplates:pebble:3.0.3'
    ...
}

(Nous utilisons gradle pour compiler notre projet, mais vous pouvez utiliser l’outil de votre choix, la dépendance est accessible ici).

Voici la hiérarchie de notre projet :

.
├── build.gradle
├── gradle
│   └── wrapper
│       ├── gradle-wrapper.jar
│       └── gradle-wrapper.properties
├── gradlew
├── gradlew.bat
├── libs
│   └── prince.jar
├── README.md
├── settings.gradle
└── src
    └── main
        ├── java
        │   └── main
        │       └── fr
        │           └── ippon
        │           ├── bean
        │           ├── enums
        │           ├── generator
        │           │   └── PDFGenerator.java
        │           ├── Main.java
        │           ├── pebble
        │           │   ├── extensions
        │           │   ├── filters
        │           │   ├── functions
        │           │   └── PebbleEngineFactory.java
        │           └── util
        └── resources
            ├── app.properties
            └── templates
                ├── css
                │   ├── icon.css
                │   └── style.css
                ├── day-details.peb
                ├── forecast.peb
                ├── header.peb
                ├── index.peb
                └── summary.peb

Configuration du PebbleEngine

Une fois la dépendance importée, nous allons créer une factory capable de générer un PebbleEngine préconfiguré. C’est cet engine qui se chargera de parser les fichiers de template et de produire du HTML pur :

import com.mitchellbosecke.pebble.PebbleEngine;
import com.mitchellbosecke.pebble.loader.ClasspathLoader;
import com.mitchellbosecke.pebble.loader.Loader;

public class PebbleEngineFactory {

    /**
     * Generates a pebble engine with our extensions
     * and the "templates" path prefix.
     */
    public static PebbleEngine buildPebbleEngine() {
        final Loader loader = new ClasspathLoader();
	  // Our templates are in the "templates" folder
        loader.setPrefix("templates"); 
        return new PebbleEngine.Builder()
            .loader(loader)
            .build();
    }
}

Nous pourrons ensuite utiliser notre PebbleEngine de la manière suivante :

PebbleEngine engine = PebbleEngineFactory.buildPebbleEngine();
final PebbleTemplate template = engine.getTemplate("index.peb");
final Writer writer = new StringWriter();
template.evaluate(writer, context);

Vous pouvez ensuite utiliser l’objet Writer pour produire un fichier HTML parsé.
Il reste toutefois une inconnue dans le précédent snippet : la variable context. Cette variable vous permet de stocker les données qui peupleront vos templates, nous en parlerons plus en détail dans un autre chapitre.

Structure du template

La particularité de notre projet est de devoir produire un unique fichier HTML que l’on transformera en PDF. Cela ne nous empêche pas de créer plusieurs fichiers de template, que l’on rassemblera ensuite.
Nous l’avons vu dans la hiérarchie du projet plus haut, nous avons 5 templates Pebble (fichiers .peb) :

 └── templates
    ├── day-details.peb
    ├── forecast.peb
    ├── header.peb
    ├── index.peb
    └── summary.peb

le fichier index.peb nous sert ici de point d’entrée et d’inclusion du reste de nos templates :

<!doctype html>

<html>
 <head>
   <title>Weather forecast</title>
   <style>
     {% include "css/style.css" %}
     {% include "css/icon.css" %}
   </style>
 </head>
 <body>
     <section class="landscape">
       <div class="today-container column-flex">
         {% include "header.peb" %}
         {% include "summary.peb" %}
         {% include "day-details.peb" %}
       </div>
     </section>
     <section class="portrait">
       {% include "forecast.peb" %}
     </section>
 </body>
</html>

Le contexte Pebble

Vous pourrez passer vos données au PebbleEngine par le biais d’une variable context. Voici comment cela fonctionne :

final List<Day> dayList = DayUtils.generateRandomDayList(15);
final Map<String, Object> context = new HashMap<>();
context.put("today", dayList.get(0));
context.put("forecast", dayList.subList(1, dayList.size()));
context.put("currentHour", LocalTime.now().getHour());
context.put("hourKeys", IntStream.range(
    MIN_HOUR, MAX_HOUR).boxed().collect(Collectors.toList()));

Le contexte est une Map dont les clés, des String, correspondent aux noms des variables qui seront accessible dans le template. Dans l’exemple plus haut, j’aurai donc accès à 4 variables : “today”, “forecast”, “currentHour” et “hourKeys”.
Certaines de ces variables sont des objets complexes et non des types primitifs. Nous allons donc voir comment accéder au contenu de ces objets dans un template.
Si je veux par exemple accéder à l’attribut humidity de mon objet today, il me suffit d’utiliser la syntaxe :

<span class="big-text">{{ today.humidity }}</span>

L’attribut humidity de la classe Day est privé, il faut donc passer par le getter correspondant, l’attribut lui-même étant inaccessible. Contrairement à ce que la syntaxe suggère, Pebble n’accède pas à l’attribut directement. Lorsque vous écrivez ceci, l’engine tente d’accéder à la donnée en essayant dans l’ordre :

today.get("humidity") //If today is a map
today.getHumidity()
today.isHumidity()
today.hasBar()
today.humidity()
today.humidity

L’engine utilisera donc ici today.getHumidity().
Si maintenant nous voulons accéder à une donnée plus profonde, il nous suffit de chaîner les appels en conservant cette syntaxe :

<span class="caption">Speed: {{ today.wind.speed }} Km/h - 
{{ today.wind.origin.toString }}</span>

Les filtres et les fonctions

Il est possible que vous ayez besoin de produire de nouvelles données à la volée dans le template en vous basant sur les variables du contexte. C’est à ce moment que les fonctions et les filtres Pebble interviennent. Bien que leur implémentation soit très semblable, c’est surtout leur utilisation qui diffère. Un filtre est utilisé pour altérer une donnée, alors qu’une fonction est utilisée pour générer du nouveau contenu.
Il existe déjà des filtres et fonctions prédéfinis dans Pebble, tels que max, title, date ou encore first.

Prenons l’exemple d’un filtre qui remplace les caractères “-” par des espaces dans une String. Voici comment il est utilisé :

<span>{{ day.weather.toString | removeHyphen }}</span>

Ici on passe la string résultante de l’appel de weather.toString() au filtre via un pipe.

Et voici comment il est implémenté :

public class RemoveHyphenFilter implements Filter {

   @Override
   public Object apply(Object input, Map<String, Object> args,
       PebbleTemplate self, EvaluationContext context, int lineNumber)
       throws PebbleException {
       if (!(input instanceof String)) {
           return input;
       }
       return ((String) input).replace("-", " ");
   }

   @Override
   public List<String> getArgumentNames() {
       return Collections.emptyList();
   }
}

Un filtre Pebble doit implémenter l’interface com.mitchellbosecke.pebble.extension.Filter et implémenter les méthodes apply et getArgumentNames. La donnée passée via le pipe dans le template est placée dans la variable input de la méthode apply. Nous n’utilisons pas d’argument ici, mais il est possible de passer des arguments à un filtre, c’est le cas du filtre date par exemple (code source ici).
Les filtres ont pour but d’être chaînés, un peu à la manière des opérations de stream en Java :

<span>{{ day.weather.toString | removeHyphen | title }}</span>

Le filtre title met la première lettre de tous les mots de la chaîne en majuscule.

Afin de pouvoir utiliser ce filtre dans un template, il est nécessaire de l’ajouter dans l’engine via une extension. Pour cela, nous avons créé la classe PebbleExtension, qui permet d’ajouter filtres et fonctions :

public class PebbleExtension extends AbstractExtension {

   @Override
   public Map<String, Filter> getFilters() {
       Map<String, Filter> filters = new HashMap<>();
       filters.put("removeHyphen", new RemoveHyphenFilter());
       return filters;
   }

   @Override
   public Map<String, Function> getFunctions() {
       Map<String, Function> functions = new HashMap<>();
       functions.put("getMaximumTemperature",
           new GetMaximumTemperatureFunction());
       return functions;
   }
}

Il nous suffit ensuite d’ajouter cette extension à notre engine lors de sa création, qui se fait dans la classe PebbleEngineFactory déjà décrite dans la partie Configuration du PebbleEngine :

public class PebbleEngineFactory {

   public static PebbleEngine buildPebbleEngine() {
       final Loader loader = new ClasspathLoader();
       loader.setPrefix("templates");
       return new PebbleEngine.Builder()
           .loader(loader)
           .extension(new PebbleExtension()) // Adding our extensions
           .build();
   }
}

Les fonctions suivent le même schéma. On crée une classe implémentant l’interface Function :

public class GetMaximumTemperatureFunction implements Function {

   @Override
   public Object execute(Map<String, Object> args,
       PebbleTemplate self, EvaluationContext context, int lineNumber) {
       Day d = (Day) args.get("day");
       return d.getHours().values().stream()
           .mapToInt(h -> h.getTemperature())
           .max()
           .orElse(0);
   }

   @Override
   public List<String> getArgumentNames() {
       List<String> names = new ArrayList<>();
       names.add("day");
       return names;
   }
}

A la différence d’un filtre, nous n’avons pas de variable input. Les données seront passées de la même manière qu’une méthode classique :

<div class="max">Max: {{ getMaximumTemperature(today) }}</div>

Pebble offre plusieurs autres fonctionnalités qu’on s’attend à trouver dans un moteur de templating, telles que les macros, les boucles, les blocs conditionnels, la gestion de l’i18n, l’héritage de template...
Vous pouvez retrouver la liste de ces fonctionnalités et des exemples d’utilisation ici.

Comment installer Prince

Les binaires d’installation se trouvent ici, et les instructions d’installation par système d’exploitation à cette adresse. Si vous souhaitez en plus installer une licence sur une machine, vous trouverez les instructions ici.
Il est bien entendu possible d’utiliser les binaires pour générer directement des documents PDF à partir de fichiers HTML :

$ prince monfichier.html

Pouvoir convertir un HTML en PDF peut permettre de tester le bon fonctionnement de l’installation, mais ce qui nous intéresse davantage est l’intégration dans une application et la gestion dynamique du contenu.
Afin d’utiliser Prince au sein d’une application, il faut inclure le wrapper correspondant au langage utilisé dans les dépendances du projet. Vous n’aurez ensuite qu’à indiquer le chemin vers l’exécutable à l’instanciation du moteur.

Produire un PDF

Après avoir généré un contenu HTML avec Pebble, il est temps de passer à l’étape de la génération du PDF.
Nous en avons parlé précédemment, nous avons besoin d’importer le wrapper Java de Prince. Pour cela, nous plaçons le jar dans un dossier “lib” à la racine du projet, que nous importons ensuite via gradle :

dependencies {
   ...
   compile files('libs/prince.jar')
   ...
}

Il nous faut ensuite initialiser Prince :

Prince prince = new Prince("/usr/bin/prince");
prince.setHTML(true);
prince.setJavaScript(false);
prince.setVerbose(false);
prince.setXInclude(false);
prince.setEncryptInfo(128, null, null, false, true, true, true);
prince.clearScripts();
prince.clearStyleSheets();

Pour finir, il nous suffit d’utiliser l’objet Prince pour convertir un fichier HTML en PDF grâce à la méthode convert :

prince.convert(inputStreamHTML, outputStreamPDF);

Le flux d’entrée inputStreamHTML contient le HTML, et le flux de sortie contient le PDF, que nous pouvons ensuite écrire dans un fichier.
C’est tout ce dont nous avons besoin pour générer un PDF. La documentation de Prince est disponible en ligne ici.

Quelques exemples de fonctionnalités CSS

Voici le “weather dashboard” que produit notre application :
S-lection_022
En se basant sur CSS3, Prince nous permet d’utiliser des fonctionnalités comme la transparence, les dégradés, les effets de bordure, le positionnement (relatif/absolu), les paddings/margins, les transform et bien d’autres.
Le @page
Commençons par examiner la disposition du PDF. La première page est au format paysage, la deuxième est au format portrait (celles qui suivent aussi, bien que la capture ne le montre pas). Ce paramétrage se fait via la règle CSS @page :

@page landscape-def {
 size: A3 landscape;
 margin: 0;
}

.landscape {
 page: landscape-def;
 height: 100%;
}

Le contenu de la première page est placé dans un élément de classe .landscape, fixant ainsi la taille et l’orientation.

De la même manière, les pages orientées en portrait sont contenues dans un élément de classe .portrait :

@page portrait-def {
 size: A3 portrait;
 margin: 0;
}

.portrait {
 page: portrait-def;
 height: 100%;
}

Les flexboxes

L’utilisation des flexboxes pour placer nos éléments sur le dashboard nous évite de nous préoccuper de leur position et de leurs dimensions en fonction de leur contenu. Le rendu est identique à celui d’un navigateur. On peut prendre pour exemple la bannière de résumé pour illustrer cet avantage :
S-lection_023

<div class="summary flex">
  <div class="temperature flex column category">
    <div class="title">Temperature</div>
    <div class="content flex column">
      <div class="recap flex">
        <div class="max">Max: 39<sup>°C</sup></div>
        <div class="min">Min: 3<sup>°C</sup></div>
      </div>
      <span class="big-text">39<sup>°C</sup></span>
      <span class="caption">Felt: 39<sup>°C</sup></span>
    </div>
  </div>
  <div class="weather flex column category">...</div>
  <div class="humidity flex column category">...</div>
  <div class="pressure flex column category">...</div>
  <div class="wind flex column category">...</div>
</div>
.flex {
 display: flex;
 justify-content: space-around;
}

.column {
 flex-direction: column;
}

Prince prend en charge la plupart des attributs du Flexbox Layout et permet ainsi de profiter de la souplesse qu’offre ce module. Vous pouvez retrouver un guide pratique sur les propriétés de ce module ici.

Images

Dans notre exemple nous utilisons des images vectorielles, souvent plus légères que les images classiques. Pour les placer, nous avons fait le choix d’utiliser l’attribut CSS background-image plutôt qu’une balise dans nos templates, mais les deux sont possibles.
Nous avons fait ce choix afin de pouvoir changer dynamiquement une image dans un template en modifiant la classe d’un élément, plutôt que de manipuler des URI dynamiquement, et d’inclure nos images dans notre CSS. Voici un exemple :

<div class="icon {{ day.weather.toString }}"></div>
.icon {
 width: 40px;
 height: 40px;
 align-self: center;
 background-repeat: no-repeat;
 margin: 8px;
}

.icon.fog {
 background-image: url(data:image/svg+xml;base64,PHN...2Zz4=);
}

.icon.heavy-rain {
 background-image: url(data:image/svg+xml;base64,PHN...3ZnPg==);
}

.icon.light-rain {
background-image: url(data:image/svg+xml;base64,PHN...3ZnPg==);
}

Le résultat de {{ day.weather.toString }} nous donnera un nom de classe de type fog, heavy-rain, light-rain

Conclusion

Comme nous l’avons vu, Prince n’est pas gratuit, et ne conviendra pas à tous les usages. Ceci dit, les fonctionnalités de Prince évoquées dans cet article devraient vous éclairer sur le gain potentiel de temps que son adoption représente face à d’autres outils équivalents. L’évolution des templates, leur maintenance et le coût d’entrée sur un projet utilisant du HTM5L/CSS3 peut justifier cette dépense supplémentaire, la volumétrie étant probablement un des facteurs principaux.