Files
resume/cv/services/dowload/pdf.py
Pavel Sobolev d5ff05abdb add linters
2025-11-13 01:33:00 +03:00

200 lines
8.0 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Рендеринг резюме в PDF на базе WeasyPrint.
Модуль предоставляет класс `PdfRenderer` для генерации минималистичного HTML
и конвертации его в PDF. Включает базовый печатный CSS и сериализацию профиля.
"""
from __future__ import annotations
import logging
from collections.abc import Sequence
from io import BytesIO
from typing import Any
from weasyprint import HTML
from cv.models import Profile
from cv.services.dowload.base import ProfileSerializer
from resume.utils.logging import configure_root_logger
# Инициализация логирования с RichHandler (формат и уровень по правилам проекта)
_root = logging.getLogger()
if not _root.handlers:
configure_root_logger()
logger = logging.getLogger(__name__)
class PdfRenderer:
"""Формирует HTML на лету (без шаблона) и конвертирует в PDF.
Методы класса генерируют минимальный печатный HTML и превращают его
в PDF с помощью WeasyPrint.
"""
def __init__(self) -> None:
"""Инициализирует сериализатор и части HTML."""
self.serializer = ProfileSerializer()
self.parts: list[str] = []
def _build_print_css(self) -> str:
"""Вернуть минимальный CSS для печатного PDF.
Returns:
str: Строка CSS-правил, совместимых с WeasyPrint.
"""
css_rules = """
@page { size: A4; margin: 18mm 16mm; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI",
Roboto, Arial, sans-serif;
color: #111;
}
h1 {
margin: 0 0 6mm 0;
font-size: 20pt;
}
h2 {
margin: 8mm 0 3mm 0;
font-size: 13pt;
border-bottom: 1px solid #ccc;
padding-bottom: 2mm;
}
p {
margin: 0 0 3mm 0;
line-height: 1.4;
}
.meta { color: #555; }
.tag {
display: inline-block;
border: 1px solid #ddd;
padding: 2px 6px;
border-radius: 10px;
font-size: 9pt;
color: #444;
margin-right: 4px;
}
.item { margin: 0 0 5mm 0; }
.item-title { font-weight: 700; }
.item-period { color: #666; }
ul { margin: 2mm 0 2mm 6mm; }
""".strip()
return css_rules
def _append_head(self, full_name: str) -> None:
"""Добавить секцию head с CSS.
Args:
full_name (str): Полное имя для тега <title>.
"""
self.parts.append("<!DOCTYPE html><html lang='ru'><head><meta charset='utf-8'>")
self.parts.append(f"<title>{full_name} — Резюме (PDF)</title>")
self.parts.append("<style>")
self.parts.append(self._build_print_css())
self.parts.append("</style></head><body>")
def _append_header_section(self, data: dict[str, Any]) -> None:
"""Добавить шапку с именем, ролью, summary и метками."""
self.parts.append(f"<h1>{data['full_name']}</h1>")
if data.get("role"):
self.parts.append(f"<p class='meta'><strong>{data['role']}</strong></p>")
if data.get("summary"):
self.parts.append(f"<p>{data['summary']}</p>")
meta_tags: list[str] = []
if data.get("location"):
meta_tags.append(f"<span class='tag'>{data['location']}</span>")
if data.get("languages"):
meta_tags.append(f"<span class='tag'>Языки: {', '.join(data['languages'])}</span>")
if meta_tags:
self.parts.append(f"<p class='meta'>{' '.join(meta_tags)}</p>")
def _append_contacts_section(self, contacts: dict[str, Any]) -> None:
"""Добавить секцию контактов."""
self.parts.append("<h2>Контакты</h2><p>")
if contacts.get("email"):
self.parts.append(f"<strong>Email:</strong> {contacts['email']}<br>")
if contacts.get("phone"):
self.parts.append(f"<strong>Телефон:</strong> {contacts['phone']}<br>")
if contacts.get("telegram"):
self.parts.append(f"<strong>Telegram:</strong> {contacts['telegram']}")
self.parts.append("</p>")
def _append_experience_section(self, experience: Sequence[Any] | None) -> None:
"""Добавить секцию опыта работы."""
self.parts.append("<h2>Опыт работы</h2>")
if experience:
for e in experience:
self.parts.append("<div class='item'>")
self.parts.append(f"<div class='item-title'>{e.company}</div>")
period = ""
if getattr(e, "start_date", None):
period += e.start_date.strftime("%B %Y")
period += ""
period += (
e.end_date.strftime("%B %Y")
if getattr(e, "end_date", None)
else "настоящее время"
)
self.parts.append(f"<div class='item-period'>{period}</div>")
if e.summary:
self.parts.append(f"<p>{e.summary}</p>")
if e.achievements:
self.parts.append("<ul>")
for a in e.achievements:
if a:
self.parts.append(f"<li>{a}</li>")
self.parts.append("</ul>")
if e.tech:
self.parts.append(f"<p class='meta'>Технологии: {', '.join(e.tech)}</p>")
self.parts.append("</div>")
else:
self.parts.append("<p class='meta'>Нет записей.</p>")
def _append_skills_section(self, skills: Sequence[Any] | None) -> None:
"""Добавить секцию навыков."""
self.parts.append("<h2>Навыки</h2>")
if skills:
for g in skills:
if g.items:
self.parts.append(
f"<p><strong>{g.group}:</strong> {', '.join([i for i in g.items if i])}</p>"
)
else:
self.parts.append("<p class='meta'>Нет данных.</p>")
def render(self, profile: Profile) -> BytesIO:
"""Формирует HTML на лету (без шаблона) и конвертирует в PDF.
Args:
profile (Profile): Профиль для сериализации.
Returns:
BytesIO: PDF-файл.
"""
logger.info("Старт рендеринга PDF для профиля")
data = self.serializer.serialize(profile)
if not data or "full_name" not in data or not data["full_name"]:
logger.error("Отсутствует обязательное поле full_name после сериализации")
raise ValueError("Невозможно сформировать PDF: отсутствует full_name")
logger.info("Сериализация профиля завершена, подготовка HTML")
# Head + Header
self._append_head(data["full_name"])
self._append_header_section(data)
# Contacts
self._append_contacts_section(data["contacts"])
# Experience
self._append_experience_section(data["experience"])
# Skills
self._append_skills_section(data["skills_map"])
self.parts.append("</body></html>")
logger.info("HTML подготовлен, генерация PDF через WeasyPrint")
html = "".join(self.parts)
pdf = HTML(string=html).write_pdf()
out = BytesIO(pdf)
out.seek(0)
logger.info("PDF сгенерирован: %s байт", len(pdf))
return out