245 lines
10 KiB
Python
245 lines
10 KiB
Python
#!/usr/bin/env python3
|
|
import os
|
|
import sys
|
|
import tkinter as tk
|
|
from tkinter import filedialog, messagebox, ttk
|
|
import threading
|
|
from PIL import Image, ImageTk, ImageFilter
|
|
|
|
# --- Constants & Styling ---
|
|
BG_COLOR = "#0D1117"
|
|
CARD_COLOR = "#21262D"
|
|
ACCENT_COLOR = "#2F81F7"
|
|
TEXT_COLOR = "#C9D1D9"
|
|
TEXT_DIM = "#8B949E"
|
|
SUCCESS_COLOR = "#238636"
|
|
|
|
class SquareIconGUI:
|
|
def __init__(self, root):
|
|
self.root = root
|
|
self.root.title("Standard - Square Icon Studio")
|
|
self.root.geometry("1000x700")
|
|
self.root.configure(bg=BG_COLOR)
|
|
|
|
self.source_paths = []
|
|
self.output_dir = "src-tauri/icons"
|
|
self.tk_orig: ImageTk.PhotoImage | None = None
|
|
self.tk_after: ImageTk.PhotoImage | None = None
|
|
self.is_processing = False
|
|
|
|
self.setup_styles()
|
|
self.setup_ui()
|
|
|
|
def setup_styles(self):
|
|
style = ttk.Style()
|
|
style.theme_use("clam")
|
|
|
|
style.configure("TFrame", background=BG_COLOR)
|
|
style.configure("Card.TFrame", background=CARD_COLOR)
|
|
style.configure("TLabel", background=BG_COLOR, foreground=TEXT_COLOR, font=("Segoe UI", 10))
|
|
style.configure("Header.TLabel", background=BG_COLOR, foreground=TEXT_COLOR, font=("Segoe UI Bold", 20))
|
|
style.configure("Sub.TLabel", background=BG_COLOR, foreground=TEXT_DIM, font=("Segoe UI", 11))
|
|
|
|
style.configure("Config.TLabelframe", background=BG_COLOR, foreground=TEXT_COLOR)
|
|
style.configure("Config.TLabelframe.Label", background=BG_COLOR, foreground=TEXT_COLOR, font=("Segoe UI Bold", 10))
|
|
|
|
def setup_ui(self):
|
|
# Sidebar for controls
|
|
sidebar = ttk.Frame(self.root, padding=20)
|
|
sidebar.pack(side="left", fill="y", padx=(0, 2))
|
|
|
|
ttk.Label(sidebar, text="Square Icon", style="Header.TLabel").pack(anchor="w")
|
|
ttk.Label(sidebar, text="Studio Utility", style="Sub.TLabel").pack(anchor="w", pady=(0, 40))
|
|
|
|
# Main actions
|
|
self.create_action_btn(sidebar, "Select Images", "Batch or single image", self.pick_images, ACCENT_COLOR)
|
|
self.create_action_btn(sidebar, "Output Folder", f"Target: {self.output_dir}", self.pick_output, CARD_COLOR)
|
|
|
|
# Config section
|
|
config_frame = ttk.LabelFrame(sidebar, text=" Resolution ", padding=20, style="Config.TLabelframe")
|
|
config_frame.pack(fill="x", pady=30)
|
|
|
|
ttk.Label(config_frame, text="Target Size (px)").pack(anchor="w")
|
|
self.size_var = tk.StringVar(value="1024")
|
|
self.size_entry = tk.Entry(config_frame, textvariable=self.size_var, bg="#010409", fg=TEXT_COLOR,
|
|
insertbackground=TEXT_COLOR, relief="flat", font=("Segoe UI", 11))
|
|
self.size_entry.pack(fill="x", pady=(5, 0), ipady=5)
|
|
|
|
# Status
|
|
self.status_var = tk.StringVar(value="Ready to process...")
|
|
self.status_label = ttk.Label(sidebar, textvariable=self.status_var, style="Sub.TLabel", wraplength=250)
|
|
self.status_label.pack(side="bottom", fill="x", pady=20)
|
|
|
|
self.process_btn = tk.Button(sidebar, text="Generate Icons", font=("Segoe UI Bold", 12),
|
|
bg=SUCCESS_COLOR, fg="white", relief="flat", cursor="hand2",
|
|
command=self.start_processing, padx=20, pady=12)
|
|
self.process_btn.pack(side="bottom", fill="x")
|
|
|
|
# Preview Area
|
|
preview_container = ttk.Frame(self.root, style="TFrame", padding=30)
|
|
preview_container.pack(side="right", fill="both", expand=True)
|
|
|
|
title_frame = ttk.Frame(preview_container)
|
|
title_frame.pack(fill="x", pady=(0, 20))
|
|
ttk.Label(title_frame, text="Before vs After", font=("Segoe UI Bold", 16)).pack(side="left")
|
|
|
|
# Preview Boxes
|
|
self.preview_grid = ttk.Frame(preview_container)
|
|
self.preview_grid.pack(fill="both", expand=True)
|
|
|
|
# Before
|
|
before_frame = tk.Frame(self.preview_grid, bg="#161b22", highlightbackground="#30363d", highlightthickness=1)
|
|
before_frame.place(relx=0.05, rely=0.05, relwidth=0.42, relheight=0.8)
|
|
self.before_img_lbl = tk.Label(before_frame, text="Original", bg="#161b22", fg=TEXT_DIM)
|
|
self.before_img_lbl.pack(fill="both", expand=True)
|
|
|
|
# After
|
|
after_frame = tk.Frame(self.preview_grid, bg="#161b22", highlightbackground="#30363d", highlightthickness=1)
|
|
after_frame.place(relx=0.53, rely=0.05, relwidth=0.42, relheight=0.8)
|
|
self.after_img_lbl = tk.Label(after_frame, text="Result", bg="#161b22", fg=TEXT_DIM)
|
|
self.after_img_lbl.pack(fill="both", expand=True)
|
|
|
|
def create_action_btn(self, parent, text, sub, cmd, color):
|
|
f = tk.Frame(parent, bg=BG_COLOR)
|
|
f.pack(fill="x", pady=(0, 20))
|
|
|
|
b = tk.Button(f, text=text, command=cmd, bg=color, fg="white",
|
|
font=("Segoe UI Bold", 11), relief="flat", cursor="hand2", pady=8)
|
|
b.pack(fill="x")
|
|
l = ttk.Label(f, text=sub, style="Sub.TLabel")
|
|
l.pack(anchor="w", pady=(2, 0))
|
|
|
|
def pick_images(self):
|
|
paths = filedialog.askopenfilenames(title="Select Source Images")
|
|
if paths:
|
|
self.source_paths = list(paths)
|
|
self.status_var.set(f"Loaded {len(self.source_paths)} file(s)")
|
|
self.update_preview(self.source_paths[0])
|
|
|
|
def pick_output(self):
|
|
path = filedialog.askdirectory(title="Select Output Folder")
|
|
if path:
|
|
self.output_dir = path
|
|
self.status_var.set(f"Output: {os.path.basename(path)}")
|
|
|
|
def update_preview(self, path):
|
|
try:
|
|
img = Image.open(path).convert("RGBA")
|
|
|
|
# Squared preview
|
|
w, h = img.size
|
|
s = max(w, h)
|
|
square = Image.new("RGBA", (s, s), (0, 0, 0, 0))
|
|
square.paste(img, ((s-w)//2, (s-h)//2))
|
|
|
|
# Constraints
|
|
max_p = 350
|
|
|
|
# Thumbnails using compatible Resampling
|
|
resample = getattr(Image, 'Resampling', Image).LANCZOS
|
|
img.thumbnail((max_p, max_p), resample=resample)
|
|
self.tk_orig = ImageTk.PhotoImage(img)
|
|
self.before_img_lbl.config(image=self.tk_orig, text="")
|
|
|
|
square.thumbnail((max_p, max_p), resample=resample)
|
|
self.tk_after = ImageTk.PhotoImage(square)
|
|
self.after_img_lbl.config(image=self.tk_after, text="")
|
|
|
|
except Exception as e:
|
|
self.status_var.set(f"Preview fail: {e}")
|
|
|
|
def start_processing(self):
|
|
if not self.source_paths: return
|
|
self.is_processing = True
|
|
self.process_btn.config(state="disabled", text="Working...")
|
|
threading.Thread(target=self.run_logic, daemon=True).start()
|
|
|
|
def run_logic(self):
|
|
try:
|
|
size = int(self.size_var.get())
|
|
if not os.path.exists(self.output_dir): os.makedirs(self.output_dir)
|
|
|
|
for i, p in enumerate(self.source_paths):
|
|
self.status_var.set(f"Saving {i+1}/{len(self.source_paths)}...")
|
|
make_square_icon(p, self.output_dir, size)
|
|
|
|
self.root.after(0, lambda: messagebox.showinfo("Done", f"Processed {len(self.source_paths)} icons!"))
|
|
except Exception as e:
|
|
self.root.after(0, lambda: messagebox.showerror("Error", str(e)))
|
|
finally:
|
|
self.root.after(0, self.reset)
|
|
|
|
def reset(self):
|
|
self.is_processing = False
|
|
self.process_btn.config(state="normal", text="Generate Icons")
|
|
self.status_var.set("Ready for more.")
|
|
|
|
def make_square_icon(source_path, output_dir="src-tauri/icons", target_size=1024):
|
|
"""
|
|
Core logic: Takes any image and turns it into a perfectly centered,
|
|
transparent square icon (PNG and multi-res ICO).
|
|
"""
|
|
if not os.path.exists(source_path): return
|
|
|
|
img = Image.open(source_path).convert("RGBA")
|
|
width, height = img.size
|
|
size = max(width, height)
|
|
|
|
square = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
|
offset = ((size - width) // 2, (size - height) // 2)
|
|
square.paste(img, offset)
|
|
|
|
if not os.path.exists(output_dir):
|
|
os.makedirs(output_dir)
|
|
|
|
# Standardize naming
|
|
base = os.path.splitext(os.path.basename(source_path))[0]
|
|
|
|
# 1. Detect Content Bounding Box (Auto-Zoom logic)
|
|
# This ensures small icons are recognizable by removing empty transparent space
|
|
bbox = img.getbbox()
|
|
if bbox:
|
|
# Crop to the actual icon content
|
|
content = img.crop(bbox)
|
|
c_w, c_h = content.size
|
|
# Make content square with padding
|
|
c_size = max(c_w, c_h)
|
|
square = Image.new("RGBA", (c_size, c_size), (0, 0, 0, 0))
|
|
square.paste(content, ((c_size - c_w) // 2, (c_size - c_h) // 2))
|
|
|
|
# 2. Resampling Constants
|
|
resample_high = getattr(Image, 'Resampling', Image).LANCZOS
|
|
|
|
# 3. Save Primary (High-Res) PNG
|
|
icon_png = square.resize((target_size, target_size), resample_high)
|
|
png_path = os.path.join(output_dir, f"{base}_icon.png" if "app-icon" not in base else "icon.png")
|
|
icon_png.save(png_path)
|
|
|
|
# 4. Generate High-Quality Multi-Res for .ico (Manually Sharpened)
|
|
icon_sizes = [256, 128, 64, 48, 32, 16]
|
|
icon_layers = []
|
|
|
|
for s in icon_sizes:
|
|
# For small sizes, apply extra sharpening to ensure legibility
|
|
layer = square.resize((s, s), resample_high)
|
|
if s <= 32:
|
|
# Apply subtle sharpening for small icons
|
|
layer = layer.filter(ImageFilter.SHARPEN)
|
|
# Boost contrast slightly if very small (improves legibility on dark/light bars)
|
|
if s <= 16:
|
|
layer = layer.filter(ImageFilter.EDGE_ENHANCE_MORE)
|
|
icon_layers.append(layer)
|
|
|
|
ico_path = os.path.join(output_dir, f"{base}_icon.ico" if "app-icon" not in base else "icon.ico")
|
|
icon_layers[0].save(ico_path, sizes=[(l.width, l.height) for l in icon_layers], append_images=icon_layers[1:])
|
|
|
|
if __name__ == "__main__":
|
|
if len(sys.argv) > 1:
|
|
# CLI Mode
|
|
make_square_icon(sys.argv[1])
|
|
else:
|
|
# GUI Mode
|
|
root = tk.Tk()
|
|
app = SquareIconGUI(root)
|
|
root.mainloop()
|