КАК ГЕНЕРИРОВАТЬ PDF-ДОКУМЕНТЫ НА PYTHON
Эта статья — результат моего первого опыта взаимодействия с инструментами генерации PDF-документов. Здесь речь идет даже не о Django, а о печати регламентированных документов из Python с использованием шаблонизаторов. Возможно, мой опыт поможет кому-то сэкономить время и избавит от необходимости искать нужную информацию на страницах разработчиков, чтобы, дочитав до обсуждений 7-летней давности, убедиться в том, что многие проблемы не решены и сегодня. Ниже я подробно расскажу о том, как сгенерировать PDF-документ на примере использования двух разных утилит.


Автор:
Игорь Ремша
Категория:
Статьи
Дата публикации:
ЗАДАЧИ
Основной задачей было создание красивого документа с описанием технического задания, данные о котором клиент оставлял в форме на нашем сайте.
Как это выглядит пошагово:
- Пользователь вводит данные в веб-форму.
- Сервер вставляет эти данные в шаблон.
- По заполненному шаблону генерирует PDF-документ.
- И отдает его пользователю.
ИНСТРУМЕНТЫ

Если вы обратитесь к Google с запросом «Генерация pdf-документов на Python», то обнаружите, что инструментов, предназначенных для этого, достаточно много, и на каждом форуме рекомендуют разный инструмент.
Я покажу пример использования двух.
- WKhtmltopdf — консольная утилита для рендера HTML в PDF, для которой существует множество оберток для Python-Django.
- WeasyPrint — это визуальный движок рендеринга для HTML и CSS, который можно экспортировать в PDF и PNG.
ВЁРСТКА
В качестве примера рассмотрим часть HTML-документа, которую предоставил мой коллега.

