Konwersja HTML na PDF w języku Python

Styczeń 12, 2025 | #python

000101
000000
111000
000011

Pracując nad zadaniem polegającym na dodaniu do dokumentu publikowanego w formie HTML i PDF danych teleadresowych w postaci kodu QR, spotkałem się z problemem czytelności niewielkiego kodu. Ponieważ w grę wchodziło m.in. generowanie PDF-a - a miałem z tym kiedyś nie małe przeboje - zapowiadało się, że spędzę nad tym trochę czasu. Niekiedy przy takich okazjach robię na boku osobny skrypt, albo nawet niewielką aplikację aby poeksperymentować, albo zaimplementować coś na szybko - taki Proof of Concept (w skórcie PoC). W projekcie nie miałem możliwości zmiany narzędzia do generowania PDF-a (bez dobrych argumentów), ale ponieważ w trakcie urlopu też trzeba coś robić postanowiłem dodatkowo przetestować różne biblioteki Python-a oferujące możliwość konwersji HTML do PDF.

Generowanie QR kodu z danymi teleadresowymi w formacie vCard

QR kod w założeniu miał zawierać dane w formacie vCard (inaczej VCF (Virtual Contact File)). Są to wirtualne wizytówki, które przesłane e-mail-em, MMS-em czy zeskanowane z kodu QR można łatwo dodać do książki teleadresowej.

import vobject

def create_vcard(
    name: str,
    first_name: str | None = None,
    last_name: str | None = None,
    title: str | None = None,
    phone: str | None = None,
    email: str | None = None,
    website: str | None = None,
    address: str | None = None
) -> str:
    v_card = vobject.vCard()
    v_card.add('fn').value = name
    if first_name or last_name:
        n = v_card.add('n')
        n.value = vobject.vcard.Name(
            family=last_name,
            given=first_name
        )
    if title:
        v_card.add('title').value = title
    if address:
        adr = v_card.add('adr')
        adr.type_param = 'WORK'
        adr.value = vobject.vcard.Address(
            street=address['street'],
            city=address['city'],
            code=address['postal_code'],
            country=address['country']
        )
    if phone:
        tel = v_card.add('tel')
        tel.type_param = 'WORK,VOICE'
        tel.value = phone
    if email:
        email_field = v_card.add('email')
        email_field.type_param = 'INTERNET'
        email_field.value = email
    if website:
        url = v_card.add('url')
        url.type_param = 'WORK'
        url.value = website
    return v_card.serialize()
    
vcard_data = {
    'name': 'Jan Kowalski',
    'first_name': 'Jan',
    'last_name': 'Kowalski',
    'title': 'Software Developer',
    'phone': '+48 123 456 789',
    'email': 'jan.kowalski@example.com',
    'website': 'https://example.com',
    'address': {
        'street': 'ul. Przykładowa 1',
        'city': 'Miasto',
        'postal_code': '00-000',
        'country': 'Polska'
    }
}

vcard = create_vcard(**vcard_data)

QR kod w formacie PNG

Początkowo wygenerowałem kod QR używając do tego biblioteki qrcode, która domyślnie generuje obrazki w formacie PNG.

import qrcode
# from qrcode.constants import ERROR_CORRECT_L
from io import BytesIO
import base64

def generate_qr_code_png(vcard: str) -> str:
    qr = qrcode.QRCode(
        # version=1,
        # error_correction=ERROR_CORRECT_L,
        # box_size=10,
        # border=4
    )
    qr.add_data(vcard)
    qr.make(fit=True)
    buffer = BytesIO()
    qr.make_image(fill='black', back_color='white').save(buffer, format='PNG')
    img_base64 = base64.b64encode(buffer.getvalue()).decode('utf-8')
    return f'data:image/png;base64,{img_base64}'
    
qr_uri_png = generate_qr_code_using_qrcode_png(vcard)

Przetestowałem też zaawansowane ustawienia zmieniając wartości zakomentowanych parametrów, ale na potrzeby tego eksperymentu pokazuję wyniki dla ustawień domyślnych.

QR kod w formacie SVG

Postanowiłem także wygenerować kod QR w formacie SVG, aby sprawdzić czy wpłynie to na czytelność kodu.

import qrcode.image.svg

def generate_qr_code_svg(vcard: str) -> str:
    qr = qrcode.QRCode(
        image_factory=qrcode.image.svg.SvgPathImage
    )
    qr.add_data(vcard)
    qr.make(fit=True)
    img = qr.make_image(attrib={'class': 'qr-code'})
    return img.to_string(encoding='unicode')
    
qr_svg = generate_qr_code_using_qrcode_svg(vcard)

