SXXXXXXX_ProjectUtility/tools/icon_generator/create_icon_file.py
2025-04-29 10:09:19 +02:00

482 lines
20 KiB
Python

# create_icon_file.py (Refactored for ProjectUtility integration)
import tkinter as tk
from tkinter import filedialog, messagebox, ttk # Added ttk
from PIL import Image, ImageTk
import os
import sys
import argparse # Needed even if tool_utils is used, for initial check
from typing import List, Tuple, Dict, Any, Optional
import logging
# --- Attempt to import tool_utils ---
# Assume tool_utils directory is sibling to this script's directory for simplicity
script_dir = os.path.dirname(os.path.abspath(__file__))
tool_utils_path = os.path.join(os.path.dirname(script_dir), 'ToolUtils') # Adjust if structure differs
# Add tool_utils path to sys.path IF NOT ALREADY THERE
if tool_utils_path not in sys.path:
sys.path.insert(0, tool_utils_path)
try:
from tool_utils.communicator import (
parse_arguments, load_tool_config,
send_progress, send_status, send_log, send_result,
print_error, exit_success
)
TOOL_UTILS_AVAILABLE = True
except ImportError as e:
# Provide a basic print_error if tool_utils is missing for managed mode
def print_error(message: str, exit_code: Optional[int] = 1) -> None:
print(f"ERROR: {message}", file=sys.stderr, flush=True)
if exit_code is not None:
sys.exit(exit_code)
print_error(f"Failed to import tool_utils: {e}. Managed mode requires tool_utils.", exit_code=4)
TOOL_UTILS_AVAILABLE = False # Fallback, though script will likely exit above
# --- Core Conversion Logic ---
def remove_white_background(img: Image.Image) -> Image.Image:
"""Makes white or near-white pixels transparent."""
if img.mode != "RGBA":
img = img.convert("RGBA")
datas = img.getdata()
newData = []
# Threshold for 'white' (adjust if needed)
white_threshold = 240
for item in datas:
# item is (R, G, B, A)
if item[0] > white_threshold and item[1] > white_threshold and item[2] > white_threshold:
# Pixel is white/near-white: make it transparent (keep original alpha if needed?)
# For simplicity, setting alpha to 0 for white.
newData.append((item[0], item[1], item[2], 0)) # Make transparent
else:
newData.append(item) # Keep original pixel
img.putdata(newData)
return img
def parse_sizes(sizes_str: str) -> List[Tuple[int, int]]:
"""Parses comma-separated size string into list of tuples."""
parsed_sizes = []
if not sizes_str or not isinstance(sizes_str, str):
return [] # Return empty if no string provided
parts = sizes_str.split(',')
for part in parts:
part = part.strip()
if part.isdigit():
size_val = int(part)
if size_val > 0:
parsed_sizes.append((size_val, size_val))
else:
raise ValueError(f"Invalid size '{part}': must be a positive integer.")
else:
raise ValueError(f"Invalid size format '{part}': expected integer.")
if not parsed_sizes:
raise ValueError("No valid sizes found in size string.")
return parsed_sizes
def perform_conversion(input_path: str, output_path: str, remove_bg: bool, sizes_list: List[Tuple[int, int]]) -> Dict[str, Any]:
"""
Performs the core PNG to ICO conversion.
Args:
input_path: Path to the source PNG file.
output_path: Path where the ICO file will be saved.
remove_bg: Whether to attempt removing white background.
sizes_list: List of (width, height) tuples for the ICO sizes.
Returns:
A dictionary containing result information (e.g., output file path).
Raises:
FileNotFoundError: If input file doesn't exist.
Exception: For other PIL errors or issues during conversion.
"""
if not os.path.isfile(input_path):
raise FileNotFoundError(f"Input PNG file not found: {input_path}")
if not sizes_list:
raise ValueError("No valid sizes provided for ICO generation.")
img = Image.open(input_path)
# Ensure RGBA for transparency handling
if img.mode != "RGBA":
img = img.convert("RGBA")
# Remove background if requested
if remove_bg:
img = remove_white_background(img) # Use the refactored function
# Save the ICO file with specified sizes
img.save(output_path, format='ICO', sizes=sizes_list)
return {"output_file": output_path, "sizes_generated": sizes_list}
# --- Standalone Tkinter GUI Application ---
class PNGtoICOApp:
def __init__(self, root):
self.root = root
self.root.title("PNG to ICO Converter (Standalone)")
self.root.geometry("600x700")
# self.root.resizable(False, False) # Allow resizing
self.png_path: Optional[str] = None
self.preview_img: Optional[ImageTk.PhotoImage] = None
self.ico_previews: List[ImageTk.PhotoImage] = [] # Keep track to avoid garbage collection
# --- GUI Widgets ---
top_frame = ttk.Frame(root, padding="10")
top_frame.pack(fill=tk.X)
ttk.Label(top_frame, text="Convert PNG to ICO", font=("Segoe UI", 12, "bold")).pack()
select_frame = ttk.Frame(top_frame)
select_frame.pack(fill=tk.X, pady=5)
self.select_button = ttk.Button(select_frame, text="Select PNG Image", command=self.select_image)
self.select_button.pack(side=tk.LEFT, padx=(0, 10))
self.preview_label = ttk.Label(select_frame, text="No image selected.", style="Italic.TLabel") # Using style
self.preview_label.pack(side=tk.LEFT)
self.image_label = ttk.Label(root) # Label to show the 128x128 preview
self.image_label.pack(pady=10)
options_frame = ttk.Frame(root, padding="0 0 0 10")
options_frame.pack(fill=tk.X)
self.remove_bg_var = tk.BooleanVar(value=True)
self.remove_bg_checkbox = ttk.Checkbutton(
options_frame,
text="Remove white/near-white background",
variable=self.remove_bg_var
)
self.remove_bg_checkbox.pack(pady=5, anchor='w')
# Add Sizes input (optional for GUI, uses default if empty)
size_frame = ttk.Frame(options_frame)
size_frame.pack(fill=tk.X, pady=5)
ttk.Label(size_frame, text="Sizes (csv):").pack(side=tk.LEFT, padx=(0, 5))
self.sizes_var = tk.StringVar(value="16,32,48,64,128,256") # Default sizes
self.sizes_entry = ttk.Entry(size_frame, textvariable=self.sizes_var, width=40)
self.sizes_entry.pack(side=tk.LEFT, fill=tk.X, expand=True)
self.convert_button = ttk.Button(root, text="Convert and Save ICO", command=self.run_conversion_gui, state=tk.DISABLED)
self.convert_button.pack(pady=10)
# --- Preview Area ---
preview_container = ttk.LabelFrame(root, text="Generated Size Previews", padding="10")
preview_container.pack(fill="both", expand=True, padx=10, pady=(0, 10))
self.canvas = tk.Canvas(preview_container, borderwidth=0, background="#ffffff") # Set background
self.scroll_y = ttk.Scrollbar(preview_container, orient="vertical", command=self.canvas.yview)
self.scroll_frame = ttk.Frame(self.canvas, style='Preview.TFrame') # Use a style for the frame
self.scroll_frame.bind(
"<Configure>",
lambda e: self.canvas.configure(
scrollregion=self.canvas.bbox("all")
)
)
self.canvas_window = self.canvas.create_window((0, 0), window=self.scroll_frame, anchor="nw")
self.canvas.configure(yscrollcommand=self.scroll_y.set)
# Adjust canvas window width when canvas resizes
self.canvas.bind('<Configure>', lambda e: self.canvas.itemconfig(self.canvas_window, width=e.width))
self.canvas.pack(side="left", fill="both", expand=True)
self.scroll_y.pack(side="right", fill="y")
# --- Styles ---
style = ttk.Style()
style.configure("Italic.TLabel", font=("Segoe UI", 9, "italic"))
style.configure("Preview.TFrame", background="#ffffff") # Match canvas background
def select_image(self):
"""Handles PNG image selection in GUI mode."""
# Use previously selected dir or script dir
initial_dir = os.path.dirname(self.png_path) if self.png_path else script_dir
path = filedialog.askopenfilename(
title="Select PNG Image",
initialdir=initial_dir,
filetypes=[("PNG files", "*.png")]
)
if not path:
return
self.png_path = path
self.preview_label.config(text=os.path.basename(path), style='TLabel') # Reset style
try:
img = Image.open(path)
# Create a 128x128 thumbnail for the main preview
img_thumb = img.copy()
img_thumb.thumbnail((128, 128), Image.LANCZOS)
self.preview_img = ImageTk.PhotoImage(img_thumb)
self.image_label.config(image=self.preview_img)
self.convert_button.config(state=tk.NORMAL)
self.clear_previews() # Clear old previews
except Exception as e:
messagebox.showerror("Error", f"Failed to load or preview image:\n{e}")
self.png_path = None
self.preview_label.config(text="Error loading image.", style="Error.TLabel") # Add Error style if needed
self.image_label.config(image='')
self.convert_button.config(state=tk.DISABLED)
def run_conversion_gui(self):
"""Initiates the conversion process from the GUI."""
if not self.png_path:
messagebox.showwarning("Input Missing", "Please select a PNG image first.")
return
default_name = os.path.splitext(os.path.basename(self.png_path))[0] + ".ico"
output_path = filedialog.asksaveasfilename(
title="Save ICO As",
defaultextension=".ico",
initialfile=default_name,
initialdir=os.path.dirname(self.png_path), # Start in image dir
filetypes=[("ICO files", "*.ico")]
)
if not output_path:
return # User cancelled save dialog
# Get options from GUI
remove_bg_flag = self.remove_bg_var.get()
sizes_str = self.sizes_var.get()
try:
sizes_list = parse_sizes(sizes_str) # Use the parser function
if not sizes_list: # Should be caught by parse_sizes, but double check
raise ValueError("No valid sizes specified.")
# --- Call the core logic function ---
result_info = perform_conversion(
input_path=self.png_path,
output_path=output_path,
remove_bg=remove_bg_flag,
sizes_list=sizes_list
)
base_img = Image.open(self.png_path) # Re-open base image for previews
# Applica la stessa logica di rimozione sfondo per coerenza nelle anteprime
if remove_bg_flag:
base_img = remove_white_background(base_img)
# 2. Mostra le anteprime PRIMA del messaggio di successo
self.show_generated_previews(base_img, result_info['sizes_generated'])
# 3. (Opzionale ma consigliato) Forza l'aggiornamento dell'interfaccia
# per assicurarsi che le anteprime siano visibili prima del popup bloccante
self.root.update_idletasks()
# 4. Mostra il messaggio di successo DOPO aver mostrato le anteprime
messagebox.showinfo("Success", f"ICO file saved successfully:\n{result_info['output_file']}")
except ValueError as ve: # Catch specific errors like invalid sizes
messagebox.showerror("Configuration Error", f"Error in configuration: {ve}")
except FileNotFoundError as fnf:
messagebox.showerror("File Error", f"{fnf}")
except Exception as e:
messagebox.showerror("Conversion Error", f"An error occurred during conversion:\n{e}")
def show_generated_previews(self, base_img: Image.Image, sizes: List[Tuple[int, int]]):
"""Displays previews of generated icon sizes in the scrollable frame."""
self.clear_previews()
self.ico_previews = [] # Clear old PhotoImage refs
columns = 4 # Adjust number of columns for previews
max_preview_dim = 64 # Max dimension for preview image itself
for i, size in enumerate(sizes):
# Create a scaled preview, but don't make it huge
preview_size = (min(size[0], max_preview_dim), min(size[1], max_preview_dim))
preview_pil = base_img.copy()
preview_pil.thumbnail(preview_size, Image.LANCZOS) # Use thumbnail for better quality scaling down
preview_img = ImageTk.PhotoImage(preview_pil)
self.ico_previews.append(preview_img) # Store reference!
# Frame for each preview item
frame = ttk.Frame(self.scroll_frame, padding=5, style='Preview.TFrame')
frame.grid(row=(i // columns), column=i % columns, padx=5, pady=5, sticky='n')
# Use tooltips (simple version via label text, better libraries exist)
# Or just rely on the size text
img_label = ttk.Label(frame, image=preview_img, style='Preview.TLabel') # Use styled label
img_label.pack()
size_label = ttk.Label(frame, text=f"{size[0]}x{size[1]}", style='Preview.TLabel')
size_label.pack()
# Update canvas scroll region after adding all widgets
self.scroll_frame.update_idletasks()
self.canvas.configure(scrollregion=self.canvas.bbox("all"))
def clear_previews(self):
"""Clears the preview area."""
for widget in self.scroll_frame.winfo_children():
widget.destroy()
self.ico_previews.clear()
# --- Main Execution Logic ---
def run_managed_mode():
"""Runs the tool in non-interactive mode, controlled by ProjectUtility."""
if not TOOL_UTILS_AVAILABLE:
# Error already printed during import attempt
sys.exit(4) # Exit if utils couldn't be loaded
send_status("Icon Generator tool started (managed mode).")
# 1. Load configuration to get parameter definitions
config = load_tool_config() # Assumes tool_config.json is in the same dir
if config is None:
# Error message already printed by load_tool_config to stderr
sys.exit(3) # Specific exit code for config load failure
tool_params_def = config.get("parameters", [])
# 2. Parse arguments using tool_utils based on the definition
try:
args = parse_arguments(tool_params_def)
except Exception as e:
# parse_arguments might exit, but catch errors just in case
print_error(f"Critical error parsing arguments: {e}", exit_code=2)
sys.exit(2) # Ensure exit
# Extract arguments (use defaults defined in config if not provided/required)
input_file = args.input_png
output_file = args.output_ico
remove_bg_flag = args.remove_bg # Will be True if --remove-bg passed, else False
sizes_str = args.sizes # Default value from config used by argparse if not passed
# 3. Perform Core Logic
try:
send_status(f"Input PNG: {input_file}")
send_status(f"Output ICO: {output_file}")
send_status(f"Remove Background: {remove_bg_flag}")
send_status(f"Sizes String: '{sizes_str}'")
# Parse sizes string here
sizes_list = parse_sizes(sizes_str)
send_status(f"Parsed sizes: {sizes_list}")
send_progress(0.1, "Starting conversion...") # Initial progress
# Call the core conversion function
result_info = perform_conversion(
input_path=input_file,
output_path=output_file,
remove_bg=remove_bg_flag,
sizes_list=sizes_list
)
send_progress(1.0, "Conversion finished.") # Final progress
# 4. Send results
send_result(result_info)
# 5. Exit successfully
exit_success(f"ICO file generated successfully at {result_info['output_file']}")
except FileNotFoundError as fnf:
print_error(f"File error: {fnf}", exit_code=3) # Specific code for file not found
except ValueError as ve:
print_error(f"Configuration or Value Error: {ve}", exit_code=2) # Specific code for bad input/config
except ImportError as imp_err:
# Catch potential Pillow import error if not installed
print_error(f"Import Error: {imp_err}. Is Pillow installed (`pip install Pillow`)?", exit_code=6)
except Exception as e:
# Catch unexpected errors during conversion
# Log the full traceback using print_error's logger is helpful
import traceback
tb_str = traceback.format_exc()
print_error(f"An unexpected error occurred during conversion: {e}\nTraceback:\n{tb_str}", exit_code=1)
def run_standalone_mode():
"""Runs the tool as a standalone Tkinter GUI application."""
try:
root = tk.Tk()
# Apply themed widgets if available
try:
style = ttk.Style(root)
# Examples: 'clam', 'alt', 'default', 'classic' (depends on OS)
available_themes = style.theme_names()
# Prefer modern themes if available
for theme in ['clam', 'alt', 'default']:
if theme in available_themes:
style.theme_use(theme)
break
except tk.TclError:
print("INFO: ttk themes not fully available.", file=sys.stderr) # Inform user
app = PNGtoICOApp(root)
root.mainloop()
except ImportError as e:
# Catch Pillow import error specifically for GUI mode too
message = f"ERROR: Required library Pillow (PIL) is not installed.\nPlease install it using:\n pip install Pillow\n\nDetails: {e}"
# Try showing a simple Tk message box if possible, otherwise print
try:
root = tk.Tk()
root.withdraw() # Hide main window
messagebox.showerror("Missing Dependency", message)
root.destroy()
except tk.TclError:
print(message, file=sys.stderr)
sys.exit(6) # Exit code for dependency error
except Exception as e:
# Catch other potential GUI startup errors
message = f"ERROR: Failed to start the GUI application.\nDetails: {e}"
try:
root = tk.Tk(); root.withdraw(); messagebox.showerror("Startup Error", message); root.destroy()
except tk.TclError:
print(message, file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
# --- Determine run mode ---
# Simple check: If arguments exist beyond the script name itself,
# assume it's an attempt to run in managed mode via command line.
# Standalone GUI execution typically won't have extra arguments.
# This avoids complex (and potentially faulty) pre-parsing.
is_managed_run_attempt = len(sys.argv) > 1
if is_managed_run_attempt:
# Configure minimal logging just in case tool_utils failed import,
# otherwise tool_utils or the tool itself should handle logging.
try:
if not TOOL_UTILS_AVAILABLE:
logging.basicConfig(level=logging.WARNING, format='%(name)s - %(levelname)s - %(message)s', stream=sys.stderr)
logger = logging.getLogger(__name__) # Get logger for this script
logger.info("Command-line arguments detected. Attempting to run in managed mode.")
except Exception: pass # Ignore logging setup errors here
# Execute the managed mode logic
run_managed_mode()
else:
# Configure minimal logging for GUI mode if needed
try:
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
logger.info("No command-line arguments detected. Attempting to run in standalone GUI mode.")
except Exception: pass
# Execute the standalone GUI logic
run_standalone_mode()
if is_managed_run_attempt:
run_managed_mode()
else:
run_standalone_mode()