Vous êtes ici : Accueil / 2017 / Mai / Génération de PDF depuis des applications Web en Python

Génération de PDF depuis des applications Web en Python

écrit le 12/05/2017 Par Gaël Le Mignot
Il est de plus en plus souvent utile, voire nécessaire, de générer des fichiers PDF de tout type (bordereaux, formulaires, rapports, versions imprimables de documents HTML, ...) dans le cadre d'une application Web. Cet article présente quelques outils pouvant servir à générer des PDF depuis des applications Web en Python, leurs cas d'utilisation, ainsi que quelques précautions à prendre et pièges à éviter.

Les outils

weasyprint

Présentation

Weasyprint est une bibliothèque Python (ainsi qu'un utilitaire en ligne de commande utilisant cette bibliothèque) pour convertir un document HTML5 en PDF en utilisant la CSS d'impression.

Son utilisation est relativement simple, par exemple

from weasyprint import HTML, CSS
html = my_template()
data = HTML(string=html).write_pdf()

Si nécessaire, il peut être utile de :

  • modifier le code HTML avant de le donner à Weasyprint, par exemple en utilisant BeautifulSoup pour supprimer ou modifier des éléments de la page, par exemple pour retirer certaines CSS

    from bs4 import BeautifulSoup
    soup = BeautifulSoup(html, 'html.parser')
    for css in soup.findAll("link"):
        for cssname in ('typography.com', 'default.css', 'logged-in.css'):
            if cssname in css['href']:
                css.extract()
    
  • fournir une CSS spécifique à Weasyprint en ajoutant par exemple

    stylesheets=[CSS(url=base_url+'/pdf_print.css')])
    
  • ajouter des pieds de page ou en-tête via CSS

    @page {
     margin: 3cm 2cm;
     @bottom-right {
         content: "Page " counter(page)
     }
     @top-center {
         content: "Pilot Systems";
     }
    }
    
Cas d'utilisation

Weasyprint est très utile pour générer des versions PDF de pages existant déjà en HTML, mais aussi pour réaliser des vues spécifiques d'impression. Le principal avantage dans ce cas est que, s'appuyant sur du HTML et du CSS, il s'intègre parfaitement dans les processus de développement (ainsi que des langages de templating, ...) d'applications Web.

Cependant le rendu possible est parfois limité par ce qui peut être exprimé en CSS, et les CSS d'impression sont parfois délicates à débuguer.

reportlab

Présentation

Reportlab est une bibliothèque Python de génération PDF.

L'écosystème Reportlab est un peu complexe en raison des licences, il y a 3 outils différents en réalité :

  1. Reportlab PDF toolkit : un outil libre (licences mixtes) permettant de générer des PDF en Python (paquet python-reportlab dans Debian).
  2. ReportLab PLUS : une version propriétaire incluant de nombreuses extensions par rapport à la version libre, dont un langage de template RML pour décrire les PDF.
  3. trml2pdf : une bibliothèque libre (LGPL) qui s'appuie sur la version libre de Reportlab et réimplémente une grosse partie du langage RML (paquet Debian python-trml2pdf).

Pour les utilisateurs de Zope, il existe de plus un produit Zope nommé RMLPageTemplate intégrant le RML et les page template Zope.

Par exemple pour positionner un cadre jaune fluo d'avertissement sur un document provisoire on peut réaliser quelque chose du type

<tal:if tal:condition="not: formulas/validated">
  <fill color="yellow"/>
  <stroke color="black"/>
  <rect x="115mm" y="217mm" width="90mm" height="18mm"
        fill="yes" stroke="yes"/>
  <frame id="warning" x1="115mm" y1="213mm" width="90mm" height="24mm"  />
  <fill color="black"/>
  <stroke color="black"/>
</tal:if>

<story>
  <para style="warning" tal:condition="not: formulas/validated">
    CECI EST UN DOCUMENT PROVISOIRE NON VALIDÉ - NE PAS IMPRIMER
  </para>
</story>
Cas d'utilisation

Reportlab fourni beaucoup de fonctionnalités, mais ses points forts sont la génération de graphes et la possibilité d'un placement fin des éléments.

