577 lines
21 KiB
Python
577 lines
21 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(config_filename="tool_config_params.json") # 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()
|