Utility_Apps/Svg2PngPdf/Archived/svg_to_png_gui_G-v5-NewTheme.py

894 lines
No EOL
48 KiB
Python

# #############################################
# # SVG to Image Converter (using Inkscape) #
# # Version: 1.4.0 (UI Overhaul) #
# #############################################
#
# Prerequisites:
# - Python 3.6+
# - Inkscape 1.0+ installed and preferably in the system PATH or a standard location.
# - Pillow, tkinterdnd2 libraries (`pip install Pillow tkinterdnd2`)
# - An 'icons' subfolder containing: add_file.png, add_folder.png,
# remove.png, clear.png, start.png, cancel.png
#
# Required libraries:
# pip install Pillow tkinterdnd2
#
# PyInstaller command (example):
# Windows: pyinstaller --onefile --windowed --name SVG_Converter --icon=icon.ico --add-data="path/to/tkinterdnd2;tkinterdnd2" --add-data="icons;icons" svg_converter_app_v1_4.py
# macOS: pyinstaller --onefile --windowed --name SVG_Converter --icon=icon.icns --add-data="path/to/tkinterdnd2:tkinterdnd2" --add-data="icons:icons" svg_converter_app_v1_4.py
# Linux: pyinstaller --onefile --windowed --name SVG_Converter --add-data="path/to/tkinterdnd2:tkinterdnd2" --add-data="icons:icons" svg_converter_app_v1_4.py
#
# #############################################
import tkinter as tk
from tkinter import ttk, filedialog, messagebox, scrolledtext, colorchooser
from tkinterdnd2 import DND_FILES, TkinterDnD
import subprocess
import os
import sys
import shutil
import threading
import queue
import json
from pathlib import Path
from concurrent.futures import ThreadPoolExecutor, as_completed
from PIL import Image, ImageTk # Requires: pip install Pillow
# Removed time import as debounce is no longer needed
# --- Constants ---
APP_NAME = "SVG Converter"
VERSION = "1.4.0"
SETTINGS_FILE = "svg_converter_settings.json"
MAX_CONCURRENT_TASKS = 4
# --- Theme Colors (Based on mockup) ---
BG_COLOR = "#F5F5DC" # Beige/Cream background
FG_COLOR = "#333333" # Dark grey text
BTN_BG_COLOR = "#E0E0E0" # Light grey button background
BTN_FG_COLOR = "#202020" # Dark button text
BTN_ACTIVE_BG = "#C8C8C8" # Slightly darker grey when button pressed
LIST_BG = "#FFFFFF" # White background for listbox
LIST_FG = "#1A1A1A"
LIST_SELECT_BG = "#B0C4DE" # Light Steel Blue for selection
LOG_BG = "#FFFFFF"
LOG_FG = "#1A1A1A"
PROGRESS_TROUGH = "#E0E0E0"
PROGRESS_BAR = "#77DD77" # Pastel green for progress bar
# --- Helper Functions ---
def find_inkscape_executable():
"""Attempts to find the Inkscape executable."""
common_paths = []
names = ["inkscape", "inkscape.exe", "inkscape.com"]
for name in names:
inkscape_path = shutil.which(name)
if inkscape_path: return inkscape_path
if sys.platform == "win32":
pf = os.environ.get("ProgramFiles", "C:\\Program Files")
pf_x86 = os.environ.get("ProgramFiles(x86)", "C:\\Program Files (x86)")
common_paths.extend([ Path(pf) / "Inkscape" / "bin" / "inkscape.exe", Path(pf_x86) / "Inkscape" / "bin" / "inkscape.exe", Path(os.environ.get("LOCALAPPDATA", "")) / "Programs" / "Inkscape" / "bin" / "inkscape.exe", Path(os.environ.get("ProgramW6432", "")) / "Inkscape" / "bin" / "inkscape.exe", ])
elif sys.platform == "darwin": common_paths.extend([ Path("/Applications/Inkscape.app/Contents/MacOS/inkscape"), Path("/usr/local/bin/inkscape"), ])
else: common_paths.extend([ Path("/usr/bin/inkscape"), Path("/usr/local/bin/inkscape"), Path(os.path.expanduser("~/.local/bin/inkscape")), Path("/var/lib/flatpak/exports/bin/org.inkscape.Inkscape"), Path("/snap/bin/inkscape"), ])
for path in common_paths:
try:
if path.is_file(): return str(path)
except OSError: continue
return None
def get_platform_open_command(path):
"""Returns the command to open a folder in the default file explorer."""
path = os.path.abspath(path)
if sys.platform == "win32": return lambda: os.startfile(path)
elif sys.platform == "darwin": return lambda: subprocess.run(["open", path], check=True)
else: return lambda: subprocess.run(["xdg-open", path], check=True)
# Simple Tooltip Class (Modified for background color)
class Tooltip:
def __init__(self, widget, text, bg=None):
self.widget = widget
self.text = text
self.tooltip = None
self.bg = bg if bg else "#FFFFE0" # Default light yellow
self.widget.bind("<Enter>", self.show_tooltip)
self.widget.bind("<Leave>", self.hide_tooltip)
self.widget.bind("<ButtonPress>", self.hide_tooltip)
def show_tooltip(self, event=None):
if self.tooltip: return
x = event.x_root + 15
y = event.y_root + 10
self.tooltip = tk.Toplevel(self.widget)
self.tooltip.wm_overrideredirect(True)
self.tooltip.wm_geometry(f"+{x}+{y}")
label = tk.Label(self.tooltip, text=self.text, background=self.bg, relief="solid", borderwidth=1, justify='left', padx=4, pady=2)
label.pack()
def hide_tooltip(self, event=None):
if self.tooltip: self.tooltip.destroy()
self.tooltip = None
# --- Main Application Class ---
class SVGtoPNGConverter(TkinterDnD.Tk):
def __init__(self):
super().__init__()
self.configure(bg=BG_COLOR) # Set root background
# --- Load Icons ---
self.icons = {}
icon_files = {
"add_file": "add_file.png", "add_folder": "add_folder.png",
"remove": "remove.png", "clear": "clear.png",
"start": "start.png", "cancel": "cancel.png"
}
icons_path = Path(__file__).parent / "icons"
if getattr(sys, 'frozen', False): # Adjust path if running as PyInstaller bundle
icons_path = Path(sys._MEIPASS) / "icons"
for name, filename in icon_files.items():
try:
img = Image.open(icons_path / filename).resize((16, 16), Image.Resampling.LANCZOS) # Resize icons
self.icons[name] = ImageTk.PhotoImage(img)
except FileNotFoundError:
print(f"Warning: Icon file not found: {icons_path / filename}")
self.icons[name] = None # Set to None if not found
except Exception as e:
print(f"Error loading icon {filename}: {e}")
self.icons[name] = None
# --- Style Configuration ---
self.style = ttk.Style(self)
self.style.theme_use('clam') # Use clam as a base
# Configure base style
self.style.configure('.', background=BG_COLOR, foreground=FG_COLOR, fieldbackground=LIST_BG, lightcolor=BG_COLOR, darkcolor=BG_COLOR, bordercolor="#A0A0A0")
self.style.map('.', background=[('active', BG_COLOR)]) # Prevent weird background changes on hover
# Frames and Labels
self.style.configure('TFrame', background=BG_COLOR)
self.style.configure('TLabel', background=BG_COLOR, foreground=FG_COLOR, padding=(5, 2))
self.style.configure('TLabelframe', background=BG_COLOR, bordercolor="#A0A0A0")
self.style.configure('TLabelframe.Label', background=BG_COLOR, foreground=FG_COLOR, padding=(5, 0))
# Buttons
self.style.configure('TButton', background=BTN_BG_COLOR, foreground=BTN_FG_COLOR,
padding=(8, 5), borderwidth=1, relief='raised', font=('Segoe UI', 9)) # Adjusted padding
self.style.map('TButton',
background=[('pressed', BTN_ACTIVE_BG), ('active', BTN_BG_COLOR)], # Active is hover
relief=[('pressed', 'sunken'), ('!pressed', 'raised')])
# Notebook (Tabs)
self.style.configure('TNotebook', background=BG_COLOR, borderwidth=0)
self.style.configure('TNotebook.Tab', background=BTN_BG_COLOR, foreground=BTN_FG_COLOR, padding=(10, 5), borderwidth=1)
self.style.map('TNotebook.Tab',
background=[('selected', BG_COLOR)], # Selected tab matches main bg
foreground=[('selected', FG_COLOR)],
expand=[('selected', [1, 1, 1, 0])]) # Small padding adjust
# Progress Bar
self.style.configure('Horizontal.TProgressbar', troughcolor=PROGRESS_TROUGH, background=PROGRESS_BAR, borderwidth=0, thickness=18)
# Other Widgets (ttk doesn't control everything)
# Listbox and Log Area styling done directly in setup_ui
self.title(f"{APP_NAME} v{VERSION}")
# self.geometry("600x750") # Adjust starting size if needed
# --- Detect Inkscape ---
self.inkscape_path = find_inkscape_executable()
print(f"DEBUG: Using Inkscape found at: {self.inkscape_path or 'Not Found'}")
# Warning shown later if needed, after UI is partially up
# --- Variables ---
self.selected_files = [] # Stores absolute paths
self.output_dir = tk.StringVar(value="<Same as source>")
self.dpi = tk.IntVar(value=150)
self.background = tk.StringVar(value="#ffffff")
self.output_format = tk.StringVar(value="png")
self.export_width = tk.StringVar(value="")
self.export_height = tk.StringVar(value="")
self.jpeg_quality = tk.IntVar(value=90)
self.auto_open_folder = tk.BooleanVar(value=False)
self.concurrent_tasks = tk.IntVar(value=MAX_CONCURRENT_TASKS)
self.conversion_active = False
self.cancel_requested = False
self.failed_files = []
self.log_queue = queue.Queue()
# --- Load Settings ---
self.load_settings()
# --- UI Setup ---
self.setup_ui() # Build the UI elements
# Show Inkscape warning now if necessary
if not self.inkscape_path:
messagebox.showerror("Inkscape Not Found",
"Inkscape executable could not be found.\n\n"
"Please ensure Inkscape 1.0+ is installed and:\n"
" - Added to your system's PATH, OR\n"
" - Installed in a standard location.\n\n"
"Conversion will not work without Inkscape.")
# --- Bindings & Traces ---
self.file_list.bind("<<ListboxSelect>>", self._handle_listbox_select) # Just for status update now
# --- Drag and Drop Setup ---
self.drop_target_register(DND_FILES)
self.dnd_bind('<<Drop>>', self.handle_drop)
# --- Protocol Handlers ---
self.protocol("WM_DELETE_WINDOW", self.on_closing)
# --- Start Log Queue Monitor ---
self.after(100, self.process_log_queue)
# Log Inkscape path
self.log(f"Using Inkscape: {self.inkscape_path or 'Not Found - Conversion Disabled'}", "debug" if self.inkscape_path else "error")
if not self.inkscape_path:
self.log("Please install Inkscape 1.0+ and ensure it's in your PATH.", "error")
def setup_ui(self):
"""Creates and arranges the GUI elements based on the new mockup."""
# Main Frame
self.main_frame = ttk.Frame(self, padding=(15, 10), style='TFrame')
self.main_frame.pack(fill=tk.BOTH, expand=True)
# Configure main grid columns (0 for content, 1 for buttons)
self.main_frame.columnconfigure(0, weight=1)
self.main_frame.columnconfigure(1, weight=0)
# Configure main grid rows (Files, Progress, Settings, Log)
self.main_frame.rowconfigure(0, weight=0) # Files/Buttons row
self.main_frame.rowconfigure(1, weight=0) # Progress row
self.main_frame.rowconfigure(2, weight=1) # Settings row (allow expansion)
self.main_frame.rowconfigure(3, weight=1) # Log row (allow expansion)
# --- Top Row: Files List (Left) ---
file_frame = ttk.LabelFrame(self.main_frame, text="Files", padding=10)
file_frame.grid(row=0, column=0, padx=(0, 10), pady=(0, 10), sticky="nsew")
file_frame.columnconfigure(0, weight=1)
file_frame.rowconfigure(0, weight=1)
self.file_list_scrollbar = ttk.Scrollbar(file_frame, orient=tk.VERTICAL)
self.file_list = tk.Listbox(file_frame, selectmode=tk.EXTENDED, height=8, # Adjust height as needed
yscrollcommand=self.file_list_scrollbar.set,
exportselection=False,
bg=LIST_BG, fg=LIST_FG,
selectbackground=LIST_SELECT_BG,
selectforeground=LIST_FG,
borderwidth=1, relief="sunken",
activestyle='none') # Prevent dotted selection box
self.file_list_scrollbar.config(command=self.file_list.yview)
self.file_list_scrollbar.grid(row=0, column=1, sticky="ns")
self.file_list.grid(row=0, column=0, sticky="nsew")
# --- Top Row: Buttons (Right) ---
button_frame = ttk.Frame(self.main_frame, padding=(0, 0, 0, 10)) # Padding only at bottom
button_frame.grid(row=0, column=1, padx=(0, 0), pady=(0, 10), sticky="nw") # Stick to top-right
# Add/Folder Buttons
btn_add = ttk.Button(button_frame, text=" Add Files", command=self.add_files, image=self.icons.get("add_file"), compound=tk.LEFT)
btn_add.grid(row=0, column=0, sticky="ew", pady=(0, 4))
Tooltip(btn_add, "Add individual SVG files.", bg=BG_COLOR)
btn_add_folder = ttk.Button(button_frame, text=" Add Folder", command=self.add_folder, image=self.icons.get("add_folder"), compound=tk.LEFT)
btn_add_folder.grid(row=1, column=0, sticky="ew", pady=4)
Tooltip(btn_add_folder, "Add all SVG files from a folder (recursive).", bg=BG_COLOR)
# Remove/Clear Buttons
btn_remove = ttk.Button(button_frame, text=" Remove", command=self.remove_selected_files, image=self.icons.get("remove"), compound=tk.LEFT)
btn_remove.grid(row=2, column=0, sticky="ew", pady=4)
Tooltip(btn_remove, "Remove the highlighted file(s) from the list.", bg=BG_COLOR)
btn_clear = ttk.Button(button_frame, text=" Clear All", command=self.clear_list, image=self.icons.get("clear"), compound=tk.LEFT)
btn_clear.grid(row=3, column=0, sticky="ew", pady=4)
Tooltip(btn_clear, "Remove all files from the list.", bg=BG_COLOR)
# Start/Cancel Button
self.convert_button = ttk.Button(button_frame, text=" Start", command=self.start_conversion, image=self.icons.get("start"), compound=tk.LEFT)
self.convert_button.grid(row=4, column=0, sticky="ew", pady=(10, 4)) # More space before Start
Tooltip(self.convert_button, "Start converting the files in the list using current settings, or cancel if running.", bg=BG_COLOR)
self.retry_button = ttk.Button(button_frame, text=" Retry Failed", command=self.retry_failed, state=tk.DISABLED) # No icon for retry for now
self.retry_button.grid(row=5, column=0, sticky="ew", pady=4)
Tooltip(self.retry_button, "Re-attempt conversion only for files that failed in the last run.", bg=BG_COLOR)
# --- Progress Row ---
progress_frame = ttk.Frame(self.main_frame)
progress_frame.grid(row=1, column=0, columnspan=2, pady=(0, 10), sticky="ew")
progress_frame.columnconfigure(1, weight=1) # Make progress bar expand
self.progress_label = ttk.Label(progress_frame, text="Progress:")
self.progress_label.grid(row=0, column=0, padx=(0, 5), sticky='w')
self.progress = ttk.Progressbar(progress_frame, orient='horizontal', length=200, mode='determinate', style='Horizontal.TProgressbar')
self.progress.grid(row=0, column=1, padx=0, sticky="ew")
self.status_label = ttk.Label(progress_frame, text="", width=25, anchor="e") # Wider status
self.status_label.grid(row=0, column=2, padx=(5, 0), sticky='e')
# --- Settings Row ---
settings_notebook = ttk.Notebook(self.main_frame, padding=(0, 5))
settings_notebook.grid(row=2, column=0, columnspan=2, pady=(0, 10), sticky="nsew")
# -- Output Tab --
output_tab = ttk.Frame(settings_notebook, padding=15) # More padding inside tab
settings_notebook.add(output_tab, text=" Output Settings ")
output_tab.columnconfigure(1, weight=1)
ttk.Label(output_tab, text="Format:").grid(row=0, column=0, padx=5, pady=5, sticky="w")
format_menu = ttk.OptionMenu(output_tab, self.output_format, "png", "png", "jpg", "tiff", "pdf", command=self.update_quality_slider_visibility)
format_menu.grid(row=0, column=1, columnspan=2, padx=5, pady=8, sticky="ew")
Tooltip(format_menu, "Choose the output image format.", bg=BG_COLOR)
# JPEG Quality widgets (placed by update_quality_slider_visibility)
self.quality_label = ttk.Label(output_tab, text="JPEG Quality:")
self.quality_scale = ttk.Scale(output_tab, from_=1, to=100, orient=tk.HORIZONTAL, variable=self.jpeg_quality, length=150)
self.quality_value_label = ttk.Label(output_tab, textvariable=self.jpeg_quality, width=4)
ttk.Label(output_tab, text="Output Folder:").grid(row=2, column=0, padx=5, pady=8, sticky="w")
output_entry = ttk.Entry(output_tab, textvariable=self.output_dir, state='readonly', width=30) # Make entry wider
output_entry.grid(row=2, column=1, padx=5, pady=8, sticky="ew")
Tooltip(output_entry, "Directory where converted files will be saved.\n'<Same as source>' saves alongside original SVG.", bg=BG_COLOR)
output_browse_btn = ttk.Button(output_tab, text="Browse...", command=self.choose_output_dir)
output_browse_btn.grid(row=2, column=2, padx=(5,0), pady=8)
Tooltip(output_browse_btn, "Choose the output directory.", bg=BG_COLOR)
tasks_label = ttk.Label(output_tab, text="Parallel Tasks:")
tasks_label.grid(row=3, column=0, padx=5, pady=8, sticky="w")
tasks_spinbox = ttk.Spinbox(output_tab, from_=1, to=os.cpu_count() or 4, increment=1, textvariable=self.concurrent_tasks, width=8)
tasks_spinbox.grid(row=3, column=1, padx=5, pady=8, sticky="w")
Tooltip(tasks_label, "Number of Inkscape processes to run at once (max = CPU cores).\nHigher might be faster but uses more CPU/RAM.", bg=BG_COLOR)
Tooltip(tasks_spinbox, "Number of Inkscape processes to run at once (max = CPU cores).\nHigher might be faster but uses more CPU/RAM.", bg=BG_COLOR)
auto_open_check = ttk.Checkbutton(output_tab, text="Auto-open folder on completion", variable=self.auto_open_folder)
auto_open_check.grid(row=4, column=0, columnspan=3, padx=5, pady=(15, 5), sticky="w") # More padding before
Tooltip(auto_open_check, "Automatically open the output folder in your file explorer after conversion finishes.", bg=BG_COLOR)
# -- Image Tab --
image_tab = ttk.Frame(settings_notebook, padding=15) # More padding inside tab
settings_notebook.add(image_tab, text=" Image Settings ")
image_tab.columnconfigure(1, weight=1)
image_tab.columnconfigure(3, weight=0) # Don't make right column expand by default
image_tab.columnconfigure(4, weight=0)
dpi_label = ttk.Label(image_tab, text="DPI:")
dpi_label.grid(row=0, column=0, padx=5, pady=8, sticky="w")
dpi_spinbox = ttk.Spinbox(image_tab, from_=10, to=1200, increment=10, textvariable=self.dpi, width=8)
dpi_spinbox.grid(row=0, column=1, padx=5, pady=8, sticky="ew")
Tooltip(dpi_label, "Dots Per Inch - affects raster image resolution (PNG, JPG, TIFF).\nHigher DPI = larger file size & better print quality (e.g., 300 for print).", bg=BG_COLOR)
Tooltip(dpi_spinbox, "Dots Per Inch - affects raster image resolution (PNG, JPG, TIFF).\nHigher DPI = larger file size & better print quality (e.g., 300 for print).", bg=BG_COLOR)
bg_label = ttk.Label(image_tab, text="Background:")
bg_label.grid(row=0, column=2, padx=(20, 5), pady=8, sticky="w")
bg_entry = ttk.Entry(image_tab, textvariable=self.background, width=10)
bg_entry.grid(row=0, column=3, padx=5, pady=8, sticky="ew")
bg_color_btn = ttk.Button(image_tab, text="Color...", command=self.choose_color)
bg_color_btn.grid(row=0, column=4, padx=(0, 5), pady=8)
Tooltip(bg_label, "Background color (e.g., #ffffff, transparent, black).\n'transparent' works best for PNG (no quotes needed).", bg=BG_COLOR)
Tooltip(bg_entry, "Background color (e.g., #ffffff, transparent, black).\n'transparent' works best for PNG (no quotes needed).", bg=BG_COLOR)
Tooltip(bg_color_btn, "Choose background color visually.", bg=BG_COLOR)
width_label = ttk.Label(image_tab, text="Width (px):")
width_label.grid(row=1, column=0, padx=5, pady=8, sticky="w")
width_entry = ttk.Entry(image_tab, textvariable=self.export_width, width=8)
width_entry.grid(row=1, column=1, padx=5, pady=8, sticky="ew")
Tooltip(width_label, "Specify output width in pixels (optional).\nOverrides DPI scaling if set. Leave blank for auto.", bg=BG_COLOR)
Tooltip(width_entry, "Specify output width in pixels (optional).\nOverrides DPI scaling if set. Leave blank for auto.", bg=BG_COLOR)
height_label = ttk.Label(image_tab, text="Height (px):")
height_label.grid(row=1, column=2, padx=(20, 5), pady=8, sticky="w")
height_entry = ttk.Entry(image_tab, textvariable=self.export_height, width=8)
height_entry.grid(row=1, column=3, padx=5, pady=8, sticky="ew")
Tooltip(height_label, "Specify output height in pixels (optional).\nOverrides DPI scaling if set. Leave blank for auto.", bg=BG_COLOR)
Tooltip(height_entry, "Specify output height in pixels (optional).\nOverrides DPI scaling if set. Leave blank for auto.", bg=BG_COLOR)
ttk.Label(image_tab, text="(Optional)").grid(row=1, column=4, padx=(0, 5), pady=8, sticky="w")
self.update_quality_slider_visibility() # Set initial visibility
# --- Log Row ---
log_frame = ttk.LabelFrame(self.main_frame, text="Log", padding=10)
log_frame.grid(row=3, column=0, columnspan=2, pady=(0, 0), sticky="nsew")
log_frame.columnconfigure(0, weight=1)
log_frame.rowconfigure(0, weight=1)
self.log_area = scrolledtext.ScrolledText(log_frame, height=6, wrap=tk.WORD, state='disabled',
bg=LOG_BG, fg=LOG_FG, relief="sunken", borderwidth=1)
self.log_area.grid(row=0, column=0, sticky="nsew")
# Configure tags for log levels (foreground colors)
self.log_area.tag_config("info", foreground=LOG_FG) # Use log's default fg
self.log_area.tag_config("success", foreground="green")
self.log_area.tag_config("warning", foreground="#E69900") # Darker Orange
self.log_area.tag_config("error", foreground="red")
self.log_area.tag_config("debug", foreground="grey")
def update_quality_slider_visibility(self, *args):
"""Show JPEG quality slider only when JPEG format is selected."""
output_tab = None
try: # Find Output tab reliably
settings_notebook = self.main_frame.winfo_children()[2] # Assume notebook is 3rd child now (Files, Progress, Notebook, Log)
for tab_id in settings_notebook.tabs():
if settings_notebook.tab(tab_id, "text").strip() == "Output Settings":
output_tab = settings_notebook.nametowidget(tab_id)
break
except IndexError: # If main_frame children structure changes
self.log("UI Error: Could not find settings notebook.", "error")
return
except Exception as e:
self.log(f"UI Error finding Output tab: {e}", "error")
return
if not output_tab: return
if self.output_format.get().lower() == "jpg":
self.quality_label.grid(in_=output_tab, row=1, column=0, padx=5, pady=8, sticky="w")
self.quality_scale.grid(in_=output_tab, row=1, column=1, padx=5, pady=8, sticky="ew")
self.quality_value_label.grid(in_=output_tab, row=1, column=2, padx=(0, 5), pady=8, sticky="w")
Tooltip(self.quality_label, "JPEG compression quality (1-100).\nHigher = better quality, larger file.\nRequires Inkscape 1.0+.", bg=BG_COLOR)
Tooltip(self.quality_scale, "JPEG compression quality (1-100).\nHigher = better quality, larger file.\nRequires Inkscape 1.0+.", bg=BG_COLOR)
else:
self.quality_label.grid_remove()
self.quality_scale.grid_remove()
self.quality_value_label.grid_remove()
def choose_color(self):
"""Open color chooser dialog to select background color."""
initial_color = self.background.get()
if initial_color.lower() == 'transparent': initial_color = '#ffffff'
try:
color_code = colorchooser.askcolor(parent=self, title ="Choose background color", initialcolor=initial_color)
if color_code and color_code[1]: self.background.set(color_code[1])
except Exception as e: # Catch potential Tcl errors or others
self.log(f"Color chooser error: {e}", "warning")
# Fallback without initial color
try:
color_code = colorchooser.askcolor(parent=self, title ="Choose background color")
if color_code and color_code[1]: self.background.set(color_code[1])
except Exception as fallback_e:
self.log(f"Color chooser fallback error: {fallback_e}", "error")
messagebox.showerror("Color Chooser Error", "Could not open color chooser.")
def log(self, message, level="info"):
"""Adds a message to the log queue for thread-safe display."""
self.log_queue.put((message, level))
def process_log_queue(self):
"""Processes messages from the log queue and updates the GUI."""
# Tag configuration done in setup_ui now
while not self.log_queue.empty():
try:
message, level = self.log_queue.get_nowait()
self.log_area.configure(state='normal')
self.log_area.insert(tk.END, f"{message}\n", level)
self.log_area.configure(state='disabled')
self.log_area.see(tk.END)
except queue.Empty: break
except Exception as e: print(f"Log Error: {e}")
self.after(100, self.process_log_queue)
def handle_drop(self, event):
"""Handles files dropped onto the application window."""
# This function remains largely the same, logic is sound
files_to_add = []
try:
raw_files = self.tk.splitlist(event.data)
for item in raw_files:
cleaned_item = item.strip().strip('{}')
if not cleaned_item: continue
path = Path(cleaned_item)
if path.is_file() and path.suffix.lower() == ".svg":
files_to_add.append(str(path.resolve()))
elif path.is_dir():
self.log(f"Scanning dropped folder: {path}", "debug")
for sub_item in path.rglob('*.svg'):
if sub_item.is_file(): files_to_add.append(str(sub_item.resolve()))
if files_to_add: self.update_file_list(files_to_add)
else: self.log("Drop contained no valid SVG files or folders.", "warning")
except Exception as e:
self.log(f"Error processing dropped files: {e}", "error")
messagebox.showerror("Drop Error", f"Could not process dropped files:\n{e}")
def update_file_list(self, new_files):
"""Adds new files to the listbox, avoiding duplicates."""
# Logic remains the same
count = 0
current_files_set = set(self.selected_files)
original_failed = set(self.failed_files)
for file_path in new_files:
abs_path = str(Path(file_path).resolve())
if abs_path not in current_files_set:
self.selected_files.append(abs_path)
display_name = os.path.basename(abs_path)
self.file_list.insert(tk.END, display_name)
index = self.file_list.size() - 1
if abs_path in original_failed: self.file_list.itemconfig(index, fg="red")
current_files_set.add(abs_path)
count += 1
if count > 0:
self.log(f"Added {count} new SVG file(s). Total: {len(self.selected_files)}")
if not self.file_list.curselection() and self.file_list.size() > 0:
first_new_index = self.file_list.size() - count
self.file_list.selection_set(first_new_index)
self.file_list.activate(first_new_index)
self._handle_listbox_select() # Update status bar
self.update_status()
def add_files(self):
"""Opens file dialog to select SVG files."""
# Logic remains the same
files = filedialog.askopenfilenames(title="Select SVG Files", filetypes=[("SVG files", "*.svg"), ("All files", "*.*")])
if files: self.update_file_list(files)
def add_folder(self):
"""Opens directory dialog to select a folder containing SVGs."""
# Logic remains the same
folder = filedialog.askdirectory(title="Select Folder Containing SVGs")
if folder:
self.log(f"Scanning folder: {folder}")
svg_files = [str(p.resolve()) for p in Path(folder).rglob('*.svg') if p.is_file()]
if svg_files: self.update_file_list(svg_files)
else: self.log(f"No SVG files found in {folder}", "warning")
def remove_selected_files(self):
"""Removes the selected files from the list."""
# Logic remains the same
selected_indices = self.file_list.curselection()
if not selected_indices:
messagebox.showwarning("No Selection", "Please select files in the list to remove.")
return
removed_count = 0
paths_to_remove = {self.selected_files[i] for i in selected_indices}
for index in reversed(selected_indices):
try:
del self.selected_files[index]
self.file_list.delete(index)
removed_count += 1
except IndexError: self.log(f"Error removing item at index {index}.", "error")
self.failed_files = [f for f in self.failed_files if f not in paths_to_remove]
if removed_count > 0: self.log(f"Removed {removed_count} file(s).")
self.update_status()
self.retry_button.config(state=tk.NORMAL if self.failed_files else tk.DISABLED)
def clear_list(self):
"""Clears the file list and internal selection."""
# Logic remains the same
self.selected_files.clear()
self.file_list.delete(0, tk.END)
self.failed_files.clear()
self.retry_button.config(state=tk.DISABLED)
self.log("File list cleared.")
self.update_status()
self.progress['value'] = 0
def choose_output_dir(self):
"""Allows user to select an output directory."""
# Logic remains the same
directory = filedialog.askdirectory(title="Select Output Directory")
if directory: self.output_dir.set(directory)
def _handle_listbox_select(self, event=None):
"""Just update the status bar on selection change."""
self.update_status()
# REMOVED: debounced_preview_refresh
# REMOVED: show_preview
def update_status(self, text=""):
"""Updates the status label, typically showing file counts."""
# Logic remains the same
if not text:
total = len(self.selected_files)
failed = len(self.failed_files)
selected_count = len(self.file_list.curselection())
status_parts = []
if total > 0: status_parts.append(f"{total} File{'s' if total != 1 else ''}")
if selected_count > 0 and total > 0 : status_parts.append(f"({selected_count} Selected)")
if failed > 0: status_parts.append(f"[{failed} Failed]")
text = ' '.join(status_parts)
self.status_label.config(text=text)
def set_ui_state(self, active):
"""Enable/disable UI elements during conversion."""
self.conversion_active = active
self.cancel_requested = False
state = tk.DISABLED if active else tk.NORMAL
# List of widgets/frames to toggle state
widgets_to_toggle = []
# Top-right button frame children (Add, Add Folder, Remove, Clear)
# Need to get the button frame reliably. Assuming it's the 2nd child of main_frame.
try:
button_frame = self.main_frame.winfo_children()[1]
widgets_to_toggle.extend([
button_frame.winfo_children()[0], # Add Files
button_frame.winfo_children()[1], # Add Folder
button_frame.winfo_children()[2], # Remove Selected
button_frame.winfo_children()[3], # Clear All
# Skip Convert/Retry buttons here, handled separately
])
except IndexError:
self.log("UI Error: Could not find button frame to set state.", "error")
# Listbox
widgets_to_toggle.append(self.file_list)
# Settings notebook tabs
try:
settings_notebook = self.main_frame.winfo_children()[3] # Files(0), Buttons(1), Progress(2), Notebook(3), Log(4) ? Check indices
for tab_id in settings_notebook.tabs():
tab_frame = settings_notebook.nametowidget(tab_id)
for widget in tab_frame.winfo_children():
if isinstance(widget, (ttk.Button, ttk.Spinbox, ttk.Entry, ttk.OptionMenu, ttk.Scale, ttk.Checkbutton)):
widgets_to_toggle.append(widget)
except IndexError:
self.log("UI Error: Could not find settings notebook to set state.", "error")
# Apply state
for widget in widgets_to_toggle:
try:
if isinstance(widget, ttk.Entry) and widget.cget('state') == 'readonly': pass
else: widget.config(state=state)
except tk.TclError: pass
# Special handling for Convert/Retry buttons
start_icon = self.icons.get("cancel") if active else self.icons.get("start")
self.convert_button.config(
text=" Cancel" if active else " Start",
image=start_icon,
state=tk.NORMAL if not (active and self.cancel_requested) else tk.DISABLED
)
retry_state = tk.NORMAL if not active and self.failed_files else tk.DISABLED
self.retry_button.config(state=retry_state)
# Keep log area scrollable but not editable
self.log_area.configure(state='disabled')
def start_conversion(self):
"""Initiates the conversion process or handles cancellation."""
if self.conversion_active:
if not self.cancel_requested:
self.log("Cancellation requested...", "warning")
self.cancel_requested = True
self.conversion_active = False
self.convert_button.config(text=" Cancelling...", image=self.icons.get("cancel"), state=tk.DISABLED)
return
if not self.selected_files:
messagebox.showwarning("No Files", "Please add SVG files to the list first.")
return
if not self.inkscape_path:
messagebox.showerror("Inkscape Not Found", "Cannot start conversion without Inkscape.")
return
self.log("="*30); self.log(f"Starting conversion for {len(self.selected_files)} file(s)...")
settings_summary = f"Format={self.output_format.get()}, DPI={self.dpi.get()}, BG={self.background.get()}"
if self.output_format.get().lower() == "jpg": settings_summary += f", Quality={self.jpeg_quality.get()}"
dims = [f"W={w}" for w in [self.export_width.get()] if w] + [f"H={h}" for h in [self.export_height.get()] if h]
if dims: settings_summary += f", {' '.join(dims)}px"
self.log(f"Settings: {settings_summary}", "debug")
if self.output_dir.get() != "<Same as source>": self.log(f"Output Dir: {self.output_dir.get()}", "debug")
self.set_ui_state(active=True)
self.progress['value'] = 0; self.progress['maximum'] = len(self.selected_files)
self.failed_files.clear()
for i in range(self.file_list.size()): self.file_list.itemconfig(i, fg="") # Reset colors
self.update_status("Running...")
self.conversion_thread = threading.Thread(target=self.run_conversion_threaded, daemon=True)
self.conversion_thread.start()
def run_conversion_threaded(self):
"""Manages the conversion process using a thread pool."""
# This function remains the same, logic is sound
files_to_process = list(self.selected_files)
total_files = len(files_to_process); success_count = 0; processed_count = 0
local_failed_files = []; final_output_dir = None
max_workers = self.concurrent_tasks.get(); max_workers = max(1, max_workers)
self.log(f"Using up to {max_workers} parallel tasks.", "debug")
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = {executor.submit(self.convert_file, file_path): file_path for file_path in files_to_process}
for future in as_completed(futures):
if self.cancel_requested: continue
file_path = futures[future]; processed_count += 1
try:
output_path = future.result()
if self.cancel_requested: continue
if output_path:
success_count += 1
if not self.cancel_requested:
self.log(f"Success: {os.path.basename(file_path)} -> {os.path.basename(output_path)}", "success")
final_output_dir = os.path.dirname(output_path)
elif not self.cancel_requested and file_path not in local_failed_files:
local_failed_files.append(file_path)
except Exception as exc:
if not self.cancel_requested:
self.log(f"Error processing {os.path.basename(file_path)}: {exc}", "error")
if file_path not in local_failed_files: local_failed_files.append(file_path)
finally:
self.after(0, self.update_progress, processed_count, total_files)
if self.cancel_requested:
self.log("Conversion cancelled by user.", "warning")
for f in futures:
if not f.done(): f.cancel()
self.failed_files = local_failed_files
self.after(0, self.finish_conversion, success_count, total_files, final_output_dir)
def update_progress(self, current, total):
"""Updates the progress bar value."""
if total > 0: self.progress['value'] = current
def finish_conversion(self, success_count, total_files, final_output_dir):
"""Called after all conversion threads complete or are cancelled."""
# This function remains the same, logic is sound
failed_count = len(self.failed_files); processed_count = success_count + failed_count
self.log("="*30)
if self.cancel_requested: self.log(f"Conversion Cancelled. Processed: {processed_count}/{total_files} (S:{success_count}, F:{failed_count})", "warning")
else: self.log(f"Conversion Complete. Success: {success_count}/{total_files}, Failed: {failed_count}")
if self.failed_files:
path_to_index = {path: i for i, path in enumerate(self.selected_files)}
for failed_path in self.failed_files:
list_index = path_to_index.get(failed_path)
if list_index is not None and 0 <= list_index < self.file_list.size():
try: self.file_list.itemconfig(list_index, {'fg': 'red'})
except tk.TclError as e: self.log(f"Error highlighting item at index {list_index}: {e}", "debug")
else: self.log(f"Could not find index for failed file {os.path.basename(failed_path)} to highlight.", "debug")
self.set_ui_state(active=False)
self.update_status()
if self.cancel_requested: messagebox.showwarning("Cancelled", f"Conversion cancelled.\n{processed_count} files processed ({failed_count} failed).")
elif failed_count > 0: messagebox.showwarning("Conversion Issues", f"{failed_count} file(s) failed to convert. Check the log for details.\nFailed files are marked red in the list.")
elif success_count > 0: messagebox.showinfo("Conversion Complete", f"Successfully converted {success_count} file(s).")
else: messagebox.showinfo("Conversion Complete", "No files were converted (or all failed).")
if self.auto_open_folder.get() and final_output_dir and success_count > 0 and not self.cancel_requested:
try:
self.log(f"Opening output folder: {final_output_dir}", "info")
get_platform_open_command(final_output_dir)()
except Exception as e:
self.log(f"Failed to open output folder: {e}", "error")
messagebox.showwarning("Open Folder Error", f"Could not automatically open the output folder:\n{final_output_dir}\nError: {e}")
def convert_file(self, svg_path_str):
"""Converts a single SVG file using Inkscape."""
# This function remains the same, logic is sound
if self.cancel_requested: return None
svg_path = Path(svg_path_str); base_name = svg_path.stem; source_dir = svg_path.parent
chosen_output_dir = self.output_dir.get()
output_dir = source_dir if chosen_output_dir == "<Same as source>" or not chosen_output_dir else Path(chosen_output_dir)
if output_dir != source_dir:
try: output_dir.mkdir(parents=True, exist_ok=True)
except OSError as e: self.log(f"Error creating output dir '{output_dir}': {e}. Saving to source dir instead.", "error"); output_dir = source_dir
fmt = self.output_format.get().lower()
output_filename = output_dir / f"{base_name}.{fmt}"
cmd = [ self.inkscape_path, f"--export-filename={str(output_filename)}", f"--export-type={fmt}", f"--export-dpi={self.dpi.get()}", f"--export-background={self.background.get()}", ]
bg_color_str = self.background.get().lower()
cmd.append("--export-background-opacity=1" if bg_color_str != 'transparent' and bg_color_str else "--export-background-opacity=0")
w = self.export_width.get(); h = self.export_height.get()
if w: cmd.append(f"--export-width={w}")
if h: cmd.append(f"--export-height={h}")
if fmt == "jpg" and 1 <= self.jpeg_quality.get() <= 100: cmd.append(f"--export-jpeg-quality={self.jpeg_quality.get()}")
cmd.append(str(svg_path))
self.log(f"Processing: {svg_path.name} -> {output_filename.name}", "debug")
try:
creationflags = subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0
result = subprocess.run(cmd, capture_output=True, text=True, check=False, encoding='utf-8', creationflags=creationflags, timeout=300)
if self.cancel_requested: return None
if result.returncode == 0 and output_filename.exists() and output_filename.stat().st_size > 0: return str(output_filename)
else:
error_msg = f"Failed: {svg_path.name}. Inkscape RC={result.returncode}."
err_details = result.stderr.strip() if result.stderr else result.stdout.strip()
if err_details:
if "Unknown option --export-jpeg-quality" in err_details: error_msg += " (Error: JPEG quality option failed. Is Inkscape version 1.0+ installed and in PATH?)"
elif fmt == 'tiff' and 'WARNING' in err_details and 'Expecting currStmt' in err_details: error_msg += " (Warning: TIFF export generated warnings.)"
else: error_msg += f" Details: {err_details[:250]}..."
if not output_filename.exists(): error_msg += " (Output file not created)"
elif output_filename.stat().st_size == 0:
error_msg += " (Output file is empty)"
try: output_filename.unlink()
except OSError: pass
self.log(error_msg, "error"); return None
except FileNotFoundError: self.log(f"Inkscape command not found at '{self.inkscape_path}'. Conversion failed.", "error"); self.cancel_requested = True; self.conversion_active = False; return None
except subprocess.TimeoutExpired: self.log(f"Timeout converting {svg_path.name}.", "error"); return None
except Exception as e: self.log(f"Error converting {svg_path.name}: {type(e).__name__} - {e}", "error"); return None
def retry_failed(self):
"""Attempts to reconvert files that failed previously."""
# Logic remains the same
if not self.failed_files: self.log("No failed files to retry.", "info"); return
if self.conversion_active: self.log("Cannot retry while conversion is active.", "warning"); return
self.log(f"Retrying {len(self.failed_files)} failed file(s)...")
files_to_retry = list(self.failed_files); self.selected_files = files_to_retry
self.file_list.delete(0, tk.END)
for f_path in self.selected_files: self.file_list.insert(tk.END, os.path.basename(f_path))
self.retry_button.config(state=tk.DISABLED)
self.update_status(f"{len(files_to_retry)} Files (Retry)")
self.start_conversion()
def load_settings(self):
"""Loads settings from the JSON file."""
# Removed live_preview_enabled setting
try:
settings_path = Path(SETTINGS_FILE)
if settings_path.exists():
with open(settings_path, 'r') as f: settings = json.load(f)
self.dpi.set(settings.get('dpi', 150))
self.background.set(settings.get('background', '#ffffff'))
self.output_format.set(settings.get('output_format', 'png'))
self.output_dir.set(settings.get('output_dir', '<Same as source>'))
self.export_width.set(settings.get('export_width', ''))
self.export_height.set(settings.get('export_height', ''))
self.jpeg_quality.set(settings.get('jpeg_quality', 90))
self.auto_open_folder.set(settings.get('auto_open_folder', False))
self.concurrent_tasks.set(settings.get('concurrent_tasks', MAX_CONCURRENT_TASKS))
self.log("Settings loaded.", "debug")
else: self.log("Settings file not found, using defaults.", "debug")
except json.JSONDecodeError as e: self.log(f"Error decoding settings file ({SETTINGS_FILE}): {e}. Using defaults.", "error")
except Exception as e: self.log(f"Error loading settings: {type(e).__name__} - {e}", "error")
def save_settings(self):
"""Saves current settings to the JSON file."""
# Removed live_preview_enabled setting
settings = {
'dpi': self.dpi.get(), 'background': self.background.get(),
'output_format': self.output_format.get(), 'output_dir': self.output_dir.get(),
'export_width': self.export_width.get(), 'export_height': self.export_height.get(),
'jpeg_quality': self.jpeg_quality.get(), 'auto_open_folder': self.auto_open_folder.get(),
'concurrent_tasks': self.concurrent_tasks.get(),
}
try:
with open(Path(SETTINGS_FILE), 'w') as f: json.dump(settings, f, indent=4)
except Exception as e: self.log(f"Error saving settings: {e}", "error")
def on_closing(self):
"""Handles window close event."""
# Logic remains the same
if self.conversion_active and not self.cancel_requested:
if messagebox.askyesno("Exit Confirmation", "Conversion in progress. Are you sure you want to exit?\nRunning conversions will be cancelled."):
self.log("Exiting: Cancellation requested by closing window.", "warning")
self.cancel_requested = True; self.conversion_active = False
self.after(50, self._perform_close) # Brief delay
else: return
else: self._perform_close()
def _perform_close(self):
"""Saves settings and destroys the window."""
# REMOVED preview timer check
self.save_settings()
self.destroy()
# --- Main Execution ---
if __name__ == "__main__":
try: # DPI awareness for Windows
from ctypes import windll
windll.shcore.SetProcessDpiAwareness(1)
except Exception: pass
# Check for icons folder before starting
icons_exist = (Path(__file__).parent / "icons").is_dir()
if getattr(sys, 'frozen', False): # Check in bundle if frozen
icons_exist = (Path(sys._MEIPASS) / "icons").is_dir()
if not icons_exist:
print("WARNING: 'icons' subfolder not found. Buttons will lack icons.")
# Optionally show a simple Tk message box here if desired, but console warning is less intrusive
# tk.Tk().withdraw() # Hide root window
# messagebox.showwarning("Icons Missing", "'icons' subfolder not found.\nButtons will appear without icons.")
app = SVGtoPNGConverter()
app.mainloop()