Chez Pilot Systems, nous nous en servons principalement lorsqu'il s'agit de compléter un fond PDF de formulaire qui nous est fourni, en ajoutant les éléments saisi par l'utilisateur (et/ou provenant de la base de données et de calculs) dans les cases du formulaire.

pdftk

pdftk est une boite à outils permettant de manipuler des PDF. Elle ne permet pas directement de générer des PDFs, mais permet de les transformer ensuite, par exemple extraire des pages, concaténer des PDF, ou imprimer un calque en surimpression sur un fond.

Il s'utilise en ligne de commande, en Python il faut donc utiliser subprocess (ou os.system ou équivalent).

LaTeX

Présentation

LaTeX est un système de composition de documents très largement utilisé dans le monde de la publication scientifique, mais pas seulement.

Son utilisation en Python est relativement simple : générer un document LaTeX soit directement, soit en passant par un moteur de template, soit même en passant par un langage intermédiaire plus simple type reStructuredText, puis composer le document en appelant l'exécutable pdflatex.

Cas d'utilisation

LaTeX est extrêmement puissant et flexible, et donne un rendu d'une qualité inégalée. Il peut générer automatiquement des tables des matières, des index, des glossaires, etc. C'est donc l'outil parfait pour générer des rapports riches.

Son utilisation est cependant plus complexe et peut rebuter le néophyte.

Autres pistes

Quelques autres outils et pistes possibles sont listées rapidement :

  • il existe des bibliothèques JavaScript pour faire du rendu PDF côté client, et non côté serveur ;
  • il est possible d'utiliser LibreOffice via la bibliothèque python-uno, pour convertir à la volée des documents LibreOffice (ou éventuellement MS Office) en PDF, cependant le déploiement est relativement lourd (il faut lancer un LibreOffice en mode serveur).

Quelques précautions et astuces

Les en-têtes HTTP

Lorsqu'on renvoie au navigateur un document PDF, il faut indiquer le type de document via l'en-tête HTTP Content-Type, avec quelque chose du type

response.setHeader('Content-Type', 'application/pdf')

Il est aussi possible d'indiquer au navigateur si on souhaite que le document soit affiché dans la fenêtre du navigateur (pour les navigateurs qui le supportent) ou téléchargé, via l'en-tête Content-Disposition, par exemple

response.setHeader('Content-Disposition', 'attachment; filename="%s.pdf"' % filename)

Attention, il s'agit d'un souhait, le comportement exact peut varier d'un navigateur à l'autre (et suivant la configuration de l'utilisateur).

Le temps de génération

Lorsqu'on génère des PDF de taille important (plusieurs dizaines ou centaines de pages), quelle que soit la technologie utilisée, le temps de génération peut être long, plusieurs minutes facilement, ce qui peut provoquer des timeout à plusieurs niveaux.

Les solutions envisageables sont :

  • Augmenter les temps de timeout sur tous les composants impliqués (apache, gunicorn, varnish, etc.). Penser à prévenir l'utilisateur, et à prévoir un mécanisme (par exemple désactiver le bouton en JS une fois que l'utilisateur a cliqué dessus) pour éviter que l'utilisateur impatient ne provoque plusieurs générations du même document en parallèle.

  • Informer l'utilisateur que son document va lui être transmis par mail, lancer la génération dans un processus annexe (via fork ou via un ordonnanceur de tâches comme Celery ou équivalent), et envoyer un mail avec soit le document en pièce jointe (mais attention les gros mails peuvent être bloqués), soit un lien de téléchargement dans le mail. Penser dans ce cas à nettoyer les fichiers laissés sur le serveur au bout de quelques jours (ou semaines), via un cron par exemple

    find /path/to/generated/pdfs -mtime +14 -delete
    
  • Lancer le processus en asynchrone, et mettre en place un mécanisme en JavaScript pour interroger le serveur régulièrement, et indiquer à l'utilisateur quand le fichier est généré.

Les moteurs de recherche

Souvent la génération PDF est réservée à des utilisateurs authentifiés sur le site, mais parfois elle est disponible pour les anonymes. Par exemple une fonctionnalité régulièrement demandée est de mettre un bouton "PDF" sur toutes les pages d'un CMS pour permettre aux visiteurs de les récupérer.

