163 lines
6.2 KiB
Python
163 lines
6.2 KiB
Python
"""Рендеринг резюме в формат DOCX.
|
||
|
||
Класс `DocxRenderer` формирует документ с заголовками, контактами, опытом
|
||
и навыками на основе сериализованных данных профиля.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from collections.abc import Sequence
|
||
from io import BytesIO
|
||
from typing import Any
|
||
|
||
from docx import Document as new_document
|
||
from docx.document import Document as DocxDocument
|
||
from docx.oxml import OxmlElement
|
||
from docx.oxml.ns import qn
|
||
from docx.shared import Pt, RGBColor
|
||
|
||
from cv.services.dowload.base import ProfileSerializer
|
||
from resume.utils.logging import configure_root_logger
|
||
|
||
_root = logging.getLogger()
|
||
if not _root.handlers:
|
||
configure_root_logger()
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class DocxRenderer:
|
||
"""Создает DOCX-документ на основе данных профиля."""
|
||
|
||
def __init__(
|
||
self,
|
||
) -> None:
|
||
"""Инициализирует сериализатор документов DOCX."""
|
||
self.serializer = ProfileSerializer()
|
||
|
||
@staticmethod
|
||
def _add_divider(document: DocxDocument) -> None:
|
||
"""Добавить горизонтальный разделитель как границу параграфа."""
|
||
p = document.add_paragraph()
|
||
fmt = p.paragraph_format
|
||
fmt.space_before = Pt(6)
|
||
fmt.space_after = Pt(6)
|
||
p_elm = p._p
|
||
pPr = p_elm.get_or_add_pPr()
|
||
pBdr = OxmlElement("w:pBdr")
|
||
bottom = OxmlElement("w:bottom")
|
||
bottom.set(qn("w:val"), "single")
|
||
bottom.set(qn("w:sz"), "6")
|
||
bottom.set(qn("w:space"), "1")
|
||
bottom.set(qn("w:color"), "2a3347")
|
||
pBdr.append(bottom)
|
||
pPr.append(pBdr)
|
||
|
||
@staticmethod
|
||
def _month_year(dt: Any) -> str:
|
||
"""Вернуть строку месяца и года на русском для даты."""
|
||
ru_months = {
|
||
1: "январь",
|
||
2: "февраль",
|
||
3: "март",
|
||
4: "апрель",
|
||
5: "май",
|
||
6: "июнь",
|
||
7: "июль",
|
||
8: "август",
|
||
9: "сентябрь",
|
||
10: "октябрь",
|
||
11: "ноябрь",
|
||
12: "декабрь",
|
||
}
|
||
return f"{ru_months.get(dt.month, dt.strftime('%B')).capitalize()} {dt.year}"
|
||
|
||
def _append_header(self, doc: DocxDocument, data: dict[str, Any]) -> None:
|
||
"""Добавить заголовок и краткие сведения."""
|
||
doc.core_properties.title = data["full_name"]
|
||
if data.get("role"):
|
||
doc.core_properties.subject = data["role"]
|
||
doc.add_heading(data["full_name"], level=0)
|
||
if data.get("role"):
|
||
doc.add_paragraph(data["role"])
|
||
if data.get("summary"):
|
||
doc.add_paragraph(data["summary"])
|
||
meta_parts: list[str] = []
|
||
if data.get("location"):
|
||
meta_parts.append(data["location"])
|
||
if data.get("languages"):
|
||
meta_parts.append("Языки: " + ", ".join(data["languages"]))
|
||
if meta_parts:
|
||
doc.add_paragraph(" • ".join(meta_parts))
|
||
|
||
def _append_contacts(self, doc: DocxDocument, contacts: dict[str, Any]) -> None:
|
||
"""Добавить секцию контактов."""
|
||
doc.add_heading("Контакты", level=1)
|
||
if contacts.get("email"):
|
||
doc.add_paragraph(f"Email: {contacts['email']}")
|
||
if contacts.get("phone"):
|
||
doc.add_paragraph(f"Телефон: {contacts['phone']}")
|
||
if contacts.get("telegram"):
|
||
doc.add_paragraph(f"Telegram: {contacts['telegram']}")
|
||
|
||
def _append_experience(self, doc: DocxDocument, experience: Sequence[Any] | None) -> None:
|
||
"""Добавить опыт работы."""
|
||
doc.add_heading("Опыт работы", level=1)
|
||
for i, e in enumerate(experience or []):
|
||
if i:
|
||
self._add_divider(doc)
|
||
start = self._month_year(e.start_date) if getattr(e, "start_date", None) else ""
|
||
end = (
|
||
self._month_year(e.end_date) if getattr(e, "end_date", None) else "настоящее время"
|
||
)
|
||
period = f"{start} — {end}"
|
||
p_company = doc.add_paragraph()
|
||
run_company = p_company.add_run(e.company)
|
||
run_company.bold = True
|
||
run_company.font.underline = True
|
||
run_company.font.color.rgb = RGBColor(0x2A, 0x33, 0x47)
|
||
p_period = doc.add_paragraph(period)
|
||
for r in p_period.runs:
|
||
r.font.color.rgb = RGBColor(0x99, 0xA2, 0xB2)
|
||
if e.summary:
|
||
doc.add_paragraph(e.summary)
|
||
for a in e.achievements or []:
|
||
if a:
|
||
doc.add_paragraph(a, style="List Bullet")
|
||
if e.tech:
|
||
doc.add_paragraph("Технологии: " + ", ".join([t for t in e.tech if t]))
|
||
|
||
def _append_skills(self, doc: DocxDocument, skills: Sequence[Any] | None) -> None:
|
||
"""Добавить навыки."""
|
||
doc.add_heading("Навыки", level=1)
|
||
for g in skills or []:
|
||
if g.items:
|
||
doc.add_paragraph(f"{g.group}: " + ", ".join([i for i in g.items if i]))
|
||
|
||
def render(self, profile: Any) -> BytesIO:
|
||
"""Сформировать DOCX документ по профилю.
|
||
|
||
Args:
|
||
profile: Объект профиля.
|
||
|
||
Returns:
|
||
BytesIO: Буфер с содержимым DOCX.
|
||
"""
|
||
logger.info("Старт рендеринга DOCX для профиля")
|
||
data = self.serializer.serialize(profile)
|
||
buf = BytesIO()
|
||
doc: DocxDocument = new_document()
|
||
# Заголовок
|
||
self._append_header(doc, data)
|
||
# Контакты
|
||
self._append_contacts(doc, data.get("contacts", {}))
|
||
# Опыт
|
||
self._append_experience(doc, data.get("experience"))
|
||
# Навыки
|
||
self._append_skills(doc, data.get("skills_map"))
|
||
|
||
doc.save(buf)
|
||
buf.seek(0)
|
||
logger.info("DOCX сгенерирован: %s байт", buf.getbuffer().nbytes)
|
||
return buf
|