Je reviens sur l’article « Solution générique d’impression de pages web » que j’avais rédigé il y a quelque temps. Je proposais dans le billet initial de traiter une problématique récurrente des applications web : comment générer un fichier PDF à partir d’une page html ? La démarche était expliquée et la dernière piste de solutions évoquée consistait à utiliser un client web côté serveur, qui permettait de générer le fichier PDF. En l’occurrence il s’agissait de CutyCapt.
Cette solution a ses avantages :
- L’utilisateur final n’a pas à installer un module dans son navigateur : PDFCreator, DoPDF, etc.,
- Le prestataire n’a pas à développer deux fois chaque écran « imprimable » : pour une présentation HTML côté client et pour une présentation PDF côté serveur,
- Le client est satisfait car le prestataire ne développe pas deux fois !
Concrètement, la mise en oeuvre de cette solution a quand même un coût : celui de la mise en place initiale. Une fois le système en place, toutes les ressources adressables de l’application en question seront « imprimables ».
En conclusion du billet initial, vous avez pu lire que le principal inconvénient de CutyCapt est son manque de souplesse. Par exemple, le délai nécessaire pour lancer la capture est gênant :
- si on le fixe trop court, on n’est pas certain du résultat en sortie. C’est le cas lorsque le chargement du site n’est pas totalement terminé,
- si on le fixe trop long, on perd du temps alors que le serveur reste a terminé son travail.
Depuis l’écriture du billet et pour une mise en oeuvre concrète, j’ai pu mettre en oeuvre le même principe avec une librairie bien plus intéressante : PhantomJS.
# PhantomJS
Cette librairie est plus souple que CutyCapt car elle met à disposition une API javascript qui permet de piloter l’impression :
- créer une « page web » en chargeant son contenu ou en utilisant une URL,
- injecter de nouveaux fichiers javascript dans la page,
- envoyer des événements souris : clic, etc.,
- accéder au système de fichiers,
- définir la taille de la partie visible, le format A4, etc.,
- lancer la capture vers un fichier sur disque.
# Installation de PhantomJS
Pour une installation sous CentOS ou Debian, comptez un peu de temps pour configurer un serveur X (ou utiliser Xvfb) et installer QT ainsi que GIT avant de récupérer le code source de PhantomJS et de le compiler. Vous pouvez également récupérer la version compilée. Tout est ici.
# Utilisation de PhantomJS
L’utilisation de cette librairie est triviale : on se contente d’écrire un fichier javascript dont le chemin est passé à PhantomJS pour la réalisation de la capture. C’est dans ce fichier javascript qu’on place les instructions d’impression.
L’exemple qui suit reste très simple :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
<span style="color: #000000;"><span style="color: #008000;">// On récupère les arguments </span></span><span style="color: #808080;">var urlPageSource = phantom.args[0]; </span>var cheminFichierCible = phantom.args[1]; <span style="color: #000000;"><span style="color: #008000;">// On crée l'objet qui encapsule la page et on précise le format de sortie </span></span>var page = require('webpage').create(); page.paperSize = { format: 'A4', orientation: 'portrait', border: '2cm' }; <span style="color: #000000;"><span style="color: #008000;">// Cette fonction est appelée lorsque le chargement de la page est terminé </span></span><span style="color: #808080;">page.onLoadFinished = function (status) { </span><span style="color: #000000;"><span style="color: #008000;"> // On effectue le rendu dans le fichier cible et on quitte PhantomJS </span></span><span style="color: #808080;"> page.render(cheminFichierCible); </span><span style="color: #808080;"> phantom.exit(); </span><span style="color: #808080;"><span style="color: #888888;">}; </span></span><span style="color: #808080;"><span style="color: #008000;">// On lance le chargement de la page </span></span><span style="color: #808080;">page.open(urlPageSource);</span> |
Pour tester, j’exécute l’instruction suivante sur le serveur :
1 |
DISPLAY=:0 /usr/local/phantomjs/bin/phantomjs /tmp/testsimple.js http://www.atolcd.com /tmp/phantomjs-test.pdf |
Le résultat est conforme aux attentes :
# Intégration de PhantomJS
Au sein d’une application web, PhantomJS n’est qu’une composante du système global. Pour avoir une vision complète du système, on procède de la manière suivante pour chaque ressource « imprimable ».
Pour imprimer une ressource, l’utilisateur clique sur le bouton « Imprimer ».
Une fenêtre « HTML » (une Ext Window, un Closure Dialog, etc.) d’aperçu avant impression est ouverte, qui embarque une iframe référençant la ressource à imprimer. Dans cette vue, on présente la ressource de manière à ce qu’elle soit imprimée correctement : on masque les bandeaux de navigation, etc.
Dans la fenêtre d’aperçu avant impression, deux boutons permettent de lancer une impression via le navigateur : « Imprimer » ou de lancer une impression via le serveur : « Télécharger ».
Dans le cas d’une impression via le navigateur, seul le contenu de l’iFrame est envoyé à l’imprimante système : le cadre et les boutons ne seront pas visibles. Il s’agit d’une impression classique qui donne dans le navigateur Google Chrome :
Dans le cas d’une impression via le serveur, on utilise une URL qui permet de contacter le serveur d’impressions et qui comporte un paramètre qui décrit la ressource à imprimer. Au plus simple, la valeur du paramètre est une URL. Par exemple, l’URL de contact du serveur d’impression sera :
http://www.testphantomjs.atolcd.com/servimp?url=http://www.atolcd.com
Le serveur d’impressions réceptionne l’URL à imprimer, lance l’impression en utilisant PhantomJS et renvoie le flux du fichier PDF. Un extrait en PHP :
// Récupération de l’URL à utiliser
$url = $request->getParameter(‘url’);
$cible = ‘/tmp/phantomjs-test.pdf’;
// Génération du fichier sur disque
$phantomOutput = shell_exec(‘DISPLAY=:0 /usr/local/phantomjs/bin/phantomjs /tmp/testsimple.js ‘.$url.’ ‘.$cible);
// Renvoi du contenu du fichier
$this->getResponse()->setContentType(‘application/pdf’);
$this->getResponse()->setHttpHeader(‘Content-Disposition’, ‘attachment; filename= »‘.basename($cible).' »‘);
$this->getResponse()->setContent(readfile($cible));
Le navigateur récupère la réponse sous la forme d’un flux PDF et propose l’enregistrement du fichier sur disque. Le résultat est le suivant :
Pour aller plus loin, on pensera à sécuriser le serveur d’impressions car il pourrait être utilisé pour imprimer n’importe quelle ressource adressable sur internet. Par exemple : vérifier la provenance, vérifier que l’URL cible est une URL de l’application, utiliser des clés plutôt que des URLs complètes, etc.
# Impression de ressources privées
A ce niveau de développement, l’impression fonctionnera pour toutes les ressources qui ne requièrent pas d’authentification.
Le serveur d’impression utilise le moteur WebKit si bien qu’il ouvre une nouvelle session lorsqu’il lance une impression. En conséquence, il doit être en mesure de transmettre les informations d’authentification lors des impressions.
Pour imprimer une ressource privée, plusieurs possibilités s’offrent à nous :
- le serveur fait confiance aux requêtes qui proviennent de lui même (localhost). Ceci permet un accès à la ressource et si la présentation dépend de l’utilisateur, on pourra passer une information identifiant l’utilisateur (au plus simple, son identifiant).
- le serveur propose une authentification basée sur le mode opératoire SSO. Par exemple, il procède en trois étapes :
- Etape 1 : [Handler : serveur impression] réception de la requête d’un utilisateur authentifié :
- Généréation d’un jeton à durée de vie courte dans une structure de données en mémoire ou en base
- Requête du serveur sur lui-même en utilisant le jeton
- Etape 2 : [Handler : système d’authenficiation] réception d’une requête avec jeton
- Vérification du jeton (et invalidation)
- Authentification de l’utilisateur
- Redirection vers l’URL associée
- Etape 3 : [Handler : page classique] réception d’une requête
- L’utilisateur est authentifié, le serveur laisse passer la requête
- Etape 1 : [Handler : serveur impression] réception de la requête d’un utilisateur authentifié :
Cette solution a l’avantage de proposer la souplesse d’une connexion sur le mode opératoire « SSO » : elle fonctionne également dans le cas où le serveur cible n’est pas le même que le serveur d’impression (sur lequel l’utilisateur est initialement authentifié).
Ce type de sécurisation a été retenu pour un projet qui nécessitait une impression de ressources privées.
Au niveau du passage des informations d’authentification, on remarquera à la lecture de l’API de PhantomJS, la présence de deux paramètres HTTP : « userName » et « password ». En conséquence, PhantomJS laisse le choix d’une transmission des informations d’authentification au niveau du protocole HTTP ou des paramètres de l’URL.
# Exemple de traitement avancé
Pour aller encore un peu plus loin avec PhantomJS et qualifier sa souplesse, j’ai décidé de le stresser un peu : imprimer une page dans laquelle sont utilisés OpenLayers et l’API minimale de l’IGN pour afficher les fonds de plans.
On se retrouve avec la problématique initiale : quand réaliser la capture pour avoir un résultat sûr ?
En effet, l’arrivée des fonds de l’IGN n’est pas immédiate dans la mesure où l’IGN effectue une vérification par le nom de domaine avant de laisser l’accès aux fonds et où les images peuvent représenter un volume important de données à télécharger. D’autant plus que le temps nécessaire dépend de facteurs externes : la qualité de la connexion, le chargement des serveur de cartes, etc.
Pour traiter ce cas de figure, j’ai utilisé un compteur de requêtes vers l’IGN. Quand une requête concerne une image de l’IGN, j’incrémente le compteur ; lorsque la réponse arrive, je le décrémente. La capture est réalisée lorsque qu’il n’y a plus aucune requête en attente.
Un extrait simplifié du script PhantomJS :
// A l’émission d’une requête
page.onResourceRequested = function (request) {
if (request.url.search(‘wxs.ign.fr’)>=0) {
++nbPendingReq;
}
};
// A la réception de la réponse
page.onResourceReceived = function (response) {
if (response.url.search(‘wxs.ign.fr’)>=0 && response.stage==’end’) {
–nbPendingReq;
if (nbPendingReq<1) {
page.render(target);
phantom.exit();
}
}
};
Le résultat est concluant puisqu’il s’agit d’un exemple en production (voir la capture plus haut dans le billet).
# Bilan
Pour conclure, PhantomJS est la librairie que j’attendais pour ce type de traitement. Le rendu est fidèle dans la mesure où le moteur WebKit est utilisé et les performances sont acceptables.
Les impressions multi-pages fonctionnent, bien qu’il arrive qu’une ligne de texte soit coupée en deux à l’horizontale pour se retrouver sur deux pages.
La force de la librairie réside dans la souplesse induite par son mode de fonctionnement qui permet d’adapter l’impression au cas de figure qui se présente. De plus, le format de sortie peut être imposé ce qui est un autre point d’amélioration par rapport à CutyCapt.
8 octobre 2013 at 10 h 16 min
Hello,
I find very useful your tutorial and I want to integrate rasterize on my CDE C-tools Dashboards.
It will be very helpful from you if you can post in your comment an example of how can PantomJS can be integrated in a CDE dashboard.
Thank you in advance for you reply!
Best Regards,
Dragos.
8 octobre 2013 at 11 h 12 min
Hello Dragos,
Sylvain Decloix, which works for Atol Conseils et Développements and who is an expert on the BI subject (he is the writer of http://www.osbi.fr too) has worked on it from the information provided in the article. He has made a video of the result that he posted here.
We know that the solution exposed in this article is very usefull in that kind of cases.
That’s why we will write an article that will detail how to make it as soon as we have time.
Best Regards,
Charles-Henry
8 octobre 2013 at 11 h 35 min
Thank you for you prompt answer. It will be a 10+ tutorial for me! Waiting for it.
Thank you again, Charles!
Best regards,
Dragos.
24 octobre 2013 at 15 h 59 min
Hi Dragos
Just look at this post: http://goo.gl/aiHgXQ
Hope it will help you 😉
Sylvain