# 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( "", 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( "", 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()