Obawiałem się o to, czy przy konwersji na PDF nie będzie problemu z formatem SVG. O narzędzia korzystające w tle z silników przeglądarek byłem spokojny. Natomiast biblioteki
weasyprint i xhtml2pdf stanowiły niewiadomą.

Przygotowanie kodu HTML do testów konwersji na PDF

Osadzenie QR kodu w strukturze HTML nie jest wyzwaniem w końcu mamy do czynienia ze standardowym plikiem PNG (lub jak w naszym przypadku plikiem zakodowanym do formatu base64) lub SVG. Odrobinę obaw miałem jednak w związku z kontrolą wielkości kodu QR a przede wszystkim ze skalowaniem go bez utraty jakości i czytelności. Nie ukrywam, że duże nadzieje pokładałem przede wszystkim w formacie SVG.

Renderowanie kodów QR to nie jedyne testy jakie zaplanowałem dla narzędzi konwersji kodu HTML do PDF. Z tego względu przygotowałem sobie trzy szablony html. Do wypełniania szablonów zmiennymi i renderowania html użyłem Jinja2.

from jinja2 import Environment, PackageLoader, select_autoescape

jinja = Environment(
    loader=PackageLoader("app", "templates"),
    autoescape=select_autoescape()
)

def generate_html(template, data):
    return jinja.get_template(template).render(**data)

Kod HTML postanowiłem generować w locie, ale z uwagi na potrzebę porównania wersji HTML do wygenerowanego na ich podstawie pliku PDF przygotowałem także funkcję, która zapisuje wyrenderowany szablon do wynikowego pliku html.

def save_html(html_template, html_file):
    with open(html_file, "w", encoding='utf-8') as file:
        file.write(html_template)

Kod QR w formacie PNG i SVG oraz generowanie dynamiczne treści z użyciem JavaScript

Pierwszy szablon zawiera m.in. kod QR w dwóch formatach. W formie pliku PNG oraz w formacie SVG. Dane zostały minimalnie sformatowane, dlatego dadzą też okazję sprawdzić odzwierciedlenie styli CSS w pliku PDF. Dodatkowo adres email został zakodowany za pomocą JavaScriptu w celu obsofukacji. Testowanie skuteczności technik zaciemniania kodu nie jest przedmiotem tego eksperymentu ale pozwoli sprawdzić jak narzędzia do tworzenia PDF-ów z kodu HTML radzą sobie z treściami generowanymi dynamicznie - w tym wypadku przez JavaScript.

Plik ./app/templates/business_card_1.html

<html>
<head>
    <meta charset='utf-8'>
    <title>Business Card</title>
    <link href='https://fonts.googleapis.com/css2?family=Roboto+Condensed&display=swap' rel='stylesheet'>
    <style>
        body {
            font-family: 'Roboto Condensed', sans-serif;
        }
        .card {
            border: 1px solid #000;
            padding: 20px;
            width: 300px;
            text-align: center;
        }
        .card h2 {
            font-size: 1.5em;
            font-weight: bold;
        }
        .card p {
            margin: 5px 0;
        }
        .card strong {
            font-weight: bold;
        }
        .qr-container {
            /* display: flex; */
            justify-content: center;
            align-items: center;
            margin-top: 10px;
        }
        .qr-container .qr-code {
            width: 150px;
            height: 150px;
            object-fit: contain;
        }
    </style>
</head>
<body>
    <div class='card'>
        <h2>{{ name}}</h2>
        <p><strong>Phone:</strong> {{ phone }}</p>
        <p><strong>Email:</strong> <a id="emailLinkID" href=""></a></p>
        <p><strong>Website:</strong> <a href='{{ website }}'>{{ website }}</a></p>
        <p><strong>Address:</strong> {{ address.street }}<br>{{ address.city }}, {{ address.postal_code }}, {{ address.country }}</p>
        <div class='qr-container'>
            <img src='{{ qr_uri_png }}' class="qr-code" alt='QR Code' />
            {{ qr_svg|safe }}
        </div>
    </div>
    <script>
        const emailForm = document.getElementById("emailLinkID");
        emailForm?.setAttribute("href", "mailto:".concat(window.atob(window.btoa("{{ email }}"))));
        emailForm.textContent = window.atob(window.btoa("{{ email }}"));
    </script>
</body>
</html>

Zakomentowana linia kodu w CSS /* display: flex; */ została zachowana w przykładzie nie bez powodu. Odniosę się do niej przy okazji testowania weasyprint.

Wyrenderowany HTML z tego szablonu wygląda następująco:

Uwaga to co widzisz to SVG ale możesz podejrzeć jak wygląda rzeczywisty HTML business_card_1.html

Rozbudowany CSS

Drugi szablon ma na celu sprawdzenie jak konwertery PDF poradzą sobie z nieco bardziej rozbudowanym CSS-em. W tym wypadku skorzystałem z gotowego projektu wizytówki przygotowanego przez - Joshua Ward.

Plik ./app/templates/business_card_2.html

<html>
<head>
    <meta charset='utf-8'>
    <title>Business Card</title>
    <style>
        *, *:before, *:after {
            box-sizing: border-box;
            outline: none;
        }

        html {
            font-family: "Source Sans Pro", sans-serif;
            font-size: 16px;
            font-smooth: auto;
            font-weight: 300;
            line-height: 1.5;
            color: #444;
        }

        body {
            position: relative;
            display: flex;
            align-items: center;
            justify-content: center;
            width: 100%;
            height: 100vh;
            background: linear-gradient(45deg, #E91E63, #9C27B0);
        }

        code, .card .line-numbers {
            font-family: "Lucida Console", Monaco, monospace;
            font-size: 14px;
        }

        .card {
            position: relative;
            width: 30rem;
            height: 15rem;
            perspective: 150rem;
        }
        .card-front, .card-back {
            position: absolute;
            width: 100%;
            height: 100%;
            display: flex;
            align-items: center;
            justify-content: center;
            margin: auto;
            border-radius: 5px;
            box-shadow: 0 1.5rem 4rem rgba(0, 0, 0, 0.4);
            transition: 0.9s cubic-bezier(0.25, 0.8, 0.25, 1);
            backface-visibility: hidden;
            overflow: hidden;
        }
        .card-front:before, .card-front:after, .card-back:before, .card-back:after {
            position: absolute;
        }
        .card-front:before, .card-back:before {
            top: -40px;
            right: -40px;
            content: "";
            width: 80px;
            height: 80px;
            background-color: rgba(255, 255, 255, 0.08);
            transform: rotate(45deg);
            z-index: 1;
        }
        .card-front:after, .card-back:after {
            content: "+";
            top: 0;
            right: 10px;
            font-size: 24px;
            transform: rotate(45deg);
            z-index: 2;
        }
        .card-front {
            width: 100%;
            height: 100%;
            background: linear-gradient(45deg, #101010, #2c3e50);
        }
        .card-front:after {
            color: #212f3c;
        }
        .card-back {
            background: linear-gradient(-45deg, #101010, #2c3e50);
            transform: rotateX(180deg);
        }
        .card-back:after {
            color: #11181f;
        }
        .card:hover .card-front {
            transform: rotateX(-180deg);
        }
        .card:hover .card-back {
            transform: rotateX(0deg);
        }
        .card .line-numbers {
            position: absolute;
            top: 0;
            left: 0;
            display: flex;
            flex-direction: column;
            align-items: center;
            justify-content: center;
            height: 100%;
            margin: 0;
            padding: 0 10px;
            background-color: rgba(255, 255, 255, 0.03);
            font-size: 13px;
        }
        .card .line-numbers > div {
            padding: 2.5px 0;
            opacity: 0.15;
        }
        .card code, .card .line-numbers {
            color: whitesmoke;
        }
        .card .indent {
            padding-left: 30px;
        }
        .card .operator {
            color: #4dd0e1;
        }
        .card .string {
            color: #9ccc65;
        }
        .card .variable {
            color: #BA68C8;
        }
        .card .property {
            color: #ef5350;
        }
        .card .method {
            color: #29b6f6;
        }
        .card .function {
            color: #FDD835;
        }
        .card .boolean {
            color: #4dd0e1;
        }
    </style>
</head>
<body>
    <div class="card">
        <div class="card-back">
            <div class="line-numbers">
                <div>1</div><div>2</div><div>3</div><div>4</div><div>5</div><div>6</div><div>7</div><div>8</div><div>9</div>
            </div>
            <code>
                <div>
                    <span class="variable">const </span><span class="function">aboutMe</span><span class="operator"> = </span>{
                </div>
                <div class="indent">
                    <span class="property">name</span><span class="operator">: </span><span class="string">'{{ name }}'</span>,
                </div>
                <div class="indent">
                    <span class="property">title</span><span class="operator">: </span><span class="string">'{{ title }}'</span>,
                </div>
                <div class="indent">
                    <span class="property">contact</span><span class="operator">:</span> {
                    <div class="indent">
                        <span class="property">email</span><span class="operator">: </span><span class="string">'{{ email }}'</span>,
                    </div>
                    <div class="indent">
                        <span class="property">website</span><span class="operator">: </span><span class="string">'{{ website }}'</span>
                    </div>
                    <div>}</div>
                </div>
                <div>}</div>
            </code>
        </div>
        <div class="card-front">
            <div class="line-numbers">
                <div>1</div><div>2</div><div>3</div><div>4</div><div>5</div><div>6</div><div>7</div><div>8</div><div>9</div>
            </div>
            <code>
                <div>
                    <span class="variable">this</span>.<span class="method">addEventListener</span>(<span class="string">'mouseover'</span>,
                    <span class="function">() =></span> {
                </div>
                <div class="indent">
                    <span class="variable">this</span>.<span class="property">flipCard</span> = <span class="boolean">true</span>;
                </div>
                <div>});</div>
            </code>
        </div>
    </div>
</body>

Po wyrenderowaniu wizytówka prezentuje się tak:

Możesz podejrzeć jak wygląda rzeczywisty HTML business_card_2.html

W podglądzie wydruku wizytówka standardowo pozbawiona jest kolorów tła:

@media print

Dokument w formacie PDF ze swej natury ma raczej statyczny charakter i nikt nie oczekuje aby reagował na zdarzenia JavaScript jak hover, click (nie licząc odsyłaczy do innych zasobów) it.. Z tego powodu często chcielibyśmy aby efekt końcowy przypominał bardziej wydruk niż print-screen. Można oczekiwać, że zwłaszcza biblioteki korzystające w tle z silników przeglądarek będą w trakcie konwersji strony www do PDF respektować reguły CSS przeznaczona dla widoku wydruku. Dlatego ostatni z szablonów jest dokładnie taki sam jak poprzedni a dodano do niego jedynie reguły CSS @media print.

Plik ./app/templates/business_card_3.html

@media print {
    body {
        position: static;
        display:block;
        width: auto;
        height: auto;
        /* background: none; */
    }

    .card-front:before, .card-front:after, .card-back:before, .card-back:after {
        display: none;
    }

    .card-front, .card-back {
        box-shadow: none;
        transition: none;
    }

    .card:hover .card-front, .card:hover .card-back {
        transform: rotateX(0deg);
    }

    .card-back {
        transform: rotateX(0deg);
        top: 250px;
    }

    .card {
        print-color-adjust: exact;
        -webkit-print-color-adjust: exact;
    }
}

Dzięki dodatkowym stylom wyżej zaprezentowana wizytówka w podglądzie wydruku prezentuje się następująco:

Możesz podejrzeć jak wygląda rzeczywisty HTML business_card_3.html

Konwersja HTML na PDF w języku Python

Zależy nam aby wygenerowany PDF możliwie dobrze odzwierciedlał wygląd wyrenderowanego kodu HTML.

W przypadku pierwszego szablonu skupimy uwagę na:

  • strukturze dokumentu
  • pogrubieniu wyróżnionych słów
  • poprawnym wyświetleniu obu kodów QR zarówno w wersji PNG jak i SVG
  • wyświetleniu treści adresu email, który - jak przypominam - zbudowany jest dynamicznie z użyciem JavaScript

W drugim przypadku testowym interesuje nas:

  • struktura dokumentu w tym jej pozycjonowanie w centrum strony
  • odwzorowanie kaskadowych styli CSS i wygląd wizytówki tak jak widzi ją użytkownik, który nie podejmuje żadnych akcji (nie najeżdża myszką na interaktywny obiekt)

Ostatni wariant pozwoli sprawdzić:

  • czy respektowane są style dla trybu wydruku

XHTML2PDF

Biblioteka xhtml2pdf (dawniej PISA) jest napisana w czystym Pythonie. Parsuje kod HTML i CSS za pomocą parsera html5lib (bądź w starszych wersjach inne parsery) przekształcając go do wewnętrznej reprezentacji, która jest zrozumiała dla ReportLab - silnika (toolkit) odpowiedzialnego za faktyczne tworzenie pliku PDF.

Generowanie PDF z kodu HTML przy użyciu xhtml2pdf

from xhtml2pdf import pisa


def generate_pdf_using_xhtml2pdf(html, pdf_path):
    try:
        with open(pdf_path, "wb") as pdf_file:
            pisa_status = pisa.CreatePDF(html, dest=pdf_file)
        print(f"Business card has been saved as {pdf_path}")
        return not pisa_status.err
    except Exception as e:
        print(f"Failed to export to PDF using xhtml2pdf: {e}")
        return False
from pathlib import Path

base_path = Path.cwd()
output_path = base_path / "output"

for i in range(1, 4):
    html = generate_html(f"business_card_{i}.html", vcard_data)
    generate_pdf_using_xhtml2pdf(html=html, pdf_path=output_path / f"xhtml2pdf_{i}.pdf")

Wizytówka 1. w formacie PDF wygenerowana przez xhtml2pdf

Wynik pierwszego zadania nie napawa optymizmem. Widać, że narzędzie poza tym, że wyświetliło treść nie poradziło sobie ze stylami CSS, treścią renderowaną przez JavaScript a także z QR kodem w formacie SVG.

xhtml2pdf HTML
vs.

WIzytówka 2 i 3 w formacie PDF wygenerowana przez xhtml2pdf

Wynik zadania 2 i 3 był w tym wypadku identyczny i nie ma co tu porównywać do wzorca. Potwierdza to, że xhtml2pdf nie nadaje się do odwzorowywanie HTML-a sformatowanego przy pomocy nieco bardziej złożonego CSS-a. Choć projekt xhtml2pdf jest cały czas rozwijany (w czasie pisania tego artykułu ostatnie wydanie miało miejsce 8 czerwca 2024 r.), nie sądzę aby były to prace inne, niż tylko utrzymaniowe typu aktualizacja zależności do najnowszych wersji, ewentualnie poprawa drobnych błędów.

WeasyPrint

Biblioteka weasyprint także jest napisana w języku Python. Dzięki lxml tworzy ona wewnętrzną reprezentację dokumentu (tree), oblicza style i strukturę layoutu przy pomocy tinycss2. Następnie wykorzystuje Pango do składu tekstu oraz Cairo do renderowania grafiki i zapisania go w formacie PDF (lub innego typu, np. PNG).

Generowanie PDF z kodu HTML przy użyciu WeasyPrint

from weasyprint import HTML

def generate_pdf_using_weasyprint(html, pdf_file):
    try:
        HTML(string=html).write_pdf(pdf_file)
        print(f"Business card has been saved as {pdf_file}")
    except Exception as e:
        print(f"Failed to export to PDF using WeasyPrint: {e}")
from pathlib import Path

base_path = Path.cwd()
output_path = base_path / "output"

for i in range(1, 4):
    html = generate_html(f"business_card_{i}.html", vcard_data)
    generate_pdf_using_weasyprint(html=html, pdf_file=output_path / f"weasyprint_{i}.pdf")

Wizytówka 1 w formacie PDF wygenerowana przez WeasyPrint

Wynik pierwszego zadania pokazuje, że weasyprint poradził sobie ze strukturą dokumentu, i że w jego przypadku nie ma predefiniowanych styli dla niektórych elementów HTML tak jak ma to miejsce w przeglądarkach. W tym wypadku teksty otoczone tagiem <strong> nie został pogrubione. Pracując z tą biblioteką warto stosować arkusze styli resetujące domyślne style przeglądarki i definiujące wygląd każdego spodziewanego elementu HTML w dokumencie.

Adres email renderowany przy pomocy JavaScript niestety nie pokazał się na PDF-ie więc biblioteka weasyprint nie oferuje wsparcia w tym zakresie.

Nie zauważyłem za to żadnego problemu z kodem QR. Zarówno w formacie PNG, jak i SVG kody na pierwszy rzut oka wyglądały identycznie i (choć poniższa grafika z uwagi na obniżoną jakość nie pozwala tego udowodnić) oba kody były czytelne.

weasyprint HTML
vs.

Wizytówka 2 w formacie PDF wygenerowana przez WeasyPrint

WeasyPrint domyślnie wyświetla tło więc w przypadku tej biblioteki nie trzeba się o to martwić. Powstałe marginesy to zapewne kwestia ustawień, ale style odpowiedzialne za wyrównanie tekstu w pionie są wyzwaniem dla tinycss2.

weasyprint HTML
vs.

Wizytówka 3 w formacie PDF wygenerowana przez WeasyPrint

Choć weasyprint respektuje style dla wydruku to problem z pozycjonowaniem nie daje zadowalającego rezultatu.

Python-PDFKit (wkhtmltopdf)

Python pdfkit to wrapper (opakowanie) napisane w Python (istnieją też wersje dla innych języków m.in. Ruby, JavaScript) na wkhtmltopdf - program renderujący kod HTML za pośrednictwem silnika WebKit i zapisującego go ostatecznie w formacie PDF.

Generowanie PDF z kodu HTML przy użyciu pdfkit i wkhtmltopdf

import pdfkit

def generate_pdf_using_wkhtmltopdf(html, pdf_file):
    try:
        options = {
            'print-media-type': None,
            'background': None,
        }
        pdfkit.from_string(html, pdf_file, options=options)
        print(f"Business card has been saved as {pdf_file}")
    except Exception as e:
        print(f"Failed to export to PDF using wkhtmltopdf: {e}")
from pathlib import Path

base_path = Path.cwd()
output_path = base_path / "output"

for i in range(1, 4):
    html = generate_html(f"business_card_{i}.html", vcard_data)
    generate_pdf_using_wkhtmltopdf(html=html, pdf_file=output_path / f"wkhtmltopdf_{i}.pdf")

Wkhtmltopdf to program działający w linii komend, który można wywołać z licznymi opcjami.

wkhtmltopdf --print-media-type input.html output.pdf

Zwróć uwagę, że w przypadku niektórych opcji (tych, które w wkhtmltopdf nie oczekują wartości, tylko samej flagi, np. --print-media-type lub --no-print-media-type) w słowniku opcji pdfkit przypisujemy wartość None.

Wspomnę tylko, że testując użycie obu tych opcji nie zauważyłem różnicy i w obu przypadkach silnik WebKit interpretował style przeznaczone do druku @media print tak jakby pdfkit wymuszał opcję print-media-type. Za to używając opcji no-background można było usunąć grafikę czy kolor tła.

Wizytówka 1 w formacie PDF wygenerowana przez pdfkit

Wynik jest nieomal identyczny do weasyprint. Także wkhtmltopdf nie pogrubił zawartości tagów <strong> i nie wyrenderował dynamicznej treści, choć prosty JavaScript powinien być teoretycznie osiągalny dla silnika WebKit. Kody QR zostały za to wyświetlone poprawnie w obu formatach.

pdfkit (wkhtmltopdf) HTML
vs.

Próbowałem dodać opcje aktywujące JavaScript.

options = {
    ...
    'enable-javascript': None,
    'javascript-delay': 2000,
}
pdfkit.from_string(html, pdf_file, options=options)

Niestety nie spowodowało to, że w wyniku zobaczyłem na dokumencie PDF adres email. Zwiększanie javascript-delay (domyślnie jest 200) nic nie zmieniało.

Wizytówka 2 w formacie PDF wygenerowana przez pdfkit

Prawdopodobnie wkhtmltopdf używa starszej wersji silnika WebKit, który ma problemy z niektórymi dyrektywami CSS-a. W każdym razie nie poradził sobie z centrowaniem wizytówki.

pdfkit (wkhtmltopdf) HTML
vs.

Wizytówka 3 w formacie PDF wygenerowana przez pdfkit

Wynik trzeciej próby potwierdza, że wkhtmltopdf uwzględnia style dedykowane drukarkom, ale że też generalnie radzi sobie z CSS-em całkiem nieźle i choć może nie jest idealnie to do większości zadań się sprawdzi.

Pyppeteer

Pyppeteer to biblioteka Pythona, będąca nieoficjalnym portem popularnego narzędzia Puppeteer (napisanego w Node.js i rozwijanego przez zespół odpowiedzialny za Chrome DevTools w firmie Google). Puppeteer pozwala na automatyzację czynności w przeglądarce Chrome (lub Chromium) w trybie bezgłowym (ang. headless) - czyli bez interfejsu graficznego. Przydaje się do testów frontendu aplikacji webowych, scrapingu stron, monitorowania i raportowania stanu lub zmian na stronach www itp. W naszym przypadku sprawdza się także jako narzędzie konwersji kodu HTML do PDF.

Generowanie PDF z kodu HTML przy użyciu pyppeteer

from pyppeteer import launch

async def generate_pdf_using_pyppeteer(html, pdf_file):
    browser = await launch(
        headless=True,
        args=['--no-sandbox']
    )
    options = {
        "printBackground": True
    }
    try:
        page = await browser.newPage()
        await page.setContent(html)
        await page.pdf({'path': pdf_file, **options})
        print(f"Business card has been saved as {pdf_file}")
    except Exception as e:
        print(f"Failed to export to PDF using Pyppeteer: {e}")
    finally:
        await browser.close()
import asyncio
from pathlib import Path

base_path = Path.cwd()
output_path = base_path / "output"

tasks = []

for i in range(1, 4):
    html = generate_html(f"business_card_{i}.html", vcard_data)
    tasks.append(generate_pdf_using_pyppeteer(html=html, pdf_file=output_path / f"pyppeteer_{i}.pdf"))

async def generate_pdf_using_async_functions():
    if tasks:
        results = await asyncio.gather(*tasks, return_exceptions=False)
        for result in results:
            if isinstance(result, Exception):
                print(result)

Wizytówka 1 w formacie PDF wygenerowana przez pyppeteer

Spodziewałem się, że pyppeteer poradzi sobie z każdym zadaniem z tego eksperymentu. I rzeczywiście o ile kaskadowe style CSS i kod QR w formacie PNG i SVG zostały odwzorowane wzorcowo, o tyle zaskoczył mnie brak adresu email, który jest generowany za pomocą prostej funkcji JavaScript.

pyppeteer HTML
vs.

W puppeteer napisanym w Node.js do metody setContent obiektu Page można przekazać dodatkowe opcje w tym waitUntil pozwalającej wpłynąć na zachowanie przeglądarki, która poczeka na wyrenderowanie strony w zależności od wybranej opcji.

await page.setContent(reuslt, {waitUntil: "domcontentloaded"});

W wersji pyppeteer metoda setContent nie przyjmuje opcji, dlatego trzeba sobie inaczej poradzić. Najprostszym rozwiązaniem jest "poczekać" na wykonanie JS w HTML.

await page.setContent(html)
await asyncio.sleep(0.5)
await page.pdf({'path': pdf_file, **options})

Zaproponowany sposób jest nieelegancki, ale działa. Gdybym jednak miał zdecydować się na produkcyjne użycie pyppeteer-a poszukałbym zgrabniejszego rozwiązania.

Wizytówka 2 w formacie PDF wygenerowana przez pyppeteer

Ustawienie opcji printBackground na True sprawiło, że widok HTML i wygenerowany na jego podstawie PDF wyglądają bliźniaczo podobnie. Myślę, że wynik jest w pełni zadowalający i choć jestem w stanie dostrzec drobne różnice to są one pomijalne.

pyppeteer HTML
vs.

Wizytówka 3 w formacie PDF wygenerowana przez pyppeteer

Style dla wydruku są respektowane przez pyppeteer-a, a gdyby usunąć wspomnianą już opcję printBackground to widzielibyśmy czarne wizytówki na białym tle.

Playwright

Nad rozwojem playwright pieczę sprawuje Microsoft mimo to, są to otwartoźródłowe (ang. open-source) biblioteki umożliwiające automatyzację przeglądarek internetowych (m.in. Chrome, Firefox, Edge, WebKit) w wielu językach programowania - w tym w Pythonie. Udostępniają zestaw narzędzi i funkcji przydatnych podczas realizacji testów end-to-end (E2E), web scrapingu / crawlingu, testów wizualnych i wydajnościowych oraz generowania PDF-ów.

Generowanie PDF z kodu HTML przy użyciu playwright

from playwright.async_api import async_playwright


async def generate_pdf_using_playwright(html, pdf_file, browser_name="chromium", channel_name='chromium'):
    options = {
        "print_background": True
    }
    try: 
        async with async_playwright() as p:
            browser = await getattr(p, browser_name).launch(channel=channel_name)
            page = await browser.new_page()
            await page.set_content(html)
            await page.pdf(path=pdf_file, **options)
            await browser.close()
        print(f"Business card has been saved as {pdf_file}")
    except Exception as e:
        print(f"Failed to export to PDF using playwright: {e}")

Ponieważ playwright w tle może używać różnych silników przeglądarek postanowiłem wypróbować wszystkie :)

import asyncio
from pathlib import Path

base_path = Path.cwd()
output_path = base_path / "output"

tasks = []

playwright_channels = {
    "chromium": ["chromium", "chrome", "msedge"],
    "firefox": ["firefox"],
    "webkit": ["webkit"],
}

for i in range(1, 4):
    html = generate_html(f"business_card_{i}.html", vcard_data)
    for browser, channels in playwright_channels.items():
        for channel in channels:
            tasks.append(generate_pdf_using_playwright(
                html=html,
                pdf_file=output_path / f"playwright_{browser}_{channel}_{i}.pdf",
                browser_name=browser,
                channel_name=channel
            ))

async def generate_pdf_using_async_functions():
    if tasks:
        results = await asyncio.gather(*tasks, return_exceptions=False)
        for result in results:
            if isinstance(result, Exception):
                print(result)

Dla prób użycia Firefox i Webkit do generowania PDF otrzymałem błąd:

Failed to export to PDF using playwright: Page.pdf: PDF generation is only supported for Headless Chromium

Po sprawdzeniu w dokumentacji tylko Chromium w playwright ma oficjalne wsparcie dla page.pdf()

Wizytówka 1 w formacie PDF wygenerowana przez playwright

Niezależnie czy użyłem Chromium w wersji chrome, chromium czy msedge wyniki otrzymałem identyczne (a przynajmniej ja ich nie dostrzegłem) dlatego prezentuję tylko jeden wynik.

I jest dobrze. Wszystkie wymagania zostały spełnione. Dokument układa się w prawidłową strukturę. Etykiety są pogrubione i nawet wygenerowany przez JavaScript email widoczny jest na PDF-ie bez konieczności stosowania niestandardowych opcji.

playwright HTML
vs.

Uwaga! - Wygenerowana wizytówka wydaje się być nieco węższa ale należy pamiętać, że w obu egzemplifikacjach mamy do czynienia z obrazkami - zrzutem ekranu PDF do PNG i przekonwertowanym kodem HTML do SVG więc w rzeczywistości może to wyglądać nieco inaczej.

Wizytówka 2 w formacie PDF wygenerowana przez playwright

Widać drobne różnice w kolorach i cieniach, ale podobnie jak to miało miejsce w przypadku pyppeteer postawiłem na minimalną konfigurację ograniczającą się jedynie do ustawienia opcji "print_background": True. Zważywszy, że do wygenerowania PDF-a playwright także używa Chromium można było spodziewać się podobnych wyników i tak też jest.

playwright HTML
vs.

Wizytówka 3 w formacie PDF wygenerowana przez playwright

Playwright także respektuje style dla wydruku.

Podsumowanie

Biblioteki oferujące konwersję HTML na PDF w Python-ie możemy z grubsza podzielić na te wykorzystujące w tle silniki przeglądarek internetowych i takie, które tego nie robią. Na podstawie przeprowadzonych eksperymentów mogę stwierdzić, że te pierwsze z wymienionych lepiej sobie radzą, ale to nie znaczy że weasyprint czy xhtml2pdf nie mają niczego do zaoferowania zwłaszcza jeśli potrzebne są rozwiązania lekkie (z mniejszą ilością zależności) i szybkie. Narzut jaki stanowi uruchomienie przeglądarki z pewnością musi odbić się na wydajności. W zamian dostajemy większą zgodność z CSS-ami, wsparcie dla JavaScript i dodatkowe funkcjonalności oferowane przez silniki przeglądarek - zwłaszcza oferowane przez pyppeteer i playwright, których funkcja druku do formatu PDF jest tylko jedną z wielu możliwości i wcale nie najważniejszą, gdyż oba projekty są o wiele częściej wykorzystywane do zadań związanych z automatyzacją i testowaniem. Obie biblioteki oferują też interfejs asynchroniczny co nie jest bez znaczenia jeśli w grę wchodzą jakieś mocno rozbudowane operacje.

Na zakończenie dodam jeszcze, że przedstawione tu skrypty uruchamiałem w kontenerze Dockera do stworzenia, którego użyłem następujących plików.

Dockerfile

FROM python:3.12-slim

ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
ENV PYTHONPATH "${PYTHONPATH}:/"
ENV HOME=/app


ARG USERNAME=default
ARG GROUPNAME=$USERNAME
ARG USER_UID=1000
ARG USER_GID=$USER_UID


RUN groupadd -g ${USER_GID} ${GROUPNAME} \
    && useradd -m -d ${HOME} -s /bin/bash -g ${GROUPNAME} -u ${USER_UID} ${USERNAME} \
    && mkdir -p /etc/sudoers.d \
    && echo "${USERNAME} ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/${USERNAME} \
    && chmod 0440 /etc/sudoers.d/${USERNAME}

RUN apt-get update \
    && apt-get install -y --no-install-recommends \
    build-essential \
    cargo \
    curl \
    gpg \
    libnss3 \
    libatk-bridge2.0-0 \
    libcups2 \
    libxcomposite1 \
    libxdamage1 \
    libxrandr2 \
    libgbm1 \
    libpango1.0-0 \
    libcairo2 \
    libasound2 \
    libxshmfence1 \
    libffi-dev \
    libjpeg-dev \
    libxml2-dev \
    libxslt-dev \
    pkg-config \
    wkhtmltopdf \
    zlib1g-dev \
    --no-install-recommends \
    && apt-get clean \
    && rm -rf /var/lib/apt/lists/* \
    && curl https://sh.rustup.rs -sSf | sh -s -- -y \
    && export PATH="$HOME/.cargo/bin:$PATH" \
    && python3 -m pip install --upgrade pip


ENV PATH="/root/.cargo/bin:${PATH}"
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright

COPY ./requirements.txt /requirements.txt
RUN pip3 install --no-cache-dir -r /requirements.txt \
    && mkdir -p /ms-playwright \
    && chmod -R 777 /ms-playwright \
    && playwright install --with-deps chromium chrome msedge firefox webkit


WORKDIR ${HOME}
COPY ./app ${HOME}
USER ${USERNAME}

CMD ["/bin/sh"]

requirements.txt

asyncio==3.4.3
html5lib==1.1
Jinja2==3.1.5
pdfkit==1.0.0
playwright==1.46.0
pyppeteer==2.0.0
qrcode==8.0
segno==1.6.1
vobject==0.9.9
weasyprint==53.0
xhtml2pdf==0.2.16

docker-compose.yml

services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
      args:
        USER_UID: ${USER_UID:-1000}
        USER_GID: ${USER_GID:-1000}
    volumes:
      - ./app:/app
    restart: no

Akceptuję Ta strona zapisuje niewielkie pliki tekstowe, nazywane ciasteczkami (ang. cookies) na Twoim urządzeniu w celu lepszego dostosowania treści oraz dla celów statystycznych. Możesz wyłączyć możliwość ich zapisu, zmieniając ustawienia Twojej przeglądarki. Korzystanie z tej strony bez zmiany ustawień oznacza zgodę na przechowywanie cookies w Twoim urządzeniu.