Dans ce cas il faut faire très attention à ce que les moteurs de recherche, ou autres robots, ne se mettent pas à demander une version PDF de toutes les pages, ce qui peut rendre le site temporairement indisponible.

Il y a plusieurs solutions palliatives pour ce problème :

  1. Utiliser le fichier robots.txt, mais vu que le support des regexp dans ce fichier n'est pas standard, ce n'est souvent pas possible sans un travail délicat de réécriture des URL.
  2. Mettre la génération PDF en POST et non en GET, c'est une légère entorse au protocole HTTP (puisque la génération PDF ne modifie pas d'information), mais qui bloque la plupart des robots ne suivant pas les POST.
  3. Mettre des règles en amont (par exemple sur un reverse-proxy type haproxy) pour limiter le nombre de génération PDF simultanées sur le site, ainsi en cas de génération massive de PDF, seule la fonctionnalité est bloquée, et non l'ensemble du site.

L'utilisation de ressources machine

Ce point est lié aux deux précédents. La génération de PDF peut demander des quantités importantes de mémoire (en particulier Weasyprint peut prendre plus d'1 Go lorsqu'on génère des fichiers de plusieurs centaines de pages), et de multiples générations en parallèle peuvent utiliser massivement le CPU.

Quelques astuces pour limiter ces problèmes :

  1. Avant toute mise en production, effectuer une estimation des ressources nécessaires, et dimensionner en conséquence l'hébergement.

  2. Comme pour le temps de génération élevé, utiliser un ordonnanceur de tâches afin de s'assurer qu'une seule génération est effectuée à la fois.

  3. Si la génération est faite via un processus externe (Weasyprint, pdftk, LaTeX, ...) il peut être recommandé d'utiliser taskset (pour affecter un cœur de CPU dédié au processus) et/ou nice (pour affecter une priorité au programme), par exemple en utilisant un petit script de ce type pour envelopper les appels

    #!/bin/sh
    exec nice -n 10 taskset -c 0 /usr/bin/pdftk "$@"
    
  4. Mettre en place un système de cache, pour garder les PDF générés et ne les générer de nouveau que si les données ont changé.

L'accès aux ressources (CSS, images)

Ce problème concerne surtout Weasyprint, mais peut aussi concerner d'autres solutions similaires : parfois certaines ressources référencées par un document (images, CSS, ...) ne sont pas accessibles publiquement mais nécessitent d'être authentifiés.

Trois solutions existent alors :

  1. Récupérer le cookie de connexion de l'utilisateur, et le communiquer à l'outil.
  2. Récupérer tous les éléments, les déposer dans un répertoire temporaire, réécrire les URLs pour avoir des références locales, et nettoyer le tout à la fin de la génération.
  3. Modifier le contrôle d'accès pour que les ressources utilisées lors de la génération PDF fonctionnent même en anonyme, par exemple via un filtre sur l'adresse IP source (mais faire très attention à ne pas laisser de trou avec cette solution).

Les PDF chiffrés (DRM)

Un scénario qui revient dans plusieurs projets est le suivant :

  1. Un utilisateur rempli un formulaire, comportant des champs textes mais aussi un champ fichier, où il doit mettre un PDF.
  2. Un rendu de son formulaire est fait, incluant ses réponses textes puis en annexe le PDF qu'il a inclus. Voir même un export de toutes les réponses de tous les utilisateurs dans un gros PDF, pour l'administrateur du site.

C'est un cas typique d'utilisation de Weasyprint + pdftk (ou de LaTeX). Mais parfois, à la génération, le tout échoue sans que la raison ne soit évidente.

En fait l'utilisateur a saisi un PDF protégé via du DRM, et l'outil pdftk (ou pdflatex) n'arrive pas à l'intégrer dans son propre PDF (ce serait violer les conditions du document).

Il existe des techniques de contournement, mais qui peuvent poser des problèmes de légalité.

Une autre solution consiste à rejeter les PDF protégés par du DRM lors de la validation du formulaire, en utilisant un outil comme pdfinfo (venant de poppler ) par exemple

st, out = commands.getstatusoutput('pdfinfo %s' % pdffile)
if st or re.search('Encrypted:.*yes', out):
    raise ValueError, "this PDF file is DRM protected"

Actions sur le document