<!DOCTYPE html>
<html lang="ru-RU">
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="main">
<div class="title">
<h1>Техническое задание</h1>
<h2>На разработку мобильного приложения</h2>
</div>
<div class="section">
<h1>Шаг 1 / 7</h1>
<h2>1. Общие положения о проекте:</h2>
<h3>1.1 Предмет разработки</h3>
<div class="field">
<span class="field__title">Предметом разработки является мобильное приложение:</span>
<p class="field__description">{{ app_name }}</p>
</div>
<div class="field">
<span class="field__title">Он включает в себя следующий комплекс работ: </span>
<p class="field__description">
{{ complex_works }}
</p>
</div>
<h3>1.2 Функциональное назначение продукта</h3>
<div class="field">
<span class="field__title">Продукт / сервис предназначен для:</span>
<p class="field__description">
{{ mission }}
</p>
</div>
<div class="field">
<span class="field__title">Основная цель продукта:</span>
<p class="field__description">{{ targer }}</p>
</div>
<div class="field">
<span class="field__title">Он решает следующие проблемы пользователей:</span>
<p class="field__description">
{{ solve_problems }}
</p>
</div>
</div
</div>
</body>
</html>
CSS
@import url('https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700&display=swap');
html, body {
margin: 0;
font-family: 'Visuelt Pro';
}
body {
width: 790px;
margin: 0 auto;
position: relative;
font-family: 'Montserrat', sans-serif;
}
p, h1, h2, h3, ul, li {
margin: 0;
padding: 0;
}
.main {
margin: 0 auto;
width: 80.6%;
}
.header__logo {
position: absolute;
right: 330px;
top: 91px;
z-index: 1;
}
.title {
border-bottom: 1px solid #e8e8e8;
width: 100%;
padding: 33px 0 32px;
}
.title h1 {
margin-bottom: 16px;
font-size: 20px;
line-height: 30px;
font-weight: 600;
text-align: center;
}
.title h2 {
font-size: 14px;
line-height: 20px;
font-weight: normal;
letter-spacing: 0.5px;
text-align: center;
}
.section .field:first-of-type {
margin-top: 30px;
}
.section h1 {
margin-top: 32px;
color: #d73f30;
font-weight: 600;
font-size: 16px;
line-height: 25px;
text-align: center;
}
.section h2 {
margin-top: 10px;
font-weight: 600;
font-size: 16px;
line-height: 25px;
text-align: center;
}
.section h3 {
margin: 10px 0 20px;
font-weight: 500;
font-size: 14px;
line-height: 20px;
letter-spacing: 0.5px;
text-align: center;
}
.field {
margin-bottom: 20px;
}
.field__title {
display: inline-block;
margin-bottom: 15px;
font-weight: bold;
font-size: 12px;
line-height: 20px;
letter-spacing: 0.5px;
}
.field__description {
padding: 15px 13px 14px 16px;
font-weight: normal;
font-size: 12px;
line-height: 20px;
letter-spacing: 0.5px;
border-radius: 6.31579px;
border: solid 1px #e8e8e8;
}
.description__title {
display: block;
font-weight: 700;
}
.field__description img {
display: block;
max-width: 100%;
margin: 0 auto;
}
.field__description img:not(:first-child) {
margin-top: 30px;
}
.field__description ol {
list-style: none;
counter-reset: li;
margin-top: 8px;
}
.field__description ol li:not(:last-child) {
margin-bottom: 8px;
}
.field__description > ol {
padding: 0;
margin: 0;
}
.field__description > ol ol {
padding-left: 32px;
}
Примечание: чтобы в css иметь возможность подставить файл из статики, например background-image: url("{% static 'images/check.svg' %}"); нам нужно будет поместить все стили в шаблон в тег <style>.
<!DOCTYPE > <html lang="ru-RU"> {% load static %} <head> <meta charset="utf-8"> <style> @page { size: A4; margin: 10mm 0; padding: 0; } @page :first { margin: 0 0 10mm 0; } @import url('https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700&display=swap'); html, body { margin: 0; font-family: 'Visuelt Pro'; } body { width: 790px; margin: 0 auto; position: relative; font-family: 'Montserrat', sans-serif; } p, h1, h2, h3, ul, li { margin: 0; padding: 0; } .main { margin: 0 auto; width: 80.6%; } .header__logo { position: absolute; right: 330px; top: 91px; z-index: 1; } .title { border-bottom: 1px solid #e8e8e8; width: 100%; padding: 33px 0 32px; } .title h1 { margin-bottom: 16px; font-size: 20px; line-height: 30px; font-weight: 600; text-align: center; } .title h2 { font-size: 14px; line-height: 20px; font-weight: normal; letter-spacing: 0.5px; text-align: center; } .section .field:first-of-type { margin-top: 30px; } .section h1 { margin-top: 32px; color: #d73f30; font-weight: 600; font-size: 16px; line-height: 25px; text-align: center; } .section h2 { margin-top: 10px; font-weight: 600; font-size: 16px; line-height: 25px; text-align: center; } .section h3 { margin: 10px 0 20px; font-weight: 500; font-size: 14px; line-height: 20px; letter-spacing: 0.5px; text-align: center; } .field { margin-bottom: 20px; } .field__title { display: inline-block; margin-bottom: 15px; font-weight: bold; font-size: 12px; line-height: 20px; letter-spacing: 0.5px; } .field__description { padding: 15px 13px 14px 16px; font-weight: normal; font-size: 12px; line-height: 20px; letter-spacing: 0.5px; border-radius: 6.31579px; border: solid 1px #e8e8e8; } .description__title { display: block; font-weight: 700; } .field__description img { display: block; max-width: 100%; margin: 0 auto; } .field__description img:not(:first-child) { margin-top: 30px; } .field__description ol { list-style: none; counter-reset: li; margin-top: 8px; } .field__description ol li:not(:last-child) { margin-bottom: 8px; } .field__description > ol { padding: 0; margin: 0; } .field__description > ol ol { padding-left: 32px; } </style> <link rel="preconnect" href="https://fonts.gstatic.com"> <link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@300;400;500;600;700&display=swap" rel="stylesheet"> </head> <body> {% autoescape off %} <div class="main"> <div class="title"> <h1>Техническое задание</h1> <h2>На разработку мобильного приложения</h2> </div> <div class="section"> <h1>Шаг 1 / 7</h1> <h2>1. Общие положения о проекте:</h2> <h3>1.1 Предмет разработки</h3> <div class="field"> <span class="field__title">Предметом разработки является мобильное приложение:</span> <p class="field__description">{{ app_name}}</p> </div> <div class="field"> <span class="field__title">Он включает в себя следующий комплекс работ: </span> <p class="field__description"> {{ complex_works|linebreaksbr }} </p> </div> <h3>1.2 Функциональное назначение продукта</h3> <div class="field"> <span class="field__title">Продукт / сервис предназначен для:</span> <p class="field__description"> {{ mission|linebreaksbr }} </p> </div> <div class="field"> <span class="field__title">Основная цель продукта:</span> <p class="field__description">{{ target|linebreaksbr }}</p> </div> <div class="field"> <span class="field__title">Он решает следующие проблемы пользователей:</span> <p class="field__description"> {{ solve_problems|linebreaksbr }} </p> </div> </div> {% endautoescape %} </body> </html>
ПРИМЕР ДЛЯ WKHTMLTOPDF
FROM python:3.6-alpine3.11 RUN mkdir -p /app/src/ RUN apk add --update --no-cache \ # Common gcc libffi-dev musl-dev \ # Pillow jpeg-dev zlib-dev \ # Postgres postgresql-dev gdal-dev geos-dev \ # Wkhtmltopdf wkhtmltopdf \ xvfb # install wkhtmltopdf dependencies RUN apk add --update ca-certificates openssl && update-ca-certificates COPY Pipfile* /app/ RUN cd /app/ && \ pip install pipenv && \ pipenv install --system --deploy --ignore-pipfile COPY src /app/src/ WORKDIR /app/src/ CMD ["gunicorn", "-w", "3", "--bind", ":8000", "config.wsgi:application"]
Примечание: Мои попытки установить и воспользоваться wkhtmltopdf на alpine:3.12 заканчивались неудачей и кодом ошибки: ERROR: The command 'wkhtmltopdf index.html out.pdf' returned a non-zero code: 139.
По какой-то причине в моей практике wkhtmltopdf срабатывал не на всех версиях alpine. Вот несколько версий, на которых проблем не возникало: 3.9.4 / 3.10.4 / 3.11.
[[source]] name = "pypi" url = "https://pypi.org/simple" verify_ssl = true [dev-packages] [packages] django = "~=2.2" pdfkit = "~=0.6" [requires] python_version = "3.6"
from django.template.loader import render_to_string, get_template
import tempfile
import pdfkit
def generate_pdf(request, data):
html_template = render_to_string('portable_document/technical_specification_template.html', data)
pdfkit.from_string(html_template, 'out.pdf')
Результат

Как уже говорилось выше, взаимодействовать с текстом, напечатанном в данном PDF-файле, не получится. Похоже, что каждое слово здесь представляет собой изображение, которое можно растягивать и поворачивать.

Итог
- Ставим правильную версию alpine.
- Пользуемся любой понравившейся обёрткой.
- Пользуемся любыми стилями.
- Получаем PDF, где страницы — это картинки.
ПРИМЕР ДЛЯ WEASYPRINT
FROM python:3.6-alpine3.11 RUN mkdir -p /app/src/ RUN apk add --update --no-cache \ # Common gcc libffi-dev musl-dev \ # Pillow jpeg-dev zlib-dev \ # Postgres postgresql-dev gdal-dev geos-dev RUN apk --update --upgrade add gcc musl-dev jpeg-dev zlib-dev libffi-dev cairo-dev pango-dev gdk-pixbuf-dev msttcorefonts-installer fontconfig RUN update-ms-fonts COPY Pipfile* /app/ RUN cd /app/ && \ pip install pipenv && \ pipenv install --system --deploy --ignore-pipfile COPY src /app/src/ WORKDIR /app/src/ CMD ["gunicorn", "-w", "3", "--bind", ":8000", "config.wsgi:application"]
Примечание: Первым делом мы установим все зависимости, необходимые для данной библиотеки. Затем, так как мы пользуемся легковесной версией alpine, нам необходимо установить стандартные шрифты.
RUN apk --update --upgrade add gcc musl-dev jpeg-dev zlib-dev libffi-dev cairo-dev pango-dev gdk-pixbuf-dev msttcorefonts-installer fontconfig
RUN update-ms-fonts
В противном случае мы бы получили следующую картину:

[[source]] name = "pypi" url = "https://pypi.org/simple" verify_ssl = true [dev-packages] [packages] django = "~=2.2" weasyprint = "~=52.1" [requires] python_version = "3.6"
from django.template.loader import render_to_string, get_template
from weasyprint import HTML, CSS
from weasyprint.fonts import FontConfiguration
import tempfile
import io
def generate_pdf(request, data):
font_config = FontConfiguration()
html_template = render_to_string('portable_document/technical_specification_template.html', data)
html = HTML(string=html_template, base_url=request.build_absolute_uri())
pdf = html.write_pdf(font_config=font_config, presentational_hints=True)
# Используйте эту запись, чтобы просто получить PDF-фаил локально
# html.write_pdf('out.pdf', font_config=font_config, presentational_hints=True)
pdf_in_memory = io.BytesIO(pdf)
return pdf_in_memory
Результат

Вот так выглядит сгенерированный PDF-документ, где мы можем взаимодействовать с текстом. Слева от документа присутствует разбиение по разделам, которое было сгенерировано автоматически.
Примечание
Как я уже и говорил, при использовании WeasyPrint стоит отказаться от использования Flexbox, он негативно влияет на производительность. Что я продемонстрирую далее. Подробнее здесь.
Также WeasyPrint на данный момент не поддерживает рендеринг теней. Подробнее здесь.
Итого
- Устанавливаем стандартные шрифты, если пользуемся alpine.
- Не используем Flexbox и Shadow в вёрстке (ищем альтернативы):
- 'display: flex' -> 'display: inline-block';
- 'flex-direction: column' -> 'display: block';
- По итогу мы получаем сгенерированные PDF-документы, в которых можно взаимодействовать с текстом.
СРАВНЕНИЕ
Не претендую на эталонность данных значений, т.к. время исполнения было замерено следующим образом:
- start_time = time.clock()
- generate_pdf()
- print(time.clock() - start_time, "seconds")
При использовании одного и того же шаблона (он представлен в разделе «вёрстка») и при загрузке из сети, а не со статики, 5 запущенных подряд тестов показали следующие результаты:
| Wkhtmltopdf | WeasyPrint | WeasyPrint (если заменить все inline на flexbox) |
|---|---|---|
| 0.241 sec | 0.743 sec | 3.223 sec |
| 0.138 sec | 0.522 sec | 3.294 sec |
| 0.104 sec | 0.421 sec | 3.148 sec |
| 0.106 sec | 0.463 sec | 3.109 sec |
| 0.104 sec | 0.437 sec | 3.195 sec |
ВЫВОД
Итог вышесказанного очевиден: для генерации PDF инструмент нужно выбирать исходя из конкретных задач.
Если в приоритете скорость генерации документов и возможность использовать современные CSS-инструменты (например, Flexbox и Shadowbox), подойдет WKhtmltopdf. Но итоговый текст в документе нельзя будет выделить и скопировать.
Если после генерации файла текст должен быть интерактивным, следует использовать WeasyPrint. Но в этом случае придется потратить больше времени и поискать альтернативы для Flexbox и Shadowbox.