add linters
This commit is contained in:
@@ -1,107 +1,199 @@
|
||||
"""Рендеринг резюме в 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.
|
||||
|
||||
def __init__(self):
|
||||
# template не используется, оставлен для совместимости интерфейса
|
||||
Методы класса генерируют минимальный печатный HTML и превращают его
|
||||
в PDF с помощью WeasyPrint.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Инициализирует сериализатор и части HTML."""
|
||||
self.serializer = ProfileSerializer()
|
||||
self.parts: list[str] = []
|
||||
|
||||
def render(self, profile) -> BytesIO:
|
||||
data = self.serializer.serialize(profile)
|
||||
# Минимальный, печатный HTML с безопасными стилями
|
||||
parts = []
|
||||
parts.append("<!DOCTYPE html><html lang='ru'><head><meta charset='utf-8'>")
|
||||
parts.append(f"<title>{data['full_name']} — Резюме (PDF)</title>")
|
||||
parts.append("""
|
||||
<style>
|
||||
@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; }
|
||||
</style>
|
||||
</head><body>
|
||||
""")
|
||||
# Header
|
||||
parts.append(f"<h1>{data['full_name']}</h1>")
|
||||
if data["role"]:
|
||||
parts.append(f"<p class='meta'><strong>{data['role']}</strong></p>")
|
||||
if data["summary"]:
|
||||
parts.append(f"<p>{data['summary']}</p>")
|
||||
meta_tags = []
|
||||
if data["location"]:
|
||||
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["languages"]:
|
||||
if data.get("languages"):
|
||||
meta_tags.append(f"<span class='tag'>Языки: {', '.join(data['languages'])}</span>")
|
||||
if meta_tags:
|
||||
parts.append(f"<p class='meta'>{' '.join(meta_tags)}</p>")
|
||||
self.parts.append(f"<p class='meta'>{' '.join(meta_tags)}</p>")
|
||||
|
||||
# Contacts
|
||||
contacts = data["contacts"]
|
||||
parts.append("<h2>Контакты</h2><p>")
|
||||
def _append_contacts_section(self, contacts: dict[str, Any]) -> None:
|
||||
"""Добавить секцию контактов."""
|
||||
self.parts.append("<h2>Контакты</h2><p>")
|
||||
if contacts.get("email"):
|
||||
parts.append(f"<strong>Email:</strong> {contacts['email']}<br>")
|
||||
self.parts.append(f"<strong>Email:</strong> {contacts['email']}<br>")
|
||||
if contacts.get("phone"):
|
||||
parts.append(f"<strong>Телефон:</strong> {contacts['phone']}<br>")
|
||||
self.parts.append(f"<strong>Телефон:</strong> {contacts['phone']}<br>")
|
||||
if contacts.get("telegram"):
|
||||
parts.append(f"<strong>Telegram:</strong> {contacts['telegram']}")
|
||||
parts.append("</p>")
|
||||
self.parts.append(f"<strong>Telegram:</strong> {contacts['telegram']}")
|
||||
self.parts.append("</p>")
|
||||
|
||||
# Experience
|
||||
parts.append("<h2>Опыт работы</h2>")
|
||||
exp = data["experience"]
|
||||
if exp:
|
||||
for e in exp:
|
||||
parts.append("<div class='item'>")
|
||||
parts.append(f"<div class='item-title'>{e.company}</div>")
|
||||
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):
|
||||
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 "настоящее время"
|
||||
parts.append(f"<div class='item-period'>{period}</div>")
|
||||
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:
|
||||
parts.append(f"<p>{e.summary}</p>")
|
||||
self.parts.append(f"<p>{e.summary}</p>")
|
||||
if e.achievements:
|
||||
parts.append("<ul>")
|
||||
self.parts.append("<ul>")
|
||||
for a in e.achievements:
|
||||
if a:
|
||||
parts.append(f"<li>{a}</li>")
|
||||
parts.append("</ul>")
|
||||
self.parts.append(f"<li>{a}</li>")
|
||||
self.parts.append("</ul>")
|
||||
if e.tech:
|
||||
parts.append(f"<p class='meta'>Технологии: {', '.join(e.tech)}</p>")
|
||||
parts.append("</div>")
|
||||
self.parts.append(f"<p class='meta'>Технологии: {', '.join(e.tech)}</p>")
|
||||
self.parts.append("</div>")
|
||||
else:
|
||||
parts.append("<p class='meta'>Нет записей.</p>")
|
||||
self.parts.append("<p class='meta'>Нет записей.</p>")
|
||||
|
||||
# Skills
|
||||
parts.append("<h2>Навыки</h2>")
|
||||
skills = data["skills_map"]
|
||||
def _append_skills_section(self, skills: Sequence[Any] | None) -> None:
|
||||
"""Добавить секцию навыков."""
|
||||
self.parts.append("<h2>Навыки</h2>")
|
||||
if skills:
|
||||
for g in skills:
|
||||
if g.items:
|
||||
parts.append(f"<p><strong>{g.group}:</strong> {', '.join([i for i in g.items if i])}</p>")
|
||||
self.parts.append(
|
||||
f"<p><strong>{g.group}:</strong> {', '.join([i for i in g.items if i])}</p>"
|
||||
)
|
||||
else:
|
||||
parts.append("<p class='meta'>Нет данных.</p>")
|
||||
self.parts.append("<p class='meta'>Нет данных.</p>")
|
||||
|
||||
parts.append("</body></html>")
|
||||
def render(self, profile: Profile) -> BytesIO:
|
||||
"""Формирует HTML на лету (без шаблона) и конвертирует в PDF.
|
||||
|
||||
html = "".join(parts)
|
||||
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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user