Домашняя страница
/
Блог /

КАК ГЕНЕРИРОВАТЬ PDF-ДОКУМЕНТЫ НА PYTHON

Эта статья — результат моего первого опыта взаимодействия с инструментами генерации PDF-документов. Здесь речь идет даже не о Django, а о печати регламентированных документов из Python с использованием шаблонизаторов. Возможно, мой опыт поможет кому-то сэкономить время и избавит от необходимости искать нужную информацию на страницах разработчиков, чтобы, дочитав до обсуждений 7-летней давности, убедиться в том, что многие проблемы не решены и сегодня. Ниже я подробно расскажу о том, как сгенерировать PDF-документ на примере использования двух разных утилит.

article_cover
Игорь Ремша

Автор:

Игорь Ремша

Категория:

Статьи

Дата публикации:

ЗАДАЧИ

Основной задачей было создание красивого документа с описанием технического задания, данные о котором клиент оставлял в форме на нашем сайте.

Как это выглядит пошагово:

  • Пользователь вводит данные в веб-форму.
  • Сервер вставляет эти данные в шаблон.
  • По заполненному шаблону генерирует PDF-документ.
  • И отдает его пользователю.

ИНСТРУМЕНТЫ

Инструменты для создания PDF

Если вы обратитесь к Google с запросом «Генерация pdf-документов на Python», то обнаружите, что инструментов, предназначенных для этого, достаточно много, и на каждом форуме рекомендуют разный инструмент.

Я покажу пример использования двух.

  1. WKhtmltopdf — консольная утилита для рендера HTML в PDF, для которой существует множество оберток для Python-Django.
  2. WeasyPrint — это визуальный движок рендеринга для HTML и CSS, который можно экспортировать в PDF и PNG.

ВЁРСТКА

В качестве примера рассмотрим часть HTML-документа, которую предоставил мой коллега.

В качестве примера рассмотрим часть 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> <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')

Результат

Результат для WKhtmltopdf

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

Point3

Итог

  1. Ставим правильную версию alpine.
  2. Пользуемся любой понравившейся обёрткой.
  3. Пользуемся любыми стилями.
  4. Получаем 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

В противном случае мы бы получили следующую картину:


Пример для WeasyPrint

[[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


Результат

Результат для WEASYPRINT

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

Примечание

Как я уже и говорил, при использовании WeasyPrint стоит отказаться от использования Flexbox, он негативно влияет на производительность. Что я продемонстрирую далее. Подробнее здесь.

Также WeasyPrint на данный момент не поддерживает рендеринг теней. Подробнее здесь.

Итого

  1. Устанавливаем стандартные шрифты, если пользуемся alpine.
  2. Не используем Flexbox и Shadow в вёрстке (ищем альтернативы):
    • 'display: flex' -> 'display: inline-block';
    • 'flex-direction: column' -> 'display: block';
  3. По итогу мы получаем сгенерированные PDF-документы, в которых можно взаимодействовать с текстом.

СРАВНЕНИЕ

Не претендую на эталонность данных значений, т.к. время исполнения было замерено следующим образом:

  1. start_time = time.clock()
  2. generate_pdf()
  3. print(time.clock() - start_time, "seconds")

При использовании одного и того же шаблона (он представлен в разделе «вёрстка») и при загрузке из сети, а не со статики, 5 запущенных подряд тестов показали следующие результаты:

WkhtmltopdfWeasyPrintWeasyPrint (если заменить все inline на flexbox)
0.241 sec0.743 sec3.223 sec
0.138 sec0.522 sec3.294 sec
0.104 sec0.421 sec3.148 sec
0.106 sec0.463 sec3.109 sec
0.104 sec0.437 sec3.195 sec

ВЫВОД

Итог вышесказанного очевиден: для генерации PDF инструмент нужно выбирать исходя из конкретных задач.

Если в приоритете скорость генерации документов и возможность использовать современные CSS-инструменты (например, Flexbox и Shadowbox), подойдет WKhtmltopdf. Но итоговый текст в документе нельзя будет выделить и скопировать.

Если после генерации файла текст должен быть интерактивным, следует использовать WeasyPrint. Но в этом случае придется потратить больше времени и поискать альтернативы для Flexbox и Shadowbox.

Поделиться в социальных сетях

Давайте работать вместе!

Прикрепить файл