200 lines
8.0 KiB
Python
200 lines
8.0 KiB
Python
"""Рендеринг резюме в 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
|