Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- # -*- coding: utf-8 -*-
- """
- Pikabu Avatar Maker
- Адаптивный интерфейс с переключением темы.
- by KOTOFEICH
- """
- import tkinter as tk
- from tkinter import ttk, filedialog, messagebox
- from PIL import Image, ImageTk, ImageDraw
- import os
- import re
- import sys
- import webbrowser
- import platform
- import tempfile
- import atexit
- class SingleInstance:
- """Класс для предотвращения повторного запуска программы."""
- def __init__(self, app_name: str = "PikabuAvatarMaker"):
- self.lockfile = os.path.join(tempfile.gettempdir(), f"{app_name}.lock")
- self.fd = None
- def is_running(self) -> bool:
- """Проверяет, запущен ли уже экземпляр программы."""
- if platform.system() == 'Windows':
- return self._check_windows()
- else:
- return self._check_unix()
- def _check_windows(self) -> bool:
- """Проверка для Windows."""
- try:
- # Пытаемся открыть файл в эксклюзивном режиме
- if os.path.exists(self.lockfile):
- os.remove(self.lockfile)
- self.fd = open(self.lockfile, 'w')
- self.fd.write(str(os.getpid()))
- self.fd.flush()
- return False # Не запущен
- except (IOError, OSError, PermissionError):
- return True # Уже запущен
- def _check_unix(self) -> bool:
- """Проверка для Linux/macOS."""
- import fcntl
- try:
- self.fd = open(self.lockfile, 'w')
- fcntl.flock(self.fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
- self.fd.write(str(os.getpid()))
- self.fd.flush()
- return False # Не запущен
- except (IOError, OSError):
- return True # Уже запущен
- def cleanup(self) -> None:
- """Очистка при закрытии программы."""
- try:
- if self.fd:
- self.fd.close()
- if os.path.exists(self.lockfile):
- os.remove(self.lockfile)
- except:
- pass
- class ThemeManager:
- """Менеджер тем оформления."""
- LIGHT_THEME = {
- 'name': 'light',
- 'bg': '#ffffff',
- 'fg': '#000000',
- 'accent': '#7bc143',
- 'canvas_bg': '#f5f5f5',
- 'frame_bg': '#ffffff',
- 'entry_bg': '#ffffff',
- 'entry_fg': '#000000',
- 'button_bg': '#e1e1e1',
- 'button_fg': '#000000',
- 'accent_button_bg': '#7bc143',
- 'accent_button_fg': '#ffffff',
- 'link_color': '#7bc143',
- 'border': '#cccccc',
- 'debug_fg': '#666666',
- 'disabled_fg': '#999999',
- 'slider_bg': '#7bc143',
- }
- DARK_THEME = {
- 'name': 'dark',
- 'bg': '#1a1a1a',
- 'fg': '#e0e0e0',
- 'accent': '#7bc143',
- 'canvas_bg': '#2d2d2d',
- 'frame_bg': '#252525',
- 'entry_bg': '#3d3d3d',
- 'entry_fg': '#e0e0e0',
- 'button_bg': '#4a4a4a',
- 'button_fg': '#e0e0e0',
- 'accent_button_bg': '#7bc143',
- 'accent_button_fg': '#ffffff',
- 'link_color': '#8fd14f',
- 'border': '#555555',
- 'debug_fg': '#888888',
- 'disabled_fg': '#666666',
- 'slider_bg': '#7bc143',
- }
- def __init__(self, root: tk.Tk):
- self.root = root
- self.style = ttk.Style()
- self.current_theme = self.LIGHT_THEME
- self._theme_var = tk.BooleanVar(value=False)
- self.is_windows = platform.system() == 'Windows'
- available_themes = self.style.theme_names()
- if 'clam' in available_themes:
- self.style.theme_use('clam')
- elif 'alt' in available_themes:
- self.style.theme_use('alt')
- @property
- def is_dark(self) -> bool:
- return self._theme_var.get()
- @property
- def theme_var(self) -> tk.BooleanVar:
- return self._theme_var
- def apply_theme(self, dark: bool = False) -> dict:
- """Применяет тему и возвращает её параметры."""
- self.current_theme = self.DARK_THEME if dark else self.LIGHT_THEME
- theme = self.current_theme
- # === Базовые стили ===
- self.style.configure('.',
- background=theme['bg'],
- foreground=theme['fg'],
- fieldbackground=theme['entry_bg'],
- troughcolor=theme['bg'],
- borderwidth=1,
- focuscolor=theme['accent']
- )
- # === Frame ===
- self.style.configure('TFrame', background=theme['bg'])
- # === LabelFrame ===
- self.style.configure('TLabelframe', background=theme['bg'])
- self.style.configure('TLabelframe.Label',
- background=theme['bg'],
- foreground=theme['fg'],
- font=('Arial', 10, 'bold')
- )
- # === Label ===
- self.style.configure('TLabel',
- background=theme['bg'],
- foreground=theme['fg']
- )
- # === Entry ===
- self.style.configure('TEntry',
- fieldbackground=theme['entry_bg'],
- foreground=theme['entry_fg'],
- insertcolor=theme['entry_fg'],
- bordercolor=theme['border'],
- lightcolor=theme['entry_bg'],
- darkcolor=theme['entry_bg']
- )
- self.style.map('TEntry',
- fieldbackground=[
- ('focus', theme['entry_bg']),
- ('!focus', theme['entry_bg'])
- ],
- foreground=[
- ('focus', theme['entry_fg']),
- ('!focus', theme['entry_fg'])
- ],
- bordercolor=[
- ('focus', theme['accent']),
- ('!focus', theme['border'])
- ]
- )
- # === Обычная кнопка ===
- self.style.configure('TButton',
- background=theme['button_bg'],
- foreground=theme['button_fg'],
- bordercolor=theme['border'],
- lightcolor=theme['button_bg'],
- darkcolor=theme['button_bg'],
- padding=(10, 5)
- )
- self.style.map('TButton',
- background=[
- ('active', theme['accent']),
- ('pressed', theme['accent']),
- ('disabled', theme['bg'])
- ],
- foreground=[
- ('active', '#ffffff'),
- ('pressed', '#ffffff'),
- ('disabled', theme['disabled_fg'])
- ]
- )
- # === Акцентная кнопка ===
- self.style.configure('Accent.TButton',
- background=theme['accent_button_bg'],
- foreground=theme['accent_button_fg'],
- bordercolor=theme['accent_button_bg'],
- lightcolor=theme['accent_button_bg'],
- darkcolor=theme['accent_button_bg'],
- padding=(20, 10),
- font=('Arial', 11, 'bold')
- )
- self.style.map('Accent.TButton',
- background=[
- ('active', '#8fd14f'),
- ('pressed', '#6aa832'),
- ('disabled', theme['button_bg'])
- ],
- foreground=[
- ('active', '#ffffff'),
- ('pressed', '#ffffff'),
- ('disabled', theme['disabled_fg'])
- ]
- )
- # === Checkbutton ===
- self.style.configure('TCheckbutton',
- background=theme['bg'],
- foreground=theme['fg']
- )
- self.style.map('TCheckbutton',
- background=[('active', theme['bg'])],
- foreground=[('active', theme['accent'])]
- )
- # === Radiobutton ===
- self.style.configure('TRadiobutton',
- background=theme['bg'],
- foreground=theme['fg']
- )
- self.style.map('TRadiobutton',
- background=[('active', theme['bg'])],
- foreground=[('active', theme['accent'])]
- )
- # === Scale (Slider) ===
- self.style.configure('Horizontal.TScale',
- background=theme['bg'],
- troughcolor=theme['border'],
- sliderthickness=20
- )
- self.style.map('Horizontal.TScale',
- background=[('active', theme['accent'])]
- )
- # === Separator ===
- self.style.configure('TSeparator', background=theme['border'])
- # === Специальные стили ===
- # Ссылка
- self.style.configure('Link.TLabel',
- background=theme['bg'],
- foreground=theme['link_color'],
- font=('Arial', 10, 'underline')
- )
- # Заголовок
- self.style.configure('Title.TLabel',
- background=theme['bg'],
- foreground=theme['accent'],
- font=('Arial', 16, 'bold')
- )
- # Debug
- self.style.configure('Debug.TLabel',
- background=theme['bg'],
- foreground=theme['debug_fg'],
- font=('Consolas', 9)
- )
- # Offset label
- self.style.configure('Offset.TLabel',
- background=theme['bg'],
- foreground=theme['accent'],
- font=('Consolas', 10, 'bold')
- )
- # Фон главного окна
- self.root.configure(bg=theme['bg'])
- return theme
- class IconGenerator:
- """Генератор иконки в стиле Пикабу."""
- @staticmethod
- def create_pikabu_icon(size: int = 64) -> Image.Image:
- """Создаёт иконку в стиле Пикабу (зелёный монстрик)."""
- img = Image.new('RGBA', (size, size), (0, 0, 0, 0))
- draw = ImageDraw.Draw(img)
- green = '#7bc143'
- dark_green = '#5a9e2f'
- white = '#ffffff'
- padding = size // 8
- # Тело
- draw.ellipse(
- [padding, padding, size - padding, size - padding],
- fill=green,
- outline=dark_green,
- width=max(1, size // 32)
- )
- # Глаза
- eye_size = size // 5
- eye_y = size // 3
- left_eye_x = size // 3 - eye_size // 2
- right_eye_x = 2 * size // 3 - eye_size // 2
- draw.ellipse(
- [left_eye_x, eye_y, left_eye_x + eye_size, eye_y + eye_size],
- fill=white
- )
- draw.ellipse(
- [right_eye_x, eye_y, right_eye_x + eye_size, eye_y + eye_size],
- fill=white
- )
- # Зрачки
- pupil_size = eye_size // 2
- pupil_offset = eye_size // 4
- draw.ellipse(
- [left_eye_x + pupil_offset, eye_y + pupil_offset,
- left_eye_x + pupil_offset + pupil_size, eye_y + pupil_offset + pupil_size],
- fill='#333333'
- )
- draw.ellipse(
- [right_eye_x + pupil_offset, eye_y + pupil_offset,
- right_eye_x + pupil_offset + pupil_size, eye_y + pupil_offset + pupil_size],
- fill='#333333'
- )
- # Улыбка
- smile_y = size // 2 + size // 8
- smile_width = size // 3
- smile_x = size // 2 - smile_width // 2
- draw.arc(
- [smile_x, smile_y, smile_x + smile_width, smile_y + size // 4],
- start=0, end=180,
- fill=dark_green,
- width=max(2, size // 16)
- )
- # Рожки
- antenna_y = padding + size // 16
- draw.ellipse(
- [size // 3 - size // 16, antenna_y - size // 8,
- size // 3 + size // 16, antenna_y + size // 16],
- fill=green, outline=dark_green
- )
- draw.ellipse(
- [2 * size // 3 - size // 16, antenna_y - size // 8,
- 2 * size // 3 + size // 16, antenna_y + size // 16],
- fill=green, outline=dark_green
- )
- return img
- @staticmethod
- def set_app_icon(root: tk.Tk) -> None:
- """Устанавливает иконку приложения."""
- try:
- icon_sizes = [16, 32, 48, 64, 128, 256]
- icons = []
- for size in icon_sizes:
- icon = IconGenerator.create_pikabu_icon(size)
- icons.append(ImageTk.PhotoImage(icon))
- root._icons = icons
- root.iconphoto(True, *icons)
- except Exception as e:
- print(f"Не удалось установить иконку: {e}")
- class PikabuAvatarMaker:
- """Приложение для создания аватарки и фона профиля Pikabu."""
- # Параметры Пикабу
- EXACT_BG_WIDTH = 700
- EXACT_BG_HEIGHT = 140
- EXACT_AVATAR_SIZE = 128
- EXACT_AVATAR_LEFT = 24
- EXACT_AVATAR_TOP = 50
- EXACT_BORDER_SIZE = 3
- # Параметры окна
- MIN_WINDOW_WIDTH = 600
- MIN_WINDOW_HEIGHT = 600
- # Валидация
- MIN_INPUT_WIDTH = 100
- MIN_INPUT_HEIGHT = 28
- INVALID_FILENAME_CHARS = r'[<>:"/\\|?*]'
- # Информация
- AUTHOR_NAME = "KOTOFEICH"
- AUTHOR_URL = "https://pikabu.ru/@K0T0FEICH"
- APP_TITLE = "Pikabu Avatar Maker"
- def __init__(self, root: tk.Tk):
- self.root = root
- self.root.title(self.APP_TITLE)
- self.root.geometry("1000x850")
- self.root.minsize(self.MIN_WINDOW_WIDTH, self.MIN_WINDOW_HEIGHT)
- IconGenerator.set_app_icon(root)
- self.theme_manager = ThemeManager(root)
- # Переменные
- self.input_path = tk.StringVar()
- self.output_prefix = tk.StringVar(value="pikabu")
- self.include_border = tk.BooleanVar(value=True)
- self.status_text = tk.StringVar(value="Готово к работе")
- # Смещение по вертикали (0 = по центру)
- self.vertical_offset = tk.DoubleVar(value=0.0)
- self.offset_limits = {'min': 0, 'max': 0}
- # Данные
- self.original_image: Image.Image | None = None
- self._tk_images: dict[str, ImageTk.PhotoImage] = {}
- self._resize_job = None
- self._canvases: list[tk.Canvas] = []
- self._entries: list[ttk.Entry] = []
- self._create_widgets()
- self._setup_resize_handler()
- self._apply_current_theme()
- def _create_widgets(self) -> None:
- """Создание всех виджетов."""
- self.main = ttk.Frame(self.root, padding=15)
- self.main.grid(row=0, column=0, sticky="nsew")
- self.root.columnconfigure(0, weight=1)
- self.root.rowconfigure(0, weight=1)
- self.main.columnconfigure(0, weight=1)
- self.main.rowconfigure(5, weight=1)
- self._create_header(row=0)
- self._create_file_section(row=1)
- self._create_settings_section(row=2)
- self._create_theme_section(row=3)
- self._create_offset_section(row=4)
- self._create_preview_section(row=5)
- self.debug_label = ttk.Label(self.main, text="", style='Debug.TLabel')
- self.debug_label.grid(row=6, column=0, sticky="ew", pady=5)
- self._create_buttons_section(row=7)
- self._create_footer(row=8)
- def _create_header(self, row: int) -> None:
- """Заголовок приложения."""
- header = ttk.Frame(self.main)
- header.grid(row=row, column=0, sticky="ew", pady=(0, 10))
- header.columnconfigure(0, weight=1)
- title_frame = ttk.Frame(header)
- title_frame.grid(row=0, column=0)
- icon_small = IconGenerator.create_pikabu_icon(32)
- self._tk_images['header_icon'] = ImageTk.PhotoImage(icon_small)
- icon_label = ttk.Label(title_frame, image=self._tk_images['header_icon'])
- icon_label.pack(side="left", padx=(0, 10))
- ttk.Label(
- title_frame,
- text=self.APP_TITLE,
- style='Title.TLabel'
- ).pack(side="left")
- def _create_file_section(self, row: int) -> None:
- """Секция выбора файла."""
- lf = ttk.LabelFrame(self.main, text="📁 Исходное изображение", padding=10)
- lf.grid(row=row, column=0, sticky="ew", pady=5)
- lf.columnconfigure(1, weight=1)
- ttk.Label(lf, text="Файл:").grid(row=0, column=0, sticky="w")
- entry = ttk.Entry(lf, textvariable=self.input_path)
- entry.grid(row=0, column=1, sticky="ew", padx=10)
- self._entries.append(entry)
- ttk.Button(lf, text="Обзор...", command=self._browse_file).grid(
- row=0, column=2, sticky="e"
- )
- self.file_info = ttk.Label(lf, text="Файл не выбран", foreground="gray")
- self.file_info.grid(row=1, column=0, columnspan=3, sticky="w", pady=(5, 0))
- def _create_settings_section(self, row: int) -> None:
- """Секция настроек."""
- sf = ttk.LabelFrame(self.main, text="⚙️ Настройки", padding=10)
- sf.grid(row=row, column=0, sticky="ew", pady=5)
- sf.columnconfigure(2, weight=1)
- ttk.Checkbutton(
- sf,
- text="Белая обводка аватара (3px)",
- variable=self.include_border,
- command=self._update_preview
- ).grid(row=0, column=0, sticky="w")
- ttk.Separator(sf, orient="vertical").grid(
- row=0, column=1, sticky="ns", padx=20
- )
- ttk.Label(sf, text="Префикс файлов:").grid(row=0, column=2, sticky="e")
- prefix_entry = ttk.Entry(sf, textvariable=self.output_prefix, width=20)
- prefix_entry.grid(row=0, column=3, sticky="w", padx=(10, 0))
- self._entries.append(prefix_entry)
- def _create_theme_section(self, row: int) -> None:
- """Секция переключения темы."""
- tf = ttk.Frame(self.main)
- tf.grid(row=row, column=0, sticky="ew", pady=5)
- ttk.Label(tf, text="🎨 Тема:").pack(side="left")
- ttk.Radiobutton(
- tf, text="☀️ Светлая",
- variable=self.theme_manager.theme_var,
- value=False,
- command=self._apply_current_theme
- ).pack(side="left", padx=(10, 5))
- ttk.Radiobutton(
- tf, text="🌙 Тёмная",
- variable=self.theme_manager.theme_var,
- value=True,
- command=self._apply_current_theme
- ).pack(side="left", padx=5)
- def _create_offset_section(self, row: int) -> None:
- """Секция вертикального смещения."""
- of = ttk.LabelFrame(self.main, text="↕️ Вертикальное смещение", padding=10)
- of.grid(row=row, column=0, sticky="ew", pady=5)
- of.columnconfigure(1, weight=1)
- # Левая часть - кнопки вверх/вниз (фиксированная ширина)
- left_frame = ttk.Frame(of)
- left_frame.grid(row=0, column=0, sticky="w")
- ttk.Button(left_frame, text="▲", width=3, command=self._offset_up).pack(side="left")
- ttk.Button(left_frame, text="▼", width=3, command=self._offset_down).pack(side="left", padx=(5, 0))
- # Центр - слайдер (растягивается)
- self.offset_slider = ttk.Scale(
- of,
- from_=0,
- to=100,
- orient="horizontal",
- variable=self.vertical_offset,
- command=self._on_offset_change
- )
- self.offset_slider.grid(row=0, column=1, sticky="ew", padx=15)
- self.offset_slider.configure(state="disabled")
- # Правая часть - кнопка сброса и лейбл (фиксированная ширина)
- right_frame = ttk.Frame(of)
- right_frame.grid(row=0, column=2, sticky="e")
- ttk.Button(
- right_frame,
- text="↺ Центр",
- width=10,
- command=self._reset_offset
- ).pack(side="left")
- # Лейбл с фиксированной шириной
- self.offset_label = ttk.Label(
- right_frame,
- text="Смещение: 0 px (центр)",
- style='Offset.TLabel',
- width=28,
- anchor="w"
- )
- self.offset_label.pack(side="left", padx=(15, 0))
- def _create_preview_section(self, row: int) -> None:
- """Секция превью (растягиваемая)."""
- pf = ttk.LabelFrame(
- self.main,
- text="👁️ Предпросмотр (красный = фон, синий = аватарка)",
- padding=10
- )
- pf.grid(row=row, column=0, sticky="nsew", pady=5)
- pf.columnconfigure(0, weight=1)
- pf.rowconfigure(0, weight=2)
- pf.rowconfigure(1, weight=1)
- # Canvas для схемы
- schema_frame = ttk.Frame(pf)
- schema_frame.grid(row=0, column=0, sticky="nsew", pady=(0, 10))
- schema_frame.columnconfigure(0, weight=1)
- schema_frame.rowconfigure(0, weight=1)
- self.schema_canvas = tk.Canvas(
- schema_frame, bg="#f5f5f5",
- highlightthickness=1, highlightbackground="#cccccc"
- )
- self.schema_canvas.grid(row=0, column=0, sticky="nsew")
- self._canvases.append(self.schema_canvas)
- # Превью результата
- result_frame = ttk.Frame(pf)
- result_frame.grid(row=1, column=0, sticky="nsew")
- result_frame.columnconfigure(0, weight=3)
- result_frame.columnconfigure(1, weight=1)
- result_frame.rowconfigure(0, weight=1)
- # Фон
- bg_frame = ttk.LabelFrame(result_frame, text="Фон (700×140)", padding=5)
- bg_frame.grid(row=0, column=0, sticky="nsew", padx=(0, 10))
- bg_frame.columnconfigure(0, weight=1)
- bg_frame.rowconfigure(0, weight=1)
- self.bg_canvas = tk.Canvas(
- bg_frame, bg="white",
- highlightthickness=1, highlightbackground="#cccccc"
- )
- self.bg_canvas.grid(row=0, column=0, sticky="nsew")
- self._canvases.append(self.bg_canvas)
- # Аватар
- avatar_frame = ttk.LabelFrame(result_frame, text="Аватар (128×128)", padding=5)
- avatar_frame.grid(row=0, column=1, sticky="nsew")
- avatar_frame.columnconfigure(0, weight=1)
- avatar_frame.rowconfigure(0, weight=1)
- self.avatar_canvas = tk.Canvas(
- avatar_frame, bg="#e0e0e0",
- highlightthickness=1, highlightbackground="#cccccc"
- )
- self.avatar_canvas.grid(row=0, column=0, sticky="nsew")
- self._canvases.append(self.avatar_canvas)
- def _create_buttons_section(self, row: int) -> None:
- """Секция кнопок."""
- bf = ttk.Frame(self.main)
- bf.grid(row=row, column=0, pady=10, sticky="ew")
- bf.columnconfigure(0, weight=1)
- bf.columnconfigure(2, weight=1)
- self.btn_create = ttk.Button(
- bf,
- text="💾 Создать и сохранить",
- style='Accent.TButton',
- state="disabled",
- command=self._create_exact_set
- )
- self.btn_create.grid(row=0, column=1)
- self.status_label = ttk.Label(bf, textvariable=self.status_text)
- self.status_label.grid(row=0, column=2, sticky="e", padx=(20, 0))
- def _create_footer(self, row: int) -> None:
- """Футер с информацией об авторе."""
- footer = ttk.Frame(self.main)
- footer.grid(row=row, column=0, sticky="ew", pady=(10, 0))
- footer.columnconfigure(0, weight=1)
- ttk.Separator(footer, orient="horizontal").grid(
- row=0, column=0, sticky="ew", pady=(0, 10)
- )
- link_frame = ttk.Frame(footer)
- link_frame.grid(row=1, column=0)
- ttk.Label(link_frame, text="by").pack(side="left")
- self.author_link = ttk.Label(
- link_frame,
- text=self.AUTHOR_NAME,
- style='Link.TLabel',
- cursor="hand2"
- )
- self.author_link.pack(side="left", padx=5)
- self.author_link.bind("<Button-1>", self._open_author_page)
- self.author_link.bind("<Enter>", self._on_link_hover)
- self.author_link.bind("<Leave>", self._on_link_leave)
- ttk.Label(link_frame, text="• pikabu.ru").pack(side="left")
- # =========================================================================
- # Управление смещением
- # =========================================================================
- def _calculate_offset_limits(self) -> None:
- """Вычисляет допустимые границы смещения."""
- if not self.original_image:
- self.offset_limits = {'min': 0, 'max': 0}
- return
- img = self.original_image
- w, h = img.size
- target_ratio = self.EXACT_BG_WIDTH / self.EXACT_BG_HEIGHT
- current_ratio = w / h
- if current_ratio > target_ratio:
- # Широкое изображение - нет вертикального смещения
- self.offset_limits = {'min': 0, 'max': 0}
- else:
- # Высокое изображение - можно двигать вертикально
- crop_w = w
- crop_h = round(w / target_ratio)
- scale = crop_w / self.EXACT_BG_WIDTH
- avatar_size_src = round(self.EXACT_AVATAR_SIZE * scale)
- avatar_offset_y = round(self.EXACT_AVATAR_TOP * scale)
- center_bg_top = (h - crop_h) // 2
- # Максимум вверх: фон не выходит за верхний край
- max_up = center_bg_top
- # Максимум вниз: аватарка не выходит за нижний край
- max_bg_top_for_avatar = h - avatar_offset_y - avatar_size_src
- max_down = max_bg_top_for_avatar - center_bg_top
- # Также проверяем, чтобы фон не выходил за нижний край
- max_bg_top_for_bg = h - crop_h
- max_down_for_bg = max_bg_top_for_bg - center_bg_top
- max_down = min(max_down, max_down_for_bg)
- self.offset_limits = {
- 'min': -max_up,
- 'max': max(0, max_down)
- }
- self._update_slider_range()
- def _update_slider_range(self) -> None:
- """Обновляет диапазон слайдера."""
- min_val = self.offset_limits['min']
- max_val = self.offset_limits['max']
- if min_val == max_val == 0:
- self.offset_slider.configure(state="disabled")
- self.vertical_offset.set(0)
- else:
- self.offset_slider.configure(
- state="normal",
- from_=min_val,
- to=max_val
- )
- current = self.vertical_offset.get()
- if current < min_val:
- self.vertical_offset.set(min_val)
- elif current > max_val:
- self.vertical_offset.set(max_val)
- self._update_offset_label()
- def _on_offset_change(self, value=None) -> None:
- """Обработчик изменения слайдера."""
- self._update_offset_label()
- self._update_preview()
- def _offset_up(self) -> None:
- """Сместить вверх на 10 пикселей."""
- if not self.original_image:
- return
- current = self.vertical_offset.get()
- new_val = max(self.offset_limits['min'], current - 10)
- self.vertical_offset.set(new_val)
- self._on_offset_change()
- def _offset_down(self) -> None:
- """Сместить вниз на 10 пикселей."""
- if not self.original_image:
- return
- current = self.vertical_offset.get()
- new_val = min(self.offset_limits['max'], current + 10)
- self.vertical_offset.set(new_val)
- self._on_offset_change()
- def _reset_offset(self) -> None:
- """Сбросить смещение в центр."""
- self.vertical_offset.set(0)
- self._on_offset_change()
- def _update_offset_label(self) -> None:
- """Обновить отображение текущего смещения."""
- offset = int(self.vertical_offset.get())
- if offset < 0:
- text = f"Смещение: {offset:>5} px (вверх)"
- elif offset > 0:
- text = f"Смещение: +{offset:<4} px (вниз)"
- else:
- text = f"Смещение: 0 px (центр)"
- self.offset_label.config(text=text)
- # =========================================================================
- # Hover и ссылки
- # =========================================================================
- def _on_link_hover(self, event=None) -> None:
- """Hover эффект для ссылки."""
- self.author_link.configure(font=('Arial', 10, 'underline bold'))
- def _on_link_leave(self, event=None) -> None:
- """Убираем hover эффект."""
- self.author_link.configure(font=('Arial', 10, 'underline'))
- def _open_author_page(self, event=None) -> None:
- """Открывает страницу автора в браузере."""
- try:
- webbrowser.open(self.AUTHOR_URL)
- except Exception as e:
- messagebox.showerror("Ошибка", f"Не удалось открыть ссылку:\n{e}")
- def _apply_current_theme(self) -> None:
- """Применяет текущую выбранную тему."""
- theme = self.theme_manager.apply_theme(self.theme_manager.is_dark)
- for canvas in self._canvases:
- canvas.configure(
- bg=theme['canvas_bg'],
- highlightbackground=theme['border']
- )
- if self.original_image:
- self.file_info.config(foreground=theme['accent'])
- else:
- self.file_info.config(foreground=theme['disabled_fg'])
- self._update_preview()
- def _setup_resize_handler(self) -> None:
- """Настройка обработчика изменения размера."""
- for canvas in self._canvases:
- canvas.bind("<Configure>", self._on_canvas_resize)
- def _on_canvas_resize(self, event=None) -> None:
- """Обработчик изменения размера с debounce."""
- if self._resize_job:
- self.root.after_cancel(self._resize_job)
- self._resize_job = self.root.after(100, self._update_preview)
- # =========================================================================
- # Работа с файлами
- # =========================================================================
- def _browse_file(self) -> None:
- """Открытие диалога выбора файла."""
- filename = filedialog.askopenfilename(
- title="Выберите изображение",
- filetypes=[
- ("Изображения", "*.png *.jpg *.jpeg *.webp *.bmp *.gif"),
- ("Все файлы", "*.*")
- ]
- )
- if filename:
- self._load_image(filename)
- def _load_image(self, path: str) -> None:
- """Загрузка и валидация изображения."""
- try:
- with Image.open(path) as img:
- converted = self._normalize_image_mode(img)
- self.original_image = converted.copy()
- self._validate_image_size(self.original_image)
- self.input_path.set(path)
- theme = self.theme_manager.current_theme
- self.file_info.config(
- text=f"✓ {os.path.basename(path)} — {self.original_image.width}×{self.original_image.height} px",
- foreground=theme['accent']
- )
- self.btn_create.config(state="normal")
- self.status_text.set("Изображение загружено")
- # Сбрасываем смещение и вычисляем новые границы
- self.vertical_offset.set(0)
- self._calculate_offset_limits()
- self._update_preview()
- except Exception as e:
- messagebox.showerror("Ошибка загрузки", str(e))
- self.original_image = None
- def _validate_image_size(self, img: Image.Image) -> bool:
- """Проверка минимального размера."""
- if img.width < self.MIN_INPUT_WIDTH or img.height < self.MIN_INPUT_HEIGHT:
- messagebox.showwarning(
- "Предупреждение",
- f"Изображение маленькое ({img.width}×{img.height}).\n"
- f"Рекомендуемый минимум: {self.MIN_INPUT_WIDTH}×{self.MIN_INPUT_HEIGHT} px."
- )
- return True
- def _sanitize_filename(self, prefix: str) -> str:
- """Очистка префикса."""
- sanitized = re.sub(self.INVALID_FILENAME_CHARS, '_', prefix.strip())
- return sanitized if sanitized else "pikabu"
- # =========================================================================
- # Обработка изображений
- # =========================================================================
- @staticmethod
- def _normalize_image_mode(img: Image.Image) -> Image.Image:
- """Приведение к RGB/RGBA."""
- if img.mode == 'P':
- return img.convert('RGBA')
- elif img.mode not in ('RGB', 'RGBA'):
- return img.convert('RGB')
- return img.copy()
- @staticmethod
- def _to_rgb_with_white_bg(img: Image.Image) -> Image.Image:
- """Конвертация в RGB."""
- if img.mode == 'RGBA':
- rgb = Image.new('RGB', img.size, (255, 255, 255))
- rgb.paste(img, mask=img.split()[3])
- return rgb
- elif img.mode != 'RGB':
- return img.convert('RGB')
- return img
- def _calculate_regions(self, image: Image.Image) -> tuple[tuple, tuple, float]:
- """Вычисляет области фона и аватарки с учётом смещения."""
- w, h = image.size
- if h == 0 or w == 0:
- raise ValueError("Изображение имеет нулевой размер")
- target_ratio = self.EXACT_BG_WIDTH / self.EXACT_BG_HEIGHT
- current_ratio = w / h
- offset = int(self.vertical_offset.get())
- if current_ratio > target_ratio:
- # Широкое изображение - обрезаем бока
- crop_h = h
- crop_w = round(h * target_ratio)
- bg_left = (w - crop_w) // 2
- bg_top = 0
- else:
- # Высокое изображение - обрезаем верх/низ с учётом смещения
- crop_w = w
- crop_h = round(w / target_ratio)
- bg_left = 0
- center_top = (h - crop_h) // 2
- bg_top = center_top + offset
- # Ограничиваем границами
- bg_top = max(0, min(bg_top, h - crop_h))
- bg_box = (bg_left, bg_top, bg_left + crop_w, bg_top + crop_h)
- scale = crop_w / self.EXACT_BG_WIDTH
- avatar_offset_x = round(self.EXACT_AVATAR_LEFT * scale)
- avatar_offset_y = round(self.EXACT_AVATAR_TOP * scale)
- avatar_size_src = round(self.EXACT_AVATAR_SIZE * scale)
- avatar_left = bg_left + avatar_offset_x
- avatar_top = bg_top + avatar_offset_y
- avatar_box = (
- avatar_left, avatar_top,
- avatar_left + avatar_size_src, avatar_top + avatar_size_src
- )
- return bg_box, avatar_box, scale
- def _create_background(self, image: Image.Image) -> Image.Image:
- """Создаёт фон 700×140."""
- bg_box, _, _ = self._calculate_regions(image)
- crop = image.crop(bg_box)
- return crop.resize(
- (self.EXACT_BG_WIDTH, self.EXACT_BG_HEIGHT),
- Image.LANCZOS
- )
- def _create_avatar(self, image: Image.Image) -> Image.Image:
- """Создаёт круглую аватарку 128×128."""
- _, avatar_box, _ = self._calculate_regions(image)
- area = self._extract_avatar_area(image, avatar_box)
- area = area.resize(
- (self.EXACT_AVATAR_SIZE, self.EXACT_AVATAR_SIZE),
- Image.LANCZOS
- )
- if area.mode != 'RGBA':
- area = area.convert('RGBA')
- avatar = self._apply_circle_mask(area)
- if self.include_border.get():
- avatar = self._add_border(avatar)
- return avatar
- def _extract_avatar_area(
- self,
- image: Image.Image,
- avatar_box: tuple[int, int, int, int]
- ) -> Image.Image:
- """Извлекает область аватарки."""
- av_left, av_top, av_right, av_bottom = avatar_box
- img_w, img_h = image.size
- need_padding = (
- av_left < 0 or av_top < 0 or
- av_right > img_w or av_bottom > img_h
- )
- if not need_padding:
- return image.crop(avatar_box)
- pad_left = max(0, -av_left)
- pad_top = max(0, -av_top)
- pad_right = max(0, av_right - img_w)
- pad_bottom = max(0, av_bottom - img_h)
- new_w = img_w + pad_left + pad_right
- new_h = img_h + pad_top + pad_bottom
- bg_color = (255, 255, 255, 255) if image.mode == 'RGBA' else (255, 255, 255)
- padded = Image.new(image.mode, (new_w, new_h), bg_color)
- padded.paste(image, (pad_left, pad_top))
- adjusted_box = (
- av_left + pad_left, av_top + pad_top,
- av_right + pad_left, av_bottom + pad_top
- )
- return padded.crop(adjusted_box)
- def _apply_circle_mask(self, image: Image.Image) -> Image.Image:
- """Применяет круглую маску."""
- size = image.size[0]
- mask = Image.new("L", (size, size), 0)
- ImageDraw.Draw(mask).ellipse((0, 0, size, size), fill=255)
- result = Image.new("RGBA", (size, size), (0, 0, 0, 0))
- result.paste(image, (0, 0))
- result.putalpha(mask)
- return result
- def _add_border(self, avatar: Image.Image) -> Image.Image:
- """Добавляет белую обводку."""
- b = self.EXACT_BORDER_SIZE
- s = avatar.size[0] + b * 2
- result = Image.new("RGBA", (s, s), (0, 0, 0, 0))
- draw = ImageDraw.Draw(result)
- draw.ellipse((0, 0, s, s), fill=(255, 255, 255, 255))
- result.paste(avatar, (b, b), avatar)
- return result
- # =========================================================================
- # Превью
- # =========================================================================
- def _update_preview(self) -> None:
- """Обновление превью."""
- if not self.original_image:
- self._draw_empty_canvases()
- return
- try:
- self._update_debug_info()
- self._draw_schema()
- self._update_result_previews()
- except Exception as e:
- self.debug_label.config(text=f"Ошибка: {e}")
- def _draw_empty_canvases(self) -> None:
- """Пустые canvas'ы."""
- theme = self.theme_manager.current_theme
- for canvas in self._canvases:
- canvas.delete("all")
- cw = self.schema_canvas.winfo_width()
- ch = self.schema_canvas.winfo_height()
- if cw > 1 and ch > 1:
- self.schema_canvas.create_text(
- cw // 2, ch // 2,
- text="Выберите изображение для предпросмотра",
- fill=theme['debug_fg'], font=("Arial", 12)
- )
- def _update_debug_info(self) -> None:
- """Обновление debug-информации."""
- img = self.original_image
- bg_box, avatar_box, scale = self._calculate_regions(img)
- offset = int(self.vertical_offset.get())
- debug = f"Масштаб: {scale:.2f}x | Смещение: {offset}px | "
- debug += f"Фон Y: [{bg_box[1]}→{bg_box[3]}] | "
- debug += f"Аватар Y: [{avatar_box[1]}→{avatar_box[3]}]"
- if avatar_box[3] > img.height:
- debug += f" | ⚠️ Аватар за границей!"
- self.debug_label.config(text=debug)
- def _draw_schema(self) -> None:
- """Отрисовка схемы."""
- img = self.original_image
- bg_box, avatar_box, _ = self._calculate_regions(img)
- theme = self.theme_manager.current_theme
- self.schema_canvas.delete("all")
- canvas_w = self.schema_canvas.winfo_width()
- canvas_h = self.schema_canvas.winfo_height()
- if canvas_w < 10 or canvas_h < 10:
- return
- padding = 15
- available_w = canvas_w - padding * 2
- available_h = canvas_h - padding * 2
- display_scale = min(available_w / img.width, available_h / img.height)
- dw = int(img.width * display_scale)
- dh = int(img.height * display_scale)
- offset_x = (canvas_w - dw) // 2
- offset_y = (canvas_h - dh) // 2
- # Миниатюра
- thumb = img.copy()
- thumb.thumbnail((dw, dh), Image.LANCZOS)
- self._tk_images['thumb'] = ImageTk.PhotoImage(thumb)
- self.schema_canvas.create_image(
- offset_x, offset_y, anchor="nw", image=self._tk_images['thumb']
- )
- # Рамка исходника
- self.schema_canvas.create_rectangle(
- offset_x, offset_y, offset_x + dw, offset_y + dh,
- outline=theme['border'], width=1
- )
- # Рамка фона (красная)
- bx1 = offset_x + int(bg_box[0] * display_scale)
- by1 = offset_y + int(bg_box[1] * display_scale)
- bx2 = offset_x + int(bg_box[2] * display_scale)
- by2 = offset_y + int(bg_box[3] * display_scale)
- self.schema_canvas.create_rectangle(
- bx1, by1, bx2, by2, outline="#e74c3c", width=2
- )
- self.schema_canvas.create_text(
- bx1 + 8, by1 + 8, anchor="nw",
- text="Фон", fill="#e74c3c", font=("Arial", 10, "bold")
- )
- # Рамка аватарки (синяя)
- ax1 = offset_x + int(avatar_box[0] * display_scale)
- ay1 = offset_y + int(avatar_box[1] * display_scale)
- ax2 = offset_x + int(avatar_box[2] * display_scale)
- ay2 = offset_y + int(avatar_box[3] * display_scale)
- self.schema_canvas.create_oval(
- ax1, ay1, ax2, ay2, outline="#3498db", width=2
- )
- self.schema_canvas.create_text(
- (ax1 + ax2) // 2, (ay1 + ay2) // 2,
- text="Аватар", fill="#3498db", font=("Arial", 10, "bold")
- )
- def _update_result_previews(self) -> None:
- """Обновление превью результатов."""
- img = self.original_image
- # === Фон ===
- bg = self._create_background(img)
- bg_canvas_w = self.bg_canvas.winfo_width()
- bg_canvas_h = self.bg_canvas.winfo_height()
- if bg_canvas_w > 10 and bg_canvas_h > 10:
- bg_scale = min(
- (bg_canvas_w - 10) / bg.width,
- (bg_canvas_h - 10) / bg.height
- )
- new_w = max(1, int(bg.width * bg_scale))
- new_h = max(1, int(bg.height * bg_scale))
- bg_preview = bg.resize((new_w, new_h), Image.LANCZOS)
- self._tk_images['bg'] = ImageTk.PhotoImage(bg_preview)
- self.bg_canvas.delete("all")
- self.bg_canvas.create_image(
- bg_canvas_w // 2, bg_canvas_h // 2,
- anchor="center", image=self._tk_images['bg']
- )
- # === Аватар ===
- avatar = self._create_avatar(img)
- av_canvas_w = self.avatar_canvas.winfo_width()
- av_canvas_h = self.avatar_canvas.winfo_height()
- if av_canvas_w > 10 and av_canvas_h > 10:
- av_scale = min(
- (av_canvas_w - 10) / avatar.width,
- (av_canvas_h - 10) / avatar.height,
- 1.5
- )
- new_size = max(1, int(avatar.width * av_scale))
- avatar_preview = avatar.resize((new_size, new_size), Image.LANCZOS)
- self._tk_images['avatar'] = ImageTk.PhotoImage(avatar_preview)
- self.avatar_canvas.delete("all")
- self.avatar_canvas.create_image(
- av_canvas_w // 2, av_canvas_h // 2,
- anchor="center", image=self._tk_images['avatar']
- )
- # =========================================================================
- # Сохранение
- # =========================================================================
- def _create_exact_set(self) -> None:
- """Создание и сохранение набора."""
- if not self.original_image:
- messagebox.showwarning("Предупреждение", "Сначала выберите изображение")
- return
- try:
- out_dir = os.path.dirname(self.input_path.get()) or "."
- prefix = self._sanitize_filename(self.output_prefix.get())
- bg = self._create_background(self.original_image)
- avatar = self._create_avatar(self.original_image)
- bg_path = os.path.join(out_dir, f"{prefix}_background_700x140.jpg")
- av_path = os.path.join(out_dir, f"{prefix}_avatar_128.png")
- bg_rgb = self._to_rgb_with_white_bg(bg)
- bg_rgb.save(bg_path, "JPEG", quality=95, subsampling=0)
- avatar.save(av_path, "PNG")
- self.status_text.set("✅ Файлы сохранены!")
- messagebox.showinfo(
- "Готово!",
- f"Файлы успешно созданы:\n\n"
- f"📷 {os.path.basename(bg_path)}\n"
- f"👤 {os.path.basename(av_path)}\n\n"
- f"📁 Папка: {out_dir}"
- )
- except PermissionError:
- messagebox.showerror("Ошибка", "Нет прав на запись в выбранную папку")
- except Exception as e:
- import traceback
- messagebox.showerror("Ошибка", f"{e}\n\n{traceback.format_exc()}")
- def main():
- """Точка входа."""
- # Проверка на повторный запуск
- instance = SingleInstance("PikabuAvatarMaker")
- if instance.is_running():
- # Показываем сообщение без создания полного окна
- root = tk.Tk()
- root.withdraw() # Скрываем главное окно
- messagebox.showwarning(
- "Pikabu Avatar Maker",
- "Программа уже запущена!\n\n"
- "Найдите открытое окно программы на панели задач."
- )
- root.destroy()
- sys.exit(0)
- # Регистрируем очистку при выходе
- atexit.register(instance.cleanup)
- # Запускаем приложение
- root = tk.Tk()
- app = PikabuAvatarMaker(root)
- # Очистка при закрытии окна
- def on_closing():
- instance.cleanup()
- root.destroy()
- root.protocol("WM_DELETE_WINDOW", on_closing)
- root.mainloop()
- if __name__ == "__main__":
- main()
Advertisement
Add Comment
Please, Sign In to add comment