Générer des PDF avec Node
Quand vient le temps de produire des documents ou des rapports à partir des données d’un système, plusieurs solutions s’offrent à nous. Dans des langages plus traditionnels en architecture monolithique (Roger : dans mon temps!), on avait l’habitude d’utiliser des générateurs de rapport comme LiveCycle ou JasperReport. Ce sont deux engins de rapport très complets, mais aussi assez compliqués à intégrer dans une application bâtie en Nodejs, en services REST. Aujourd’hui, on va voir comment y arriver 🙂
Configuration de l’environnement
Depuis un répertoire vide, initialiser un nouveau projet Node et installer la librairie PDFkit.
1 2 |
npm init -y npm i pdfkit |
Puis, pour reproduire l’utilisation d’une API qui permet de télécharger le PDF, nous devrons installer Express.
1 |
npm i express |
Création de la route de téléchargement
Supposons que nous voulions une route pour télécharger un PDF. Je n’entrerai pas dans les détails d’Express parce que je l’ai couvert par le passé, mais nous devons créer le squelette de l’application, puis enfin créer la route. Créer un fichier index.js à côté du package.json :
1 2 3 4 5 6 7 8 |
const express = require('express'); const app = express(); const router = new express.Router(); router.get('/TimeReport', (req, res) => res.json({ ok: true })); app.use('/api', router); app.listen(3000, () => console.log('Launched!')); |
Puis, on teste :
1 |
node index.js |
À cette adresse ( localhost:3000/api/TimeReport), vous devriez voir { ok: true }!
Encapsulation et façade
Viens donc le temps de mes recommandations! Lorsque vous utilisez une librairie tierce comme PDFkit ou n’importe quelle autre, vous devriez toujours encapsuler tout ce qui touche à cette librairie dans une ou plusieurs classes. Procéder de cette façon nous assure de pouvoir interchanger notre librairie dans le cas où nous nous buterions à une limitation de cette dernière.
Deuxième recommandation, lorsque vous utilisez une librairie aussi généraliste que PDFkit (ça génère un PDF, mais on a carte blanche), vous devriez au moins vous créer une façade (oui! le patron de conception). La façade sert à simplifier l’API d’une librairie tierce, par exemple. Créons d’abord une classe de service pour encapsuler le processus de génération de PDF :
1 2 3 4 5 6 7 |
module.exports = class ReportService { generateTimeReport() { return new Promise((resolve, reject) => { resolve({ ok: true }); }); } } |
Rien de sorcier jusqu’à maintenant, on ne fait que tester pour s’assurer que ça fonctionne bien. Modifions notre index.js pour que la route appelle ce service, en mode asynchrone :
1 2 3 4 |
const ReportService = require('./report-service'); const reportGenerator = new ReportService(); router.get('/TimeReport', async (req, res) => res.json(await reportGenerator.generateTimeReport())) |
Note : Ici, j’ai utilisé une Promise comme retour de ma classe de service parce que le processus de génération de PDF se fait via un stream asynchrone. L’utilisation de async/await est pour simplifier la syntaxe.
Si vous testez, vous devriez obtenir le même comportement.
Maintenant, créons la façade. Pour générer notre TimeReport, nous aurons besoin de :
- Créer le document PDF et son stream vers le système de fichiers
- Ajouter un titre au centre
- Ajouter un libellé avec la date et l’heure actuelle
- Fermer le document PDF et obtenir les octets
C’est donc cet algorithme simplifié qui sera notre façade :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 |
const PDFDocument = require('pdfkit'); const fs = require('fs'); const tempFileName = 'output.pdf'; module.exports = class PdfFacade { createDocument() { this.doc = new PDFDocument(tempFileName); this.doc.pipe(fs.createWriteStream()); return this; } writeTitle(title) { this.doc.font('Helvetica-Bold'); this.doc.text(title, { align: 'center' }); this.doc.font('Helvetica'); return this; } writeLabelValuePair(label, value) { this.doc.font('Helvetica-Bold'); this.doc.text(`${label} : `, { continued: true }); this.doc.font('Helvetica'); this.doc.text(value); return this; } closeAndGetBytes() { return new Promise((resolve, reject) => { try { this.doc.end(); setTimeout(() => { resolve(fs.readFileSync(tempFileName)) }, 250); } catch (err) { reject(err); } }); } } |
createDocument()
Cette fonction permet de créer un nouveau document PDF et d’écrire le résultat sur le disque. Remarquez l’utilisation de return this pour permettre le chaînage d’appels.
writeTitle(title)
Celle-ci est une méthode propre de la façade. Puisqu’une façade sert à simplifier une API, on peut par exemple déterminer que tous nos titres seront en gras et centré sur la page. Cette méthode sert donc à appeler les différentes méthode de l’API de PDFkit afin que ce qui soit propager dans notre code soit uniquement writeTitle() (par exemple).
writeLabelValuePair(label, value)
Même principe que pour la fonction précédente. C’était pour montrer comment on peut avoir plusieurs méthodes dans une même façade.
closeAndGetBytes()
Cette fonction ferme le flux et écrit sur le disque. Notez l’utilisation d’un setTimeout(). Ce que j’ai pu remarqué avec mes tests de la librairie, c’est que la fonction end() qui initie l’écriture sur le disque retourne void et non une Promise comme on pourrait s’y attendre. C’est donc impossible (à moins qu’un bon samaritain me pointe la solution en commentaire) d’attendre que le flux soit fermé et le fichier créé avant de poursuivre.
Comme je dis trop souvent : c’est un gros plaster avec du poil. Ou encore (pour nos amis européens) un gros diachylon velu 🙂
Retourner les données en PDF
Concernant la couche de service qui appelle la façade, son algo sera très simple :
1 2 3 4 5 6 7 |
generateTimeReport() { return pdfFacade.createDocument() .writeTitle('Ceci est un test') .writeLabelValuePair('Date', new Date().toISOString()) .writeLabelValuePair('Valid', true) .closeAndGetBytes(); } |
On sait donc que closeAndGetBytes() nous retourne une Promise qui sera résolue 250 ms après la fermeture du flux PDF. Pour retourner le PDF en téléchargement, simplement :
1 2 3 4 5 |
router.get('/TimeReport', async (req, res) => { const data = await reportGenerator.generateTimeReport(); res.setHeader('Content-Type', 'application/pdf'); res.send(data); }); |
Maintenant, si vous atteignez l’URL, vous devriez voir ce PDF :
Conclusion
Aujourd’hui, vous avez appris un cas d’utilisation réel d’une façade pour encapsuler et simplifier l’API d’une librairie tierce. Les avantages de procéder de cette façon sont : une diminution du couplage puisque la librairie est encapsulée, PDFkit se limite à un seul fichier source facilement remplaçable, une augmentation de cohésion dans le sens où notre classe de logique d’affaires ne génère pas de PDF à proprement parler, c’est le rôle de la façade, et un meilleur respect du principe de responsabilité unique.
Pour poursuivre votre apprentissage ou la découverte de PDFkit, je vous invite à lire la documentation très complète de la libraire. N’oubliez pas de partager cet article 🙂 Ça ne prend qu’une seconde et ça nous aide beaucoup à nous faire connaître!
Cheers :-*
Commentaires
2 commentaires
Wow j’aime bien l’idée d’encapsuler les package externe dans des classes. Bonne idée!
Aussi J’avais jamais utilisé le return this, tu viens de m’ouvrir les yeux.
Seul point négatif, le timeout. J’espère vraiment qu’il existe un autre seulement, sinon c’est horrible! 🙂
Hey Kardiamond 🙂 Effectivement, en encapsulant, la journée que tu veux changer de librairie, t’as uniquement une seule classe à changer. Suffit de réimplémenter les méthodes et voilà!
Pour le timeout, il semble que c’est possible d’utiliser l’événement ‘finish’ du stream qu’on passe en entrée.
Voir : https://github.com/foliojs/pdfkit/issues/265
Cheers 🙂 Merci pour ton commentaire
Laisser un commentaire