В 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 минут, хотя я не уверен, что замерял достаточно точно --- результаты плавают в зависимости от нагрузки на сервер.
Финальная архитектура
- Фоновый воркер — каждые 30 минут прогревает кеш картинок и предгенерирует секции каталога.
- API-эндпоинт — принимает запрос на генерацию, проверяет кеш, генерирует только изменившиеся секции.
- ProcessPoolExecutor — параллельная генерация секций на всех ядрах.
- PyPDF2 — склейка секций в финальный документ.
Итого: от запроса менеджера до готового PDF --- обычно в пределах 10-20 секунд. Потребление памяти --- в разы меньше, чем с WeasyPrint. Для менеджеров на точках это приемлемое время.
Выводы
Генерация PDF --- задача, которая кажется простой, пока не столкнёшься с масштабом. Главные уроки: не пытайся рендерить гигантский HTML в PDF --- это всегда будет медленно. Разбивай на части, генерируй параллельно, кешируй всё, что можно кешировать. И выбирай инструмент под задачу: WeasyPrint идеален для красивых отчётов на 10 страниц, но для каталога на сотни страниц нужен ReportLab с ручным контролем. Возможно, сейчас я бы также посмотрел в сторону Typst --- он очень быстрый и поддерживает программную генерацию, но на момент выбора я о нём не знал.