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 from app.utils.timezone import utc_to_local from app.utils.paths import event_logo_folder, event_tickets_folder, event_photo_folder, event_qr_folder 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 = event_logo_folder(event.id) / 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: local_event_date = utc_to_local(event.event_date) date_value = local_event_date.strftime("%d.%m.%Y") time_value = local_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 = event_qr_folder(guest.event_id) / 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 = event_photo_folder(guest.event_id) / 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 = event_tickets_folder(event.id) 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( event_id=event.id, 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)