Назад к блогу

Генерация PDF-каталогов шин: путь от 5 минут к секундам

Как я ускорял генерацию PDF-каталогов для Tirebase --- сравнение WeasyPrint, ReportLab и wkhtmltopdf, параллельная генерация, кеширование и шаблонная система.

Генерация PDF-каталогов шин: путь от 5 минут к секундам

В Tirebase менеджерам на 5 точках в Петербурге нужен PDF-каталог шин для работы с клиентами. С актуальными ценами, фотографиями, характеристиками. Нажал кнопку в search.tirebase.ru --- получил файл. Типичный каталог --- 800-1200 позиций, каждая с фото, таблицей характеристик, ценой. Итого порядка 600-800 страниц. Задача --- чтобы менеджер не ждал дольше десятка секунд.

Первый подход: WeasyPrint

Я начал с WeasyPrint, потому что он позволяет генерировать PDF из HTML+CSS. Пишешь шаблон на Jinja2, стилизуешь через CSS --- получаешь PDF. Удобно, знакомо, быстро в разработке.

from weasyprint import HTML
from jinja2 import Environment, FileSystemLoader

env = Environment(loader=FileSystemLoader("templates"))
template = env.get_template("catalog.html")

html_content = template.render(products=all_products)
HTML(string=html_content).write_pdf("catalog.pdf")

Результат для ~1000 товаров: около 5 минут. И RAM на пике улетал за 3 ГБ. Менеджер успеет не только налить кофе, но и выпить его, сходить на обед и вернуться.

Проблема WeasyPrint --- он рендерит весь HTML за один проход. CSS layout для сотен страниц --- тяжёлая операция. Плюс каждая картинка загружается и декодируется в память.

Второй подход: wkhtmltopdf

wkhtmltopdf использует WebKit для рендеринга. Теоретически быстрее, потому что WebKit оптимизирован для рендеринга больших документов. На практике --- около 3.5 минут. Быстрее, но недостаточно. И появились новые проблемы: шрифты рендерятся по-разному на разных серверах, а установка wkhtmltopdf в Docker --- отдельный квест с зависимостями. К тому же проект давно не поддерживается, и тащить его в продакшен не хотелось.

# Зависимости wkhtmltopdf — это больно
RUN apt-get update && apt-get install -y \
    wkhtmltopdf \
    xvfb \
    libfontconfig1 \
    libxrender1 \
    # ... ещё 15 пакетов

Третий подход: ReportLab + параллелизация

Я пересмотрел подход. Вместо «сгенерировать один большой HTML и конвертировать» --- генерировать PDF напрямую, по частям, параллельно.

ReportLab работает на низком уровне --- ты рисуешь на canvas, размещаешь элементы вручную. Это больше кода, но полный контроль над рендерингом и потреблением памяти.

Ключевая идея: разбить каталог на секции (по категориям), генерировать каждую секцию в отдельном процессе, потом склеить PDF-файлы.

from reportlab.lib.pagesizes import A4
from reportlab.platypus import SimpleDocTemplate, Table, Image, Paragraph
from reportlab.lib.styles import getSampleStyleSheet
from concurrent.futures import ProcessPoolExecutor
from PyPDF2 import PdfMerger

def generate_section(section_data: dict) -> bytes:
    """Генерирует PDF для одной секции каталога."""
    buffer = io.BytesIO()
    doc = SimpleDocTemplate(buffer, pagesize=A4)
    styles = getSampleStyleSheet()
    elements = []

    # Заголовок секции
    elements.append(Paragraph(section_data["category"], styles["Heading1"]))

    for product in section_data["products"]:
        elements.extend(build_product_card(product, styles))

    doc.build(elements)
    return buffer.getvalue()

def build_product_card(product: dict, styles) -> list:
    """Строит карточку товара."""
    elements = []

    # Фото — используем кешированный файл
    if product.get("cached_image_path"):
        img = Image(product["cached_image_path"], width=150, height=150)
        elements.append(img)

    # Название и цена
    elements.append(Paragraph(
        f"<b>{product['name']}</b> — {product['price']} руб.",
        styles["Heading2"]
    ))

    # Характеристики в таблице
    spec_data = [[k, v] for k, v in product["specs"].items()]
    if spec_data:
        table = Table(spec_data, colWidths=[200, 250])
        elements.append(table)

    return elements

Параллельная генерация:

async def generate_catalog(products: list[dict]) -> bytes:
    # Разбиваем на секции по категориям
    sections = group_by_category(products)

    # Генерируем секции параллельно
    with ProcessPoolExecutor(max_workers=os.cpu_count()) as executor:
        loop = asyncio.get_event_loop()
        futures = [
            loop.run_in_executor(executor, generate_section, section)
            for section in sections
        ]
        section_pdfs = await asyncio.gather(*futures)

    # Склеиваем в один PDF
    merger = PdfMerger()
    for pdf_bytes in section_pdfs:
        merger.append(io.BytesIO(pdf_bytes))

    output = io.BytesIO()
    merger.write(output)
    return output.getvalue()

Результат: 42 секунды на 8 ядрах. Уже лучше, но не «пока наливает кофе».

Кеширование картинок

Профилирование (использовал py-spy) показало, что основная часть времени уходит на обработку изображений. Каждый товар имеет фото, которое нужно загрузить по URL, декодировать, ресайзнуть под размер карточки. Для тысячи товаров --- это тысяча HTTP-запросов.

Решение — предварительное кеширование. Фоновый процесс загружает и ресайзит картинки заранее:

class ImageCache:
    def __init__(self, cache_dir: str = "/tmp/pdf-image-cache"):
        self.cache_dir = Path(cache_dir)
        self.cache_dir.mkdir(exist_ok=True)

    async def ensure_cached(self, product_id: str, image_url: str) -> str:
        cache_path = self.cache_dir / f"{product_id}.jpg"

        if cache_path.exists():
            # Проверяем свежесть (не старше 1 часа)
            age = time.time() - cache_path.stat().st_mtime
            if age < 3600:
                return str(cache_path)

        # Загружаем и ресайзим
        async with aiohttp.ClientSession() as session:
            async with session.get(image_url) as resp:
                if resp.status == 200:
                    data = await resp.read()
                    img = PILImage.open(io.BytesIO(data))
                    img.thumbnail((300, 300), PILImage.LANCZOS)
                    img.save(cache_path, "JPEG", quality=85)

        return str(cache_path)

Фоновая задача прогревает кеш каждые 30 минут. К моменту запроса на генерацию каталога все картинки уже на диске. Время генерации упало до 18 секунд.

Инкрементальная генерация

Последний трюк — не генерировать весь каталог заново. Если изменились только цены в 50 товарах — перегенерировать только секции, содержащие эти товары, и подставить в закешированный PDF.

class CatalogCache:
    def __init__(self):
        self._section_cache: dict[str, bytes] = {}
        self._section_hashes: dict[str, str] = {}

    def get_section_hash(self, section_data: dict) -> str:
        """Хеш содержимого секции для определения изменений."""
        content = json.dumps(section_data, sort_keys=True, default=str)
        return hashlib.md5(content.encode()).hexdigest()

    async def generate_with_cache(self, sections: list[dict]) -> bytes:
        tasks = []
        for section in sections:
            current_hash = self.get_section_hash(section)
            category = section["category"]

            if (category in self._section_cache and
                self._section_hashes.get(category) == current_hash):
                # Секция не изменилась — берём из кеша
                continue

            tasks.append((category, current_hash, section))

        # Генерируем только изменившиеся секции
        if tasks:
            with ProcessPoolExecutor() as executor:
                # ... параллельная генерация только изменённых секций
                pass

        # Склеиваем из кеша
        return self._merge_cached_sections(sections)

С инкрементальной генерацией повторные запросы (когда изменились только цены нескольких товаров) выполняются за несколько секунд. Полная генерация с нуля --- около 15-20 секунд. Значительно лучше исходных 5 минут, хотя я не уверен, что замерял достаточно точно --- результаты плавают в зависимости от нагрузки на сервер.

Финальная архитектура

  1. Фоновый воркер — каждые 30 минут прогревает кеш картинок и предгенерирует секции каталога.
  2. API-эндпоинт — принимает запрос на генерацию, проверяет кеш, генерирует только изменившиеся секции.
  3. ProcessPoolExecutor — параллельная генерация секций на всех ядрах.
  4. PyPDF2 — склейка секций в финальный документ.

Итого: от запроса менеджера до готового PDF --- обычно в пределах 10-20 секунд. Потребление памяти --- в разы меньше, чем с WeasyPrint. Для менеджеров на точках это приемлемое время.

Выводы

Генерация PDF --- задача, которая кажется простой, пока не столкнёшься с масштабом. Главные уроки: не пытайся рендерить гигантский HTML в PDF --- это всегда будет медленно. Разбивай на части, генерируй параллельно, кешируй всё, что можно кешировать. И выбирай инструмент под задачу: WeasyPrint идеален для красивых отчётов на 10 страниц, но для каталога на сотни страниц нужен ReportLab с ручным контролем. Возможно, сейчас я бы также посмотрел в сторону Typst --- он очень быстрый и поддерживает программную генерацию, но на момент выбора я о нём не знал.