Thisper/scripts/square_icon.py
2026-03-29 21:59:48 -05:00

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()