Django + PDF - przegląd metod i narzędzi

9 minut(y)

Dzisiaj chciałbym pokazać Wam dostępne narzędzia i metody tworzenia plików PDF z poziomu frameworka Django. Dokumentacja samego frameworka w tym temacie jest nad wyraz uboga i ukazuje bardzo pobieżnie jedną tylko metodę z wykorzystaniem narzędzia reportlab.

Zastanówmy się co właściwie jest nam potrzebne aby z poziomu Django móc tworzyć dokumenty PDF?

  • po pierwsze - odpowiednie narzędzie, które umożliwi nam z poziomu języka Python tworzyć dokumenty PDF. Może to być zewnętrzny program, lub moduł/biblioteka języka Python.
  • po drugie - element (dodatek), który zrealizuje połączenie pomiędzy kodem naszego projektu Django, a tym narzędziem.

Narzędzia do tworzenia PDFów

Poniżej przedstawiam listę najpopularniejszych narzędzi związanych z zagadnieniem tworzenia dokumentów PDF z poziomu języka Python i Django. Wszystkie wymienione tutaj narzędzia i biblioteki możemy wykorzystywać z Pythonem w wersji 3.x i Django w wersji 2.x.

ReportLab

ReportLab jest darmowym, otwartoźródłowym silnikiem do tworzenia dynamicznych dokumentów PDF i grafik wektorowych napisanym w Pythonie. Na stronie producenta dostępna jest również bardziej rozbudowana, płatna wersja PLUS. Wersja PLUS posiada wiele dodatkowych funkcjonalności m.in. zaimplementowano w niej obsługę własnego języka szablonów RML (Report Markup Language).

Silnik ReportLab składa się z trzech komponentów (warstw):

  1. z niskopoziomowego API, które umożliwia “graficzne rysowanie” na płótnie reprezentującym stronę PDF
  2. z biblioteki widżetów i wykresów do tworzenia grafik
  3. z wysokopoziomowej biblioteki PLATYPUS (Page Layout And TYPography Using Scripts), która pozwala programowo budować dokumenty PDF z elementów takich jak, nagłówki, paragrafy, czcionki, tabele i grafiki wektorowe.

Instalacja silnika ReportLab sprowadza się do wydania polecenia:

$ pip install reportlab

xhtml2pdf

Xhtml2pdf jest biblioteką języka Python, która umożliwia tworzenie dokumentów PDF na podstawie zawartości HTML. Mamy tutaj możliwość zarządzania przepływem treści - automatycznym lub ręcznym dzieleniem na strony. Biblioteka ta występuje jako moduł języka Python, który może być importowany w programach (np. Django). Dodatkowo dostępny jest niezależny program, który może być wywoływany z linii poleceń.

Bibliotekę instalujemy w następujący sposób:

$ pip install xhtml2pdf

wkhtmltopdf

Kolejnym narzędziem, który możemy wykorzystać do tworzenia dokumentów PDF jest zewnętrzny program wkhtmltopdf. Podobnie jak xhtml2pdf pozwala na tworzenie dokumentów PDF na postawie zawartości HTML. Aby z niego skorzystać musimy posiadać egzemplarz programu w postaci binarnej, działający w systemie operacyjnym, w którym działa nasz projekt - ogranicza to mocno jego stosowanie np. w produkcyjnych środowiskach chmurowych (np. Heroku).

Instalacja sprowadza się do pobrania skompilowanej wersji binarnej programu lub zbudowaniu go samodzielnie ze źródeł.

W przypadku niektórych dystrybucji linuksa (Debian/Ubuntu) pakiet wkthtmltopdf dostępny jest w repozytoriach dystrybucji. Jednak po zainstalowaniu może okazać się, że jest pozbawiony niektórych funkcjonalności albo nawet nie działa w trybie headless (bez serwera X). Należy wówczas pobrać (zainstalować) odpowiednią wersję spoza repozytoriów dystrybucji.

WeasyPrint

Ostatnim narzędziem, jaki chciałbym tutaj przedstawić jest WeasyPrint. Jest to darmowy, otwartoźródłowy silnik renderujący dla zawartości HTML i CSS, który pozwala na eksport to dokumentów PDF i w założeniu ma spełniać standardy internetowe w zakresie drukowania.

Podobnie jak xhtml2pdf występuje zarównko jako moduł języka Python i jako niezależny program wykonywalny.

WeasyPrint instalujemy w następujący sposób:

$ pip install WeasyPrint

Może się okazać, że do instalacji potrzebujemy zestawu dodatkowych pakietów (w przypadku dystrybucji Linux, macOs) lub komponentów samego Pythona (Windows). W razie wystąpienia problemów z instalacją możemy wesprzeć się dokumentacją biblioteki.

Korzystamy z narzędzi PDF w Django

Znamy już dostępne narzędzia, teraz przyszła więc pora na poznanie sposobów (rozszerzeń Django) na łatwe ich użycie w kodzie naszych projektów. Każde wyżej wymienione narzędzie (poza ReportLabe) posiada dedykowane rozszerzenie, które pośredniczy pomiędzy Django a danym narzędziem.

narzędzie rozszerzenie
ReportLab -
xhtml2pdf django-xhtml2pdf
wkhtmltopdf django-wkhtmltopdf
WeasyPrint django-weasyprint

Oczywiście korzystanie z tych rozszerzeń nie jest obligatoryjne ale bardzo często jest wygodne i umożliwia znaczne uproszczenie kodu - co pokażę w dalszej części artykułu.

Instalacja każdego z tych rozszerzeń to tradycyjnie:

$ pip install django-xxxxx

gdzie xxxxx to nazwa rozszerzenia.

W przypadku, gdy korzystamy z rozszerzenia django-wkhtmltopdf to musimy jeszcze uzupełnić konfigurację o następujące elementy:
  • dopisujemy 'wkhtmltopdf' do _INSTALLED_APPS_
  • w ustawieniach (settings) definiujemy ścieżkę do narzędzia: WKHTMLTOPDF_CMD = '/path/to/my/wkhtmltopdf'
  • weryfikujemy czy w ustawieniach mamy zdefiniowaną zmienną określającą ścieżkę dla plików statycznych STATIC_ROOT

Metody tworzenia PDFów

Renderowanie PDF w widoku

Pierwszą metodą tworzenia PDFów w Django, jaką chciałbym tutaj pokazać jest metoda polegająca na tworzeniu dokument PDF w widoku. W momencie wywołania funkcji widoku, tworzony jest dynamicznie dokument PDF i przekazywany jest on do obiektu response (HttpResponse) celem zwrócenia do przeglądarki.

ReportLab

Dla narzędzia ReportLab przykładowy kod widoku wygląda następująco (niskopoziomowe API):

import io
from django.http import FileResponse, HttpResponse
from reportlab.pdfgen import canvas
from reportlab.pdfbase import pdfmetrics, ttfonts


def pdf_view(request):
    buffer = io.BytesIO()

    pdfmetrics.registerFont(ttfonts.TTFont('Arial', 'arial.ttf'))

    pdf = canvas.Canvas(buffer, pagesize='A4')
    pdf.setFont("Arial", 15)
    pdf.drawString(40, 500, "Żółw - przykładowy Tekst")
    pdf.showPage()
    pdf.save()

    response = HttpResponse(buffer.getvalue(), content_type="application/pdf")
    response['Content-Disposition'] = 'attachment; filename="dokument.pdf"'
    return response

a tworzenie PDFów z wykorzystaniem PLATYPUS:

import tempfile
import os

from django.http import FileResponse

from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import getSampleStyleSheet
from reportlab.lib.units import cm
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer
from reportlab.pdfbase import pdfmetrics, ttfonts


def pdf_view(request):
    path = os.path.join(tempfile.mkdtemp(), 'dokument.pdf')

    pdfmetrics.registerFont(ttfonts.TTFont('Arial', 'arial.ttf'))

    doc = SimpleDocTemplate(path, pagesize=A4)
    styles = getSampleStyleSheet()
    story = [Spacer(1, 1*cm)]
    style = styles["Normal"]
    style.fontName = 'Arial'

    p = Paragraph("Żółw - przykładowy Tekst", style)
    for i in range(5):
        story.append(p)
        story.append(Spacer(1, 1*cm))

    doc.build(story)

    pdf = open(path, "rb")
    response = FileResponse(pdf, as_attachment=True,
                            filename='dokument.pdf')
    return response

xhtml2pdf

Przygotujemy najpierw szablon HTML django, który będziemy wykorzystywać kolejnych przykładowych widokach:

# dokument.html
<html>
  <head>
    <meta charset="UTF-8" />
    <title>{{ title }}</title>
    <style>
      @page {
        size: a4 portrait;
        margin: 2cm;
      }
      @font-face {
          font-family: 'Lato';
          src: url(https://github.com/google/fonts/blob/master/ofl/lato/Lato-Regular.ttf?raw=True);
      }
      * {
          font-family: 'Lato';
      }
      body {
          background-color: #f0f0f0;
      }
    </style>
  </head>
  <body>
    <h1>{{ title }}</h1>
    <p>{{ content }}</p>
  </body>
</html>

Użyjemy najpierw “czystej” biblioteki xhtml2pdf, tak aby moć później porównać kody widoków:

# app/views.py
import os
from django.http import HttpResponse
from django.template import Context
from django.template.loader import get_template
from xhtml2pdf import pisa

def pdf_view(request):
    response = HttpResponse(content_type='application/pdf')
    response['Content-Disposition'] = 'attachment; filename="dokument.pdf"'

    context = {'title': 'xhtml2pdf', 'content': 'Żółw - przykładowy Tekst'}

    template = get_template('dokument.html')
    html = template.render(context=context)

    pdf = pisa.CreatePDF(html, dest=response)
    if pdf.err:
        return HttpResponse("We had some errors!")

    return response

następnie zobaczmy przykładowy kod widoku z wykorzystaniem dodatku django-xhtml2pdf:

# app/views.py
from django_xhtml2pdf.utils import generate_pdf

def pdf_view(request):
    response = HttpResponse(content_type='application/pdf')
    response['Content-Disposition'] = 'attachment; filename="dokument.pdf"'

    context = {'title': 'django-xhtml2pdf', 'content': 'Żółw - przykładowy Tekst'}

    result = generate_pdf('dokument.html', file_object=response, context=context)
    return result

W dodatku django-xhtml2pdf mamy już domyślnie zaimplementowaną funkcję zwrotną (callback) i nie musimy martwić się o obsługę i pobieranie linków (URL), które będą występować w szablonie HTML (lokalne i zdalne). W przypadku, gdy korzystamy z “czystej” biblioteki xhtml2pdf powinniśmy napisać własną funkcję i przekazać ją do metody CreatePDF.

dla formalności jeszcze zobaczmy jak wygląda kod widoku klasowego:

from django.views.generic import TemplateView
from django_xhtml2pdf.views import PdfMixin

class PdfView(PdfMixin, TemplateView):
    template_name = "dokument.html"

    def get_context_data(self):
        return  {'title': 'django-xhtml2pdf class based view', 'content': 'Żółw - przykładowy Tekst'}

Dodatek django-xhtml2pdf daje nam możliwość skorzystania z dekoratora pdf_decorator, dzięki któremu możemy łatwo przekształcić istniejący widok w widoku zwracający dokument PDF.

from django.shortcuts import render
from django_xhtml2pdf.utils import pdf_decorator

@pdf_decorator(pdfname='dokument.pdf')
def pdf_view(request):
    context = {'title': 'django-xhtml2pdf decorated view', 'content': 'Żółw - przykładowy Tekst'}
    return render(request, template_name='dokument.html', context=context)

wkhtmltopdf

from wkhtmltopdf.views import PDFTemplateView

class PDFView(PDFTemplateView):
    template_name = 'dokument.html'
    filename = 'dokument.pdf'
    cmd_options = {
        'page-size': 'A4',
        'orientation': 'Portrait',
        'margin-top': 20,
        'margin-bottom': 20,
        'margin-left': 20,
        'margin-right': 20
    }

    def get_context_data(self):
        return  {'title': 'django-wkhtmltopdf', 'content': 'Żółw - przykładowy Tekst'}

Tak jak wspominałem wcześniej dodatek django-wkhtmltopdf wykorzystuje zewnętrzny program wkhtmltopdf, który musi być dostępny do uruchomienia z poziomu projektu django. W pierwszej kolejności, na podstawie szablonu, tworzony jest tymczasowy plik HTML, następnie, poprzez Popen, wywoływany jest program wkhtmltopdf, którego standardowe wyjście jest przechwytywane i zwracane do widoku.

WeasyPrint

django_weasyprint dostarcza nam klasę widoku WeasyTempalteView, która może nam dostarczać dokumenty PDF. Do dyspozycji mamy też klasę pomocniczą (Mixin) WeasyTemplateResponseMixin, którą pozwala nam na prostą rozbudowę istniejących już widoków celem wygenerowania dokumentów PDF. Ten drugi sposób jako mniej trywialny przedstawiam poniżej:

from django_weasyprint import WeasyTemplateResponseMixin

class MyView(TemplateView):
    template_name = "dokument.html"

    def get_context_data(self):
        return {
            'title': 'django-weasyprint class based view',
            'content': 'Żółw - przykładowy Tekst'
        }

class WeasyPrintView(WeasyTemplateResponseMixin, MyView):
    pdf_filename = 'dokument.pdf'

Dysponujemy wcześniej napisanym widokiem który zwraca nam kod HTML MyView. Dopisujemy następnie widok WeasyPrintView, który rozszerzy istniejący widok i doda odpowiedni Mixin, tak aby uzyskać jako odpowiedź dokument PDF.

Zapis PDF do pliku

W niektórych projektach możemy spotkać się z sytuacją, gdy potrzeba będzie zapisać gdzieś na serwerze, albo odległym zasobie (np. w AWS S3) wygenerowany dokument PDF celem późniejszego wykorzystania. Do tej pory tworzenie dokumentów PDF odbywało się niejako “w locie”. Na podstawie żądania, wywoływana była funkcja widoku, która inicjowała utworzenie dokument PDF i niezwłocznie zwracała taki utworzony dokument jako odpowiedź na żądanie użytkownika. Wszystko odbywało się “w pamięci” bez użycia przestrzeni dyskowej (wyjątkiem są: wkhtmltopdf - na potrzeby tego programu przygotowany zostaje tymczasowy plik HTML, i ReportLab w trybie PLATYPUS - tutaj tworzymy na dysku tymczasowy plik PDF dla SimpleDocTemplate).

Weźmy dla przykładu konkretny przypadek użycia. Potrzebujemy funkcjonalności generowania faktur elektronicznych dla naszych klientów. Posiadamy odpowiednie modele reprezentujące klientów, usługi, ceny itp. Chcemy raz w miesiącu generować faktury PDF i automatycznie wysyłać je mailem do klientów. Dodatkowo każdy klient ma mieć dostęp do swoich faktur w formacie PDF z poziomu swojego panelu klienta.

Istotną kwestią jaka pojawia się w takim przypadku jest kwestia lokalizacji wygenerowanych plików PDF. Gdyby chodziło o samą wysyłkę mailem jako załącznika, to moglibyśmy nawet wogóle nie tworzyć pliku PDF w przestrzeni dyskowej, tylko przekazywać treść dokumentu do metody wysyłającej maile, podobnie jak do obiektu response. Moglibyśmy też skorzystać z mechanizmu plików tymczasowych Pythona. Wygenerowane pliki PDF muszą być fizycznie gdzieś przechowywane w tej sytuacji. Powstaje pytanie czy lokalizacja MEDIA_ROOT w django jest odpowiednia? W tym wypadku zdecydowanie NIE! Każdy, kto znałby lokalizację i nazwę pliku PDF byłby wstanie pobrać go z naszego serwera i to niezależnie od tego jaki schemat nazywania i rozmieszczania tych plików byśmy przyjęli.

Jedną z możliwości z jakich możemy w tym miejscu skorzystać to stworzenie lokalizacji poza MEDIA_ROOT i STATIC_ROOT (o ile zdecydujemy się na przechowywanie plików w przestrzeni dyskowej serwera) i w niej umieszczać wygenerowane pliki PDF. Dodatkowo powinniśmy stworzyć mechanizm do “serwowania” tych plików użytkownikom wyposażony w mechanizmy autoryzacyjne. Pomocna tutaj mogą okazać sie klasa FileSystemStorage oraz pola typu db.models.FileField.

Zobaczmy jak wygląda fragment kodu, umożliwiający zapis dokumentu PDF do pliku z wykorzystaniem WeasyPrint:

from weasyprint import HTML

def save_pdf_file(pdf_path, html_string):
    html = HTML(string=html_string)
    html.write_pdf(target=pdf_path)

i analogicznie pobieranie takiego pliku:

from os import path
from django.core.files.storage import FileSystemStorage

def download_pdf_file(pdf_path, response):
    fs = FileSystemStorage(path.dirname(pdf_path))
    with fs.open(path.basename(pdf_path)) as pdf:
        response = HttpResponse(pdf, content_type='application/pdf')
        response['Content-Disposition'] = f'attachment; filename="{path.basename(pdf_path)}"'
        return response
    return response

Wydajność

Generowanie dokumentów PDF bezpośrednio w widoku może się wiązać z pewnym narzutem czasowym, który z punktu widzenia użytkownika przeglądarki może być czasami nieakceptowalny. Należy wówczas wykorzystać metody asynchronicznego generowania dokumentów (np. przy pomocy Celery).

Średnie czasy odpowiedzi na żądanie w przypadku wyżej wymienionych metod i przedstawionego prostego szablonu HTML, oscylują w granicach 1000-1700 ms (czyli od 1 do ok. 2 sekund). Bardziej złożone dokumenty mogą generować się nieco dłużej. Przykładowo w podobnych warunkach kilkunastostronicowy dokument PDF z grafikami i uzupełnianiem danych z modeli generuje się ok 5 sekund.

Podsumowanie

Podczas pracy z tworzeniem dokumentów PDF możemy spotkać się kilkoma problemami, które na zakończenie tylko zasygnalizuję:

  • osadzanie czcionek i problemy z polskimi (międzynarodowymi) znakami diakrytycznymi
  • osadzanie obrazów będących zdalną zawartością (http vs https)
  • skalowanie obrazów przy umieszczaniu w PDF
  • problemy z automatycznym podziałem tekstu między stronami w przypadku tabel czy nawet paragrafów.
  • brak obsługi wielu właściwości CSS w różnych narzędziach.

Jeśli macie własne doświadczenia i przemyślenia w temacie tworzenia dokumentów PDF online to zapraszam do dzielenia się w komentarzach.

Zostaw komentarz