370 lines
14 KiB
Python
370 lines
14 KiB
Python
import io
|
|
from pathlib import Path
|
|
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
from flask import current_app
|
|
|
|
from app.services.qr_service import generate_qr_for_guest
|
|
|
|
|
|
TICKET_SIZE = (650, 1150)
|
|
|
|
NON_TRANSFERABLE_TEXT = (
|
|
"Билет действителен только для указанного гостя. "
|
|
"Передача третьим лицам запрещена."
|
|
)
|
|
|
|
|
|
def _load_logo_image(logo_path):
|
|
"""Загружает логотип: PNG/JPG через Pillow, SVG через svglib + reportlab."""
|
|
if str(logo_path).lower().endswith(".svg"):
|
|
try:
|
|
from svglib.svglib import svg2rlg
|
|
from reportlab.graphics import renderPM
|
|
|
|
drawing = svg2rlg(str(logo_path))
|
|
if drawing is None:
|
|
raise ValueError("Не удалось распарсить SVG")
|
|
|
|
# svglib может оставить нулевые размеры; подстраховываемся
|
|
orig_width = drawing.width or 0
|
|
orig_height = drawing.height or 0
|
|
scale = max(orig_width, orig_height) / 1000
|
|
if scale > 0:
|
|
drawing.width /= scale
|
|
drawing.height /= scale
|
|
drawing.scale(1 / scale, 1 / scale)
|
|
|
|
png_data = renderPM.drawToString(drawing, fmt="PNG", dpi=150)
|
|
return Image.open(io.BytesIO(png_data)).convert("RGBA")
|
|
except Exception as e:
|
|
current_app.logger.warning(f"Не удалось конвертировать SVG {logo_path}: {e}")
|
|
return None
|
|
else:
|
|
return Image.open(str(logo_path)).convert("RGBA")
|
|
|
|
|
|
def _draw_logo_centered(img, event, y, max_width):
|
|
"""Рисует логотип по центру, возвращает новую y-координату."""
|
|
if not event.logo_filename:
|
|
return y
|
|
|
|
logo_path = Path(current_app.config["LOGO_FOLDER"]) / event.logo_filename
|
|
if not logo_path.exists():
|
|
current_app.logger.warning(f"Логотип не найден: {logo_path}")
|
|
return y
|
|
|
|
try:
|
|
logo = _load_logo_image(logo_path)
|
|
if logo is None:
|
|
return y
|
|
|
|
# Масштабируем по ширине
|
|
if logo.width <= 0 or logo.height <= 0:
|
|
current_app.logger.warning(f"Логотип имеет некорректные размеры: {logo.width}x{logo.height}")
|
|
return y
|
|
|
|
ratio = logo.width / logo.height
|
|
new_width = min(max_width, logo.width)
|
|
new_height = int(new_width / ratio)
|
|
logo = logo.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
|
|
|
x = (TICKET_SIZE[0] - new_width) // 2
|
|
img.paste(logo, (x, y), logo)
|
|
return y + new_height + 25
|
|
except Exception as e:
|
|
current_app.logger.warning(f"Не удалось отрисовать логотип {event.logo_filename}: {e}")
|
|
return y
|
|
|
|
|
|
def hex_to_rgb(hex_color):
|
|
"""Конвертирует HEX-цвет в кортеж RGB."""
|
|
hex_color = hex_color.lstrip("#")
|
|
if len(hex_color) != 6:
|
|
return (255, 255, 255)
|
|
return tuple(int(hex_color[i:i + 2], 16) for i in (0, 2, 4))
|
|
|
|
|
|
def _adjust_color(color, factor=0.85):
|
|
"""Затемняет или осветляет RGB-цвет."""
|
|
rgb = color if isinstance(color, tuple) else hex_to_rgb(color)
|
|
return tuple(min(255, max(0, int(c * factor))) for c in rgb)
|
|
|
|
|
|
def _get_font(size, style="regular"):
|
|
"""Пытается загрузить системный шрифт."""
|
|
candidates = {
|
|
"regular": ["calibri.ttf", "segoeui.ttf", "arial.ttf"],
|
|
"bold": ["calibrib.ttf", "segoeuib.ttf", "arialbd.ttf"],
|
|
"serif": ["times.ttf", "georgia.ttf", "cambria.ttf", "calibri.ttf"],
|
|
"serif_bold": ["timesbd.ttf", "georgiab.ttf", "cambriab.ttf", "calibrib.ttf"],
|
|
}
|
|
|
|
for font_name in candidates.get(style, candidates["regular"]):
|
|
font_path = Path("C:/Windows/Fonts") / font_name
|
|
if font_path.exists():
|
|
try:
|
|
return ImageFont.truetype(str(font_path), size)
|
|
except Exception:
|
|
pass
|
|
return ImageFont.load_default()
|
|
|
|
|
|
def _draw_text_centered(draw, text, y, width, font, fill, max_width=None):
|
|
"""Рисует текст по центру с переносом по словам."""
|
|
if not text:
|
|
return y
|
|
|
|
max_width = max_width or width - 80
|
|
words = text.split()
|
|
lines = []
|
|
current_line = ""
|
|
|
|
for word in words:
|
|
test_line = f"{current_line} {word}".strip()
|
|
bbox = draw.textbbox((0, 0), test_line, font=font)
|
|
if bbox[2] - bbox[0] <= max_width:
|
|
current_line = test_line
|
|
else:
|
|
if current_line:
|
|
lines.append(current_line)
|
|
current_line = word
|
|
if current_line:
|
|
lines.append(current_line)
|
|
|
|
line_height = font.size + 10
|
|
for line in lines:
|
|
bbox = draw.textbbox((0, 0), line, font=font)
|
|
text_width = bbox[2] - bbox[0]
|
|
x = (width - text_width) // 2
|
|
draw.text((x, y), line, font=font, fill=fill)
|
|
y += line_height
|
|
|
|
return y
|
|
|
|
|
|
def _draw_text_left(draw, text, x, y, font, fill, max_width):
|
|
"""Рисует текст слева с переносом по словам."""
|
|
if not text:
|
|
return y
|
|
|
|
words = text.split()
|
|
lines = []
|
|
current_line = ""
|
|
|
|
for word in words:
|
|
test_line = f"{current_line} {word}".strip()
|
|
bbox = draw.textbbox((0, 0), test_line, font=font)
|
|
if bbox[2] - bbox[0] <= max_width:
|
|
current_line = test_line
|
|
else:
|
|
if current_line:
|
|
lines.append(current_line)
|
|
current_line = word
|
|
if current_line:
|
|
lines.append(current_line)
|
|
|
|
line_height = font.size + 6
|
|
for line in lines:
|
|
draw.text((x, y), line, font=font, fill=fill)
|
|
y += line_height
|
|
|
|
return y
|
|
|
|
|
|
def _draw_info_row(draw, label, value, x, y, width, label_font, value_font, fill):
|
|
"""Рисует строку с подписью и значением по центру."""
|
|
if not value:
|
|
return y
|
|
|
|
# Подпись по центру
|
|
bbox = draw.textbbox((0, 0), label, font=label_font)
|
|
label_width = bbox[2] - bbox[0]
|
|
draw.text((x + (width - label_width) // 2, y), label, font=label_font, fill=fill)
|
|
y += label_font.size + 4
|
|
|
|
# Значение по центру
|
|
words = value.split()
|
|
lines = []
|
|
current_line = ""
|
|
for word in words:
|
|
test_line = f"{current_line} {word}".strip()
|
|
bbox = draw.textbbox((0, 0), test_line, font=value_font)
|
|
if bbox[2] - bbox[0] <= width:
|
|
current_line = test_line
|
|
else:
|
|
if current_line:
|
|
lines.append(current_line)
|
|
current_line = word
|
|
if current_line:
|
|
lines.append(current_line)
|
|
|
|
line_height = value_font.size + 6
|
|
for line in lines:
|
|
bbox = draw.textbbox((0, 0), line, font=value_font)
|
|
text_width = bbox[2] - bbox[0]
|
|
draw.text((x + (width - text_width) // 2, y), line, font=value_font, fill=fill)
|
|
y += line_height
|
|
|
|
return y + 16
|
|
|
|
|
|
def _draw_ornament(draw, y, width, color):
|
|
"""Рисует декоративную горизонтальную линию с точками по краям."""
|
|
margin = 100
|
|
draw.line([(margin, y), (width - margin, y)], fill=color, width=1)
|
|
r = 4
|
|
draw.ellipse([margin - r, y - r, margin + r, y + r], fill=color)
|
|
draw.ellipse([width - margin - r, y - r, width - margin + r, y + r], fill=color)
|
|
|
|
|
|
def generate_ticket_image(guest, event, output_path=None):
|
|
"""Генерирует элегантный PNG-пригласительный билет."""
|
|
if not guest.qr_filename:
|
|
generate_qr_for_guest(guest)
|
|
|
|
width, height = TICKET_SIZE
|
|
bg_color = hex_to_rgb(event.ticket_background_color or "#ffffff")
|
|
text_color = hex_to_rgb(event.ticket_text_color or "#1a1a1a")
|
|
accent_color = hex_to_rgb(event.ticket_accent_color or "#0d6efd")
|
|
|
|
img = Image.new("RGB", (width, height), bg_color)
|
|
draw = ImageDraw.Draw(img)
|
|
|
|
# Мягкий фоновый градиент/рамка
|
|
for i in range(20):
|
|
alpha = int(255 * (1 - i / 40))
|
|
c = tuple(min(255, int(bg_color[j] + (accent_color[j] - bg_color[j]) * (i / 60))) for j in range(3))
|
|
draw.rectangle([i, i, width - 1 - i, height - 1 - i], outline=c, width=1)
|
|
|
|
# Декоративная внешняя рамка
|
|
draw.rectangle([22, 22, width - 22, height - 22], outline=accent_color, width=2)
|
|
draw.rectangle([28, 28, width - 28, height - 28], outline=_adjust_color(accent_color, 1.3), width=1)
|
|
|
|
# Шрифты
|
|
title_font = _get_font(32, "serif_bold")
|
|
subtitle_font = _get_font(20, "serif")
|
|
name_font = _get_font(28, "bold")
|
|
body_font = _get_font(17, "regular")
|
|
label_font = _get_font(13, "regular")
|
|
small_font = _get_font(12, "regular")
|
|
|
|
y = 50
|
|
|
|
# Логотип по центру сверху
|
|
y = _draw_logo_centered(img, event, y, max_width=300)
|
|
|
|
# Название мероприятия
|
|
title = event.ticket_title or event.name
|
|
y = _draw_text_centered(draw, title, y, width, title_font, text_color, max_width=width - 80)
|
|
|
|
# Подзаголовок
|
|
if event.ticket_subtitle:
|
|
y += 8
|
|
y = _draw_text_centered(draw, event.ticket_subtitle, y, width, subtitle_font, text_color, max_width=width - 80)
|
|
|
|
y += 25
|
|
_draw_ornament(draw, y, width, accent_color)
|
|
y += 30
|
|
|
|
# Приглашается
|
|
y = _draw_text_centered(draw, "ПРИГЛАШАЕТСЯ", y, width, label_font, text_color)
|
|
y += 12
|
|
y = _draw_text_centered(draw, guest.build_full_name(), y, width, name_font, text_color, max_width=width - 80)
|
|
y += 30
|
|
|
|
# Данные гостя
|
|
content_width = width - 120
|
|
x_content = 60
|
|
|
|
if guest.department:
|
|
y = _draw_info_row(draw, "Отдел", guest.department, x_content, y, content_width, label_font, body_font, text_color)
|
|
if guest.position:
|
|
y = _draw_info_row(draw, "Должность", guest.position, x_content, y, content_width, label_font, body_font, text_color)
|
|
if guest.email:
|
|
y = _draw_info_row(draw, "Email", guest.email, x_content, y, content_width, label_font, body_font, text_color)
|
|
if guest.phone:
|
|
y = _draw_info_row(draw, "Телефон", guest.phone, x_content, y, content_width, label_font, body_font, text_color)
|
|
|
|
# Инфо о мероприятии
|
|
if event.event_date or event.location:
|
|
y += 10
|
|
_draw_ornament(draw, y, width, accent_color)
|
|
y += 25
|
|
if event.event_date:
|
|
date_value = event.event_date.strftime("%d.%m.%Y")
|
|
time_value = event.event_date.strftime("%H:%M")
|
|
y = _draw_info_row(draw, "Дата", date_value, x_content, y, content_width, label_font, body_font, text_color)
|
|
y = _draw_info_row(draw, "Время", time_value, x_content, y, content_width, label_font, body_font, text_color)
|
|
if event.location:
|
|
y = _draw_info_row(draw, "Место проведения", event.location, x_content, y, content_width, label_font, body_font, text_color)
|
|
|
|
# QR-код
|
|
if event.ticket_show_qr:
|
|
y += 15
|
|
qr_path = Path(current_app.config["QR_FOLDER"]) / guest.qr_filename
|
|
if qr_path.exists():
|
|
qr_size = 200
|
|
qr_img = Image.open(str(qr_path)).convert("RGB")
|
|
qr_img = qr_img.resize((qr_size, qr_size))
|
|
x_qr = (width - qr_size) // 2
|
|
img.paste(qr_img, (x_qr, y))
|
|
y += qr_size + 12
|
|
y = _draw_text_centered(draw, "QR-код для входа", y, width, small_font, text_color)
|
|
|
|
# Фото гостя
|
|
if event.ticket_show_photo and guest.photo_filename:
|
|
photo_path = Path(current_app.config["PHOTO_FOLDER"]) / guest.photo_filename
|
|
if photo_path.exists():
|
|
photo_img = Image.open(str(photo_path)).convert("RGB")
|
|
photo_img.thumbnail((130, 130))
|
|
x_photo = (width - photo_img.width) // 2
|
|
img.paste(photo_img, (x_photo, y))
|
|
y += photo_img.height + 20
|
|
|
|
# Дополнительный текст
|
|
if event.ticket_body_text:
|
|
y += 10
|
|
y = _draw_text_centered(draw, event.ticket_body_text, y, width, small_font, text_color, max_width=width - 80)
|
|
y += small_font.size + 10
|
|
|
|
# Номер билета
|
|
ticket_id = f"№ {guest.token[:8].upper()}" if not guest.token.startswith("preview") else "№ PREVIEW"
|
|
y = max(y, height - 105)
|
|
_draw_text_centered(draw, ticket_id, y, width, small_font, text_color)
|
|
|
|
# Нижнее уведомление
|
|
draw.rectangle([22, height - 65, width - 22, height - 22], fill=accent_color)
|
|
_draw_text_centered(draw, NON_TRANSFERABLE_TEXT, height - 50, width, small_font, (255, 255, 255), max_width=width - 60)
|
|
|
|
# Сохранение
|
|
if output_path is None:
|
|
tickets_folder = Path(current_app.config["UPLOAD_FOLDER"]) / "tickets"
|
|
tickets_folder.mkdir(parents=True, exist_ok=True)
|
|
output_path = tickets_folder / f"ticket_{event.id}_{guest.token}.png"
|
|
|
|
img.save(str(output_path), "PNG")
|
|
return Path(output_path)
|
|
|
|
|
|
def generate_preview_ticket(event, sample_guest=None):
|
|
"""Генерирует билет для предпросмотра."""
|
|
if sample_guest is None:
|
|
from app.models import Guest
|
|
sample_guest = Guest(
|
|
last_name="Иванов",
|
|
first_name="Иван",
|
|
middle_name="Иванович",
|
|
full_name="Иванов Иван Иванович",
|
|
department="Отдел продаж",
|
|
position="Менеджер",
|
|
email="example@company.com",
|
|
phone="+7 (999) 123-45-67",
|
|
token="preview-token-12345",
|
|
qr_filename=None,
|
|
)
|
|
from app.services.qr_service import generate_qr_for_guest
|
|
generate_qr_for_guest(sample_guest, base_url="http://example.com")
|
|
|
|
return generate_ticket_image(sample_guest, event)
|