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

1142 lines
No EOL
55 KiB
Python

# #############################################
# # SVG to Image Converter (using Inkscape) #
# # Version: 1.2.0 (G-v3-NewTheme derived) #
# #############################################
#
# Prerequisites:
# - Python 3.6+
# - Inkscape installed and preferably in the system PATH or a standard location.
# - azure.tcl theme file (e.g., from https://github.com/rdbende/Azure-ttk-theme)
# in the same directory as the script.
#
# Required libraries (for development/running from source):
# pip install Pillow tkinterdnd2
#
# To create a standalone executable (after installing PyInstaller: pip install pyinstaller):
# Windows: pyinstaller --onefile --windowed --name SVG_Converter --icon=icon.ico --add-data="path/to/tkinterdnd2;tkinterdnd2" --add-data="azure.tcl;." svg_to_png_gui_G-v3-NewTheme.py
# macOS: pyinstaller --onefile --windowed --name SVG_Converter --icon=icon.icns --add-data="path/to/tkinterdnd2:tkinterdnd2" --add-data="azure.tcl:." svg_to_png_gui_G-v3-NewTheme.py
# Linux: pyinstaller --onefile --windowed --name SVG_Converter --add-data="path/to/tkinterdnd2:tkinterdnd2" --add-data="azure.tcl:." svg_to_png_gui_G-v3-NewTheme.py
#
# Notes on PyInstaller:
# - Replace 'path/to/tkinterdnd2' with the actual path.
# - Add `--add-data="azure.tcl;."` (Win) or `--add-data="azure.tcl:."` (Mac/Linux)
# to include the theme file in the executable's root.
#
# #############################################
import tkinter as tk
from tkinter import ttk, filedialog, messagebox, scrolledtext, colorchooser
from tkinterdnd2 import DND_FILES, TkinterDnD # Requires: pip install tkinterdnd2
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
import time # For debounce
# --- Constants ---
APP_NAME = "SVG Converter"
VERSION = "1.2.0"
SETTINGS_FILE = "svg_converter_settings.json"
MAX_CONCURRENT_TASKS = 4 # Default max parallel Inkscape processes
PREVIEW_DEBOUNCE_MS = 650 # Delay for auto-preview refresh
# --- Helper Functions ---
def find_inkscape_executable():
"""Attempts to find the Inkscape executable."""
common_paths = []
names = ["inkscape", "inkscape.exe", "inkscape.com"] # .com for older windows versions
# 1. Check PATH
for name in names:
inkscape_path = shutil.which(name)
if inkscape_path:
return inkscape_path
# 2. Check common installation directories
if sys.platform == "win32":
program_files = os.environ.get("ProgramFiles", "C:\\Program Files")
program_files_x86 = os.environ.get("ProgramFiles(x86)", "C:\\Program Files (x86)")
common_paths.extend([
Path(program_files) / "Inkscape" / "bin" / "inkscape.exe",
Path(program_files_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": # macOS
common_paths.extend([
Path("/Applications/Inkscape.app/Contents/MacOS/inkscape"),
Path("/usr/local/bin/inkscape"),
])
else: # Linux
common_paths.extend([
Path("/usr/bin/inkscape"),
Path("/usr/local/bin/inkscape"),
Path(os.path.expanduser("~/.local/bin/inkscape")),
# Add Flatpak/Snap paths if necessary
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: # Handle potential permission errors or invalid paths
continue
return None # Not found
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: # Linux and other POSIX
return lambda: subprocess.run(["xdg-open", path], check=True)
# Simple Tooltip Class
class Tooltip:
def __init__(self, widget, text):
self.widget = widget
self.text = text
self.tooltip = None
self.widget.bind("<Enter>", self.show_tooltip)
self.widget.bind("<Leave>", self.hide_tooltip)
def show_tooltip(self, event=None):
x, y, _, _ = self.widget.bbox("insert")
x += self.widget.winfo_rootx() + 25
y += self.widget.winfo_rooty() + 25
self.tooltip = tk.Toplevel(self.widget)
self.tooltip.wm_overrideredirect(True) # No window decorations
self.tooltip.wm_geometry(f"+{x}+{y}")
label = ttk.Label(self.tooltip, text=self.text, background="#FFFFE0", relief="solid", borderwidth=1, padding=3)
label.pack()
def hide_tooltip(self, event=None):
if self.tooltip:
self.tooltip.destroy()
self.tooltip = None
# --- Main Application Class ---
class SVGtoPNGConverter(TkinterDnD.Tk): # Inherit from TkinterDnD.Tk for drag & drop
def __init__(self):
super().__init__()
# --- Theme Setup ---
self.current_theme = tk.StringVar(value="dark") # Default to dark
try:
# Assumes azure.tcl is in the same directory or PyInstaller bundle root
theme_path = Path(__file__).parent / "azure.tcl"
if getattr(sys, 'frozen', False): # If running as PyInstaller bundle
theme_path = Path(sys._MEIPASS) / "azure.tcl"
if theme_path.exists():
self.tk.call("source", str(theme_path))
self.tk.call("set_theme", self.current_theme.get())
else:
print("Warning: azure.tcl not found. Using default theme.")
# Fallback to clam if azure not found
style = ttk.Style()
if 'clam' in style.theme_names():
style.theme_use('clam')
except tk.TclError as e:
print(f"Error loading theme: {e}. Using default theme.")
except Exception as e:
print(f"Unexpected error during theme setup: {e}")
self.title(f"{APP_NAME} v{VERSION}")
# self.geometry("850x700") # Adjust size as needed
# --- Detect Inkscape ---
self.inkscape_path = find_inkscape_executable()
if not self.inkscape_path:
messagebox.showerror("Inkscape Not Found",
"Inkscape executable could not be found.\n\n"
"Please ensure Inkscape 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.")
# Application will still run, but conversion will fail.
# --- Variables ---
self.selected_files = []
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 # Flag for cancellation
self.failed_files = []
self.log_queue = queue.Queue()
self.preview_image = None # Reference to PhotoImage for canvas
self.preview_timer = None # For debouncing preview refresh
# --- Load Settings ---
self.load_settings()
# --- UI Setup ---
self.setup_ui()
# --- Bindings & Traces ---
self.file_list.bind("<<ListboxSelect>>", self.show_preview)
self.dpi.trace_add("write", lambda *_: self.debounced_preview_refresh())
self.background.trace_add("write", lambda *_: self.debounced_preview_refresh())
self.export_width.trace_add("write", lambda *_: self.debounced_preview_refresh())
self.export_height.trace_add("write", lambda *_: self.debounced_preview_refresh())
# --- 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 on startup
self.log(f"Using Inkscape: {self.inkscape_path or 'Not Found'}", "debug")
def toggle_theme(self):
"""Switches between light and dark themes."""
new_theme = "light" if self.current_theme.get() == "dark" else "dark"
try:
self.tk.call("set_theme", new_theme)
self.current_theme.set(new_theme)
# Update canvas background for preview if needed (theme might handle it)
bg_color = "gray85" if new_theme == "light" else "gray20"
self.preview_canvas.config(bg=bg_color)
self.log(f"Theme changed to {new_theme}", "debug")
except tk.TclError:
self.log("Could not switch theme (is azure.tcl loaded correctly?)", "warning")
except Exception as e:
self.log(f"Error switching theme: {e}", "error")
def setup_ui(self):
"""Creates and arranges the GUI elements with the new layout."""
# Main container frame
self.main_frame = ttk.Frame(self, padding=(15, 10))
self.main_frame.pack(fill=tk.BOTH, expand=True)
self.main_frame.columnconfigure(0, weight=1) # Column for file/settings/log
self.main_frame.columnconfigure(1, weight=0) # Column for preview
self.main_frame.rowconfigure(2, weight=1) # Let the log area expand vertically
# --- Left Column ---
# Top Frame: File Selection
file_frame = ttk.LabelFrame(self.main_frame, text="Input SVG Files", padding=10)
file_frame.grid(row=0, column=0, padx=(0, 10), pady=(0, 10), sticky="ew")
file_frame.columnconfigure(0, weight=1) # Make listbox frame expand
btn_frame = ttk.Frame(file_frame)
btn_frame.grid(row=0, column=0, columnspan=2, pady=(0, 5), sticky="w") # Span columns if needed
ttk.Button(btn_frame, text="Add Files", command=self.add_files).pack(side=tk.LEFT, padx=(0, 5))
ttk.Button(btn_frame, text="Add Folder", command=self.add_folder).pack(side=tk.LEFT, padx=5)
ttk.Button(btn_frame, text="Clear List", command=self.clear_list).pack(side=tk.LEFT, padx=5)
ttk.Button(btn_frame, text="Toggle Theme", command=self.toggle_theme).pack(side=tk.LEFT, padx=(15,5)) # Theme Toggle
list_frame = ttk.Frame(file_frame)
list_frame.grid(row=1, column=0, sticky="nsew")
list_frame.columnconfigure(0, weight=1)
list_frame.rowconfigure(0, weight=1)
self.file_list_scrollbar = ttk.Scrollbar(list_frame, orient=tk.VERTICAL)
self.file_list = tk.Listbox(list_frame, selectmode=tk.EXTENDED, height=10,
yscrollcommand=self.file_list_scrollbar.set)
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")
# Middle Frame: Settings Tabs
settings_notebook = ttk.Notebook(self.main_frame, padding=(0, 5))
settings_notebook.grid(row=1, column=0, padx=(0, 10), pady=(0, 10), sticky="ew")
# -- Output Tab --
output_tab = ttk.Frame(settings_notebook, padding=10)
settings_notebook.add(output_tab, text=" Output Settings ")
output_tab.columnconfigure(1, weight=1) # Allow entry fields to expand
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) # webp ?
format_menu.grid(row=0, column=1, columnspan=2, padx=5, pady=5, sticky="ew")
# JPEG Quality (conditionally shown)
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)
# Grid placement is handled by update_quality_slider_visibility
ttk.Label(output_tab, text="Output Folder:").grid(row=2, column=0, padx=5, pady=5, sticky="w")
output_entry = ttk.Entry(output_tab, textvariable=self.output_dir, state='readonly')
output_entry.grid(row=2, column=1, padx=5, pady=5, sticky="ew")
ttk.Button(output_tab, text="Browse...", command=self.choose_output_dir).grid(row=2, column=2, padx=5, pady=5)
tasks_label = ttk.Label(output_tab, text="Parallel Tasks:")
tasks_label.grid(row=3, column=0, padx=5, pady=5, 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=5, sticky="w") # Align left
Tooltip(tasks_label, "Number of Inkscape processes to run at once.\nHigher might be faster but uses more CPU/RAM.")
Tooltip(tasks_spinbox, "Number of Inkscape processes to run at once.\nHigher might be faster but uses more CPU/RAM.")
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=10, sticky="w")
# -- Image Tab --
image_tab = ttk.Frame(settings_notebook, padding=10)
settings_notebook.add(image_tab, text=" Image Settings ")
image_tab.columnconfigure(1, weight=1)
image_tab.columnconfigure(3, weight=1)
dpi_label = ttk.Label(image_tab, text="DPI:")
dpi_label.grid(row=0, column=0, padx=5, pady=5, 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=5, sticky="ew")
Tooltip(dpi_label, "Dots Per Inch - affects raster image resolution (PNG, JPG, TIFF).\nHigher DPI = larger file size & better print quality.")
Tooltip(dpi_spinbox, "Dots Per Inch - affects raster image resolution (PNG, JPG, TIFF).\nHigher DPI = larger file size & better print quality.")
bg_label = ttk.Label(image_tab, text="Background:")
bg_label.grid(row=0, column=2, padx=(20, 5), pady=5, sticky="w")
bg_entry = ttk.Entry(image_tab, textvariable=self.background, width=10)
bg_entry.grid(row=0, column=3, padx=5, pady=5, sticky="ew")
ttk.Button(image_tab, text="Color...", command=self.choose_color).grid(row=0, column=4, padx=(0, 5), pady=5)
Tooltip(bg_label, "Background color (e.g., #ffffff, transparent, black).\n'transparent' works best for PNG.")
Tooltip(bg_entry, "Background color (e.g., #ffffff, transparent, black).\n'transparent' works best for PNG.")
width_label = ttk.Label(image_tab, text="Width (px):")
width_label.grid(row=1, column=0, padx=5, pady=5, sticky="w")
width_entry = ttk.Entry(image_tab, textvariable=self.export_width, width=8)
width_entry.grid(row=1, column=1, padx=5, pady=5, sticky="ew")
Tooltip(width_label, "Specify output width in pixels (optional).\nOverrides DPI scaling if set.")
Tooltip(width_entry, "Specify output width in pixels (optional).\nOverrides DPI scaling if set.")
height_label = ttk.Label(image_tab, text="Height (px):")
height_label.grid(row=1, column=2, padx=(20, 5), pady=5, sticky="w")
height_entry = ttk.Entry(image_tab, textvariable=self.export_height, width=8)
height_entry.grid(row=1, column=3, padx=5, pady=5, sticky="ew")
Tooltip(height_label, "Specify output height in pixels (optional).\nOverrides DPI scaling if set.")
Tooltip(height_entry, "Specify output height in pixels (optional).\nOverrides DPI scaling if set.")
ttk.Label(image_tab, text="(Optional)").grid(row=1, column=4, padx=(0, 5), pady=5, sticky="w")
# Set initial visibility of JPEG slider
self.update_quality_slider_visibility()
# Bottom Frame: Log and Controls
log_control_frame = ttk.Frame(self.main_frame)
log_control_frame.grid(row=2, column=0, padx=(0, 10), pady=(10, 0), sticky="nsew")
log_control_frame.rowconfigure(1, weight=1) # Let log expand
log_control_frame.columnconfigure(0, weight=1) # Let progress bar expand
control_frame = ttk.Frame(log_control_frame)
control_frame.grid(row=0, column=0, sticky="ew", pady=(0,5))
control_frame.columnconfigure(2, weight=1) # Make progress bar expand
self.convert_button = ttk.Button(control_frame, text="Start Conversion", command=self.start_conversion)
self.convert_button.grid(row=0, column=0, padx=(0, 5))
self.retry_button = ttk.Button(control_frame, text="Retry Failed", command=self.retry_failed, state=tk.DISABLED)
self.retry_button.grid(row=0, column=1, padx=5)
self.progress_label = ttk.Label(control_frame, text="Progress:")
self.progress_label.grid(row=0, column=2, padx=(10, 5), sticky='e')
self.progress = ttk.Progressbar(control_frame, orient='horizontal', length=200, mode='determinate')
self.progress.grid(row=0, column=3, padx=0, sticky="ew")
self.status_label = ttk.Label(control_frame, text="", width=18, anchor="e") # Wider status
self.status_label.grid(row=0, column=4, padx=(5, 0), sticky='e')
log_frame = ttk.LabelFrame(log_control_frame, text="Log", padding=5)
log_frame.grid(row=1, column=0, sticky="nsew")
log_frame.columnconfigure(0, weight=1)
log_frame.rowconfigure(0, weight=1)
self.log_area = scrolledtext.ScrolledText(log_frame, height=8, wrap=tk.WORD, state='disabled')
self.log_area.grid(row=0, column=0, sticky="nsew")
# Configure tags for log levels
self.log_area.tag_config("info", foreground="gray20") # Default text color varies with theme
self.log_area.tag_config("success", foreground="green")
self.log_area.tag_config("warning", foreground="orange")
self.log_area.tag_config("error", foreground="red")
self.log_area.tag_config("debug", foreground="grey")
# --- Right Column: Preview ---
preview_frame = ttk.LabelFrame(self.main_frame, text="Preview", padding=10)
preview_frame.grid(row=0, column=1, rowspan=3, padx=5, pady=0, sticky="nsew") # Span all rows
preview_frame.columnconfigure(0, weight=1)
preview_frame.rowconfigure(0, weight=1) # Let canvas expand somewhat
canvas_bg = "gray20" if self.current_theme.get() == "dark" else "gray85"
self.preview_canvas = tk.Canvas(preview_frame, width=150, height=150, bg=canvas_bg, relief="sunken", bd=1)
self.preview_canvas.grid(row=0, column=0, pady=(0, 5), sticky="nsew")
# Preview Controls Frame
preview_controls = ttk.Frame(preview_frame)
preview_controls.grid(row=1, column=0, sticky="ew")
preview_controls.columnconfigure(0, weight=1) # Center button
self.refresh_button = ttk.Button(preview_controls, text="Refresh Preview", command=lambda: self.show_preview(force_refresh=True))
self.refresh_button.grid(row=0, column=0, pady=5)
def update_quality_slider_visibility(self, *args):
"""Show JPEG quality slider only when JPEG format is selected."""
# Assumes quality slider is in the "Output" tab (output_tab)
output_tab = self.main_frame.winfo_children()[1].winfo_children()[0] # Fragile way to get tab frame
if self.output_format.get().lower() == "jpg":
self.quality_label.grid(row=1, column=0, padx=5, pady=5, sticky="w")
self.quality_scale.grid(row=1, column=1, padx=5, pady=5, sticky="ew")
self.quality_value_label.grid(row=1, column=2, padx=(0, 5), pady=5, sticky="w")
Tooltip(self.quality_label, "JPEG compression quality (1-100).\nHigher = better quality, larger file.")
Tooltip(self.quality_scale, "JPEG compression quality (1-100).\nHigher = better quality, larger file.")
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()
# Handle 'transparent' case for color chooser
if initial_color.lower() == 'transparent':
initial_color = '#ffffff' # Default to white if 'transparent'
try:
color_code = colorchooser.askcolor(title ="Choose background color", initialcolor=initial_color)
if color_code and color_code[1]: # Check if a color was selected (returns tuple: (rgb, hex))
self.background.set(color_code[1])
except tk.TclError:
# May happen if the initial color string is invalid
color_code = colorchooser.askcolor(title ="Choose background color")
if color_code and color_code[1]:
self.background.set(color_code[1])
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."""
theme_fg = "white" if self.current_theme.get() == "dark" else "black"
self.log_area.tag_config("info", foreground=theme_fg) # Adjust default color based on theme
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) # Auto-scroll
except queue.Empty:
break
except Exception as e:
print(f"Log Error: {e}") # Basic fallback
self.after(100, self.process_log_queue) # Reschedule
def handle_drop(self, event):
"""Handles files dropped onto the application window."""
files_to_add = []
try:
raw_files = self.tk.splitlist(event.data)
for item in raw_files:
# Clean path: remove braces, strip whitespace
cleaned_item = item.strip().strip('{}')
if not cleaned_item: continue # Skip empty items
path = Path(cleaned_item)
if path.is_file() and path.suffix.lower() == ".svg":
files_to_add.append(str(path.resolve())) # Use resolved path
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."""
count = 0
current_files_set = set(self.selected_files)
original_failed = set(self.failed_files) # Remember which files were previously marked failed
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)
# Check if this newly added file was previously failed and highlight it
if abs_path in original_failed:
index = self.file_list.size() - 1
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)}")
self.update_status()
def add_files(self):
"""Opens file dialog to select SVG files."""
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."""
folder = filedialog.askdirectory(title="Select Folder Containing SVGs")
if folder:
self.log(f"Scanning folder: {folder}")
svg_files = [str(p) 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 clear_list(self):
"""Clears the file list and internal selection."""
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
self.preview_canvas.delete("all")
self.preview_image = None # Clear image ref
def choose_output_dir(self):
"""Allows user to select an output directory."""
directory = filedialog.askdirectory(title="Select Output Directory")
if directory:
self.output_dir.set(directory)
# No 'else' needed, if cancelled, keep current value
def debounced_preview_refresh(self):
"""Cancels existing timer and schedules a new preview refresh."""
if self.preview_timer:
self.preview_timer.cancel()
self.preview_timer = threading.Timer(PREVIEW_DEBOUNCE_MS / 1000.0, lambda: self.after(0, self.show_preview))
self.preview_timer.daemon = True # Allow app to exit even if timer is pending
self.preview_timer.start()
def show_preview(self, event=None, force_refresh=False):
"""
Attempts to show a small preview of the selected SVG,
using current settings for dimensions/DPI/background.
Use force_refresh=True to bypass selection check (e.g., for refresh button).
"""
selection_indices = self.file_list.curselection()
if not selection_indices and not force_refresh:
return
if not self.selected_files:
self.preview_canvas.delete("all")
self.preview_image = None
return
# If forcing refresh, use the last selected index or the first item
if force_refresh and not selection_indices and self.file_list.size() > 0:
selected_index = self.file_list.size() - 1 # Default to last item? Or 0?
self.file_list.selection_set(selected_index)
self.file_list.activate(selected_index)
self.file_list.see(selected_index)
elif selection_indices:
selected_index = selection_indices[0] # Preview only the first selected item
else:
return # Nothing to preview
self.preview_canvas.delete("all") # Clear previous preview
self.preview_image = None # Clear reference
try:
svg_path = self.selected_files[selected_index]
filename = os.path.basename(svg_path)
if not self.inkscape_path:
self.preview_canvas.create_text(75, 75, text="Inkscape\nNot Found", fill="red", width=140, anchor="center", justify="center")
return
# Create text placeholder while generating
self.preview_canvas.create_text(75, 75, text=f"Loading\n{filename}...", fill="grey", width=140, anchor="center", justify="center")
self.update() # Force redraw of placeholder text
# Generate a temporary PNG preview using Inkscape with current settings
temp_png_path = Path(os.path.expanduser("~")) / f".svg_converter_preview_{os.getpid()}.png"
cmd = [
self.inkscape_path,
f"--export-filename={temp_png_path}",
"--export-type=png",
# Use current settings for preview
f"--export-dpi={self.dpi.get()}",
f"--export-background={self.background.get()}",
]
# Add background opacity only if color is not 'transparent'
if self.background.get().lower() != 'transparent':
cmd.append("--export-background-opacity=1")
else:
cmd.append("--export-background-opacity=0")
# Use current dimensions if provided, otherwise Inkscape uses SVG size + DPI
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}")
cmd.append(svg_path)
try:
# Use timeout to prevent hanging on complex SVGs for preview
result = subprocess.run(cmd, capture_output=True, text=True, check=False, timeout=10, encoding='utf-8')
self.preview_canvas.delete("all") # Clear placeholder text
if result.returncode == 0 and temp_png_path.exists() and temp_png_path.stat().st_size > 0:
img = Image.open(temp_png_path)
# Resize to fit canvas while maintaining aspect ratio
img.thumbnail((150, 150), Image.Resampling.LANCZOS)
self.preview_image = ImageTk.PhotoImage(img)
self.preview_canvas.create_image(75, 75, image=self.preview_image, anchor="center")
else:
self.preview_canvas.create_text(75, 75, text="Preview\nFailed", fill="orange", width=140, anchor="center", justify="center")
err_log = result.stderr.strip()[:100] if result.stderr else result.stdout.strip()[:100]
self.log(f"Preview failed for {filename}: Inkscape RC={result.returncode}. {err_log}...", "warning")
except subprocess.TimeoutExpired:
self.preview_canvas.delete("all")
self.preview_canvas.create_text(75, 75, text="Preview\nTimeout", fill="orange", width=140, anchor="center", justify="center")
self.log(f"Preview timed out for {filename}", "warning")
except Exception as e:
self.preview_canvas.delete("all")
self.preview_canvas.create_text(75, 75, text="Preview\nError", fill="red", width=140, anchor="center", justify="center")
self.log(f"Preview error for {filename}: {e}", "error")
finally:
if temp_png_path.exists():
try:
temp_png_path.unlink()
except OSError as e:
self.log(f"Could not delete temp preview file: {e}", "debug")
except IndexError:
self.log("Error getting selection for preview (index out of range).", "error")
self.preview_canvas.delete("all")
self.preview_canvas.create_text(75, 75, text="Selection\nError", fill="red")
except Exception as e:
self.log(f"General error during preview generation: {e}", "error")
self.preview_canvas.delete("all")
self.preview_canvas.create_text(75, 75, text="Error", fill="red")
def update_status(self, text=""):
"""Updates the status label, typically showing file counts."""
if not text:
total = len(self.selected_files)
failed = len(self.failed_files)
if failed > 0:
text = f"{total} Files ({failed} Failed)"
elif total == 0:
text = ""
else:
text = f"{total} Files"
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 # Reset cancel flag when starting/finishing
state = tk.DISABLED if active else tk.NORMAL
# Disable most controls
try:
# Iterate through tabs in the notebook
settings_notebook = self.main_frame.winfo_children()[1] # Second child is notebook
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)):
try: widget.config(state=state)
except tk.TclError: pass # Ignore if state doesn't apply
# Iterate through file frame controls
file_frame = self.main_frame.winfo_children()[0] # First child is file frame
for widget in file_frame.winfo_children():
# Disable buttons in btn_frame
if widget.winfo_class() == 'TFrame':
for btn in widget.winfo_children():
if isinstance(btn, ttk.Button):
try: btn.config(state=state)
except tk.TclError: pass
# Disable listbox itself
elif isinstance(widget, tk.Listbox):
try: widget.config(state=state)
except tk.TclError: pass
# Disable preview refresh button
preview_frame = self.main_frame.winfo_children()[3] # Fourth is preview frame? Check index!
preview_controls = preview_frame.winfo_children()[1] # Second widget is controls frame
refresh_button = preview_controls.winfo_children()[0]
refresh_button.config(state=state)
except Exception as e:
print(f"Error setting UI state: {e}") # Debug if widget finding fails
# Special handling for Convert/Retry buttons
self.convert_button.config(text="Cancel" if active else "Start Conversion")
# Always enable Convert/Cancel button unless already cancelling
self.convert_button.config(state=tk.NORMAL if not (active and self.cancel_requested) else tk.DISABLED)
# Enable Retry only if not active AND there are failed files
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')
# Keep file list scrollbar functional? Usually disabled with listbox
def start_conversion(self):
"""Initiates the conversion process or handles cancellation."""
if self.conversion_active:
# --- Handle Cancellation ---
if not self.cancel_requested:
self.log("Cancellation requested...", "warning")
self.cancel_requested = True
self.conversion_active = False # Set flag for threads to check
self.convert_button.config(text="Cancelling...", state=tk.DISABLED) # Indicate cancelling visually
# Note: Running Inkscape processes won't be killed instantly,
# but new tasks won't start, and the loop will exit early.
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)...")
# Log settings (more concise)
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 = []
if self.export_width.get(): dims.append(f"W={self.export_width.get()}")
if self.export_height.get(): dims.append(f"H={self.export_height.get()}")
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() # Clear previous failures before new run
# Reset listbox item colors (remove red from previous failed)
for i in range(self.file_list.size()):
self.file_list.itemconfig(i, fg="") # Reset to default color
self.update_status("Running...")
# Start the conversion in a separate thread
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."""
files_to_process = list(self.selected_files) # Work on a copy
total_files = len(files_to_process)
success_count = 0
processed_count = 0
local_failed_files = [] # Keep track of failures in this run
final_output_dir = None
max_workers = self.concurrent_tasks.get()
if max_workers < 1: max_workers = 1
self.log(f"Using up to {max_workers} parallel tasks.", "debug")
with ThreadPoolExecutor(max_workers=max_workers) as executor:
# Submit tasks
futures = {executor.submit(self.convert_file, file_path): file_path for file_path in files_to_process}
for future in as_completed(futures):
# Check for cancellation *before* processing result
if self.cancel_requested:
# Don't process more results, let loop finish/break
continue # Or break? Continue allows pending tasks to finish and log errors
file_path = futures[future]
processed_count += 1
try:
output_path = future.result() # Get result (output path or None)
if self.cancel_requested: continue # Check again after result arrives
if output_path:
success_count += 1
self.log(f"Success: {os.path.basename(file_path)} -> {os.path.basename(output_path)}", "success")
final_output_dir = os.path.dirname(output_path)
else:
# Failure was logged in convert_file, add to this run's failed list
if file_path not in local_failed_files: # Avoid duplicates if retry logic changes
local_failed_files.append(file_path)
except Exception as exc:
# Exceptions from the convert_file task itself
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:
# Update progress bar (needs to be done in main thread)
self.after(0, self.update_progress, processed_count, total_files)
# --- Loop Finished ---
# Check if cancellation happened during the loop
if self.cancel_requested:
self.log("Conversion cancelled by user.", "warning")
# Attempt to cancel remaining futures (may not stop running processes, but prevents starting new ones)
for f in futures:
if not f.done():
f.cancel()
# --- Conversion Finished (or Cancelled) ---
self.failed_files = local_failed_files # Update the main failed list
# Ensure UI update happens on the main thread
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
# percentage = int((current / total) * 100)
# self.update_status(f"Running... {percentage}%") # Can be noisy
def finish_conversion(self, success_count, total_files, final_output_dir):
"""Called after all conversion threads complete or are cancelled."""
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}")
# Highlight failed files in the listbox
if self.failed_files:
current_list_items = self.file_list.get(0, tk.END)
current_full_paths = self.selected_files
for failed_path in self.failed_files:
try:
# Find the index based on the full path stored internally
list_index = current_full_paths.index(failed_path)
# Check if index is valid for current listbox size
if 0 <= list_index < len(current_list_items):
self.file_list.itemconfig(list_index, {'fg': 'red'})
except ValueError:
self.log(f"Could not find failed file {os.path.basename(failed_path)} in list to highlight.", "debug")
except Exception as e:
self.log(f"Error highlighting failed file: {e}", "error")
self.set_ui_state(active=False) # Re-enable UI
self.update_status() # Update final file counts
# Show messages based on outcome
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:
# Case: 0 success, 0 failed (e.g., empty list submitted?)
messagebox.showinfo("Conversion Complete", "No files were converted.")
# Auto-open folder if requested and successful conversions happened
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")
open_folder = get_platform_open_command(final_output_dir)
open_folder()
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. Returns output path or None.
Checks the self.cancel_requested flag.
"""
# Check for cancellation at the beginning of the task
if self.cancel_requested:
# self.log(f"Skipping {os.path.basename(svg_path_str)} due to cancellation.", "debug")
return None
svg_path = Path(svg_path_str)
base_name = svg_path.stem
source_dir = svg_path.parent
# Determine output directory
chosen_output_dir = self.output_dir.get()
if chosen_output_dir == "<Same as source>" or not chosen_output_dir:
output_dir = source_dir
else:
output_dir = Path(chosen_output_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 # Fallback
fmt = self.output_format.get().lower()
output_filename = output_dir / f"{base_name}.{fmt}"
# Build Inkscape command
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()}",
]
# Add background opacity only if color is not 'transparent'
if self.background.get().lower() != 'transparent':
cmd.append("--export-background-opacity=1")
# else: Inkscape default is 0 if background isn't specified, but if specified it defaults to 1.
# Let's be explicit for transparency if background IS specified as 'transparent'
elif self.background.get().lower() == 'transparent':
cmd.append("--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:
# Execute Inkscape
# Set process creation flags for Windows to hide console window
creationflags = 0
if sys.platform == "win32":
creationflags = subprocess.CREATE_NO_WINDOW
result = subprocess.run(cmd, capture_output=True, text=True, check=False, encoding='utf-8', creationflags=creationflags)
# Check cancellation *again* after the potentially long subprocess run
if self.cancel_requested:
# Even if successful, treat as cancelled if flag is set now
# Clean up potentially created file? Maybe not, user might want it.
return None
# Check result
if result.returncode == 0 and output_filename.exists() and output_filename.stat().st_size > 0:
return str(output_filename) # Success
else:
# Failure
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:
error_msg += f" Details: {err_details[:250]}..." # Log more details
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() # Remove empty file
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 for {svg_path.name}", "error")
# Stop all conversions if Inkscape is missing mid-run
self.cancel_requested = True # Signal cancellation
self.conversion_active = False
return None
except Exception as e:
self.log(f"Unexpected error converting {svg_path.name}: {type(e).__name__} - {e}", "error")
return None
def retry_failed(self):
"""Attempts to reconvert files that failed previously."""
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) # Copy the list
self.selected_files = files_to_retry # Set main list to only failed ones
self.file_list.delete(0, tk.END) # Clear GUI list
# Re-populate GUI list (without red color initially)
for f in files_to_retry:
self.file_list.insert(tk.END, os.path.basename(f))
# Ensure no red color from previous run if clear_list didn't handle it
# index = self.file_list.size() - 1
# self.file_list.itemconfig(index, fg="") # Reset color (redundant if clear_list works)
# self.failed_files.clear() # Clear failed list *before* starting conversion
self.retry_button.config(state=tk.DISABLED)
# Update status to show only the files being retried
self.update_status(f"{len(files_to_retry)} Files (Retry)")
self.start_conversion() # Start the conversion process again
def load_settings(self):
"""Loads settings from the JSON file."""
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.current_theme.set(settings.get('theme', 'dark')) # Load theme preference
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")
finally:
# Ensure theme is applied based on loaded setting or default
try:
if hasattr(self, 'tk'): # Check if tk object exists yet
self.tk.call("set_theme", self.current_theme.get())
except tk.TclError:
self.log(f"Could not apply loaded theme '{self.current_theme.get()}'.", "warning")
except Exception: pass # Ignore other theme errors on load
def save_settings(self):
"""Saves current settings to the JSON file."""
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(),
'theme': self.current_theme.get(), # Save theme preference
}
try:
settings_path = Path(SETTINGS_FILE)
with open(settings_path, 'w') as f:
json.dump(settings, f, indent=4)
# self.log("Settings saved.", "debug") # Can be noisy
except Exception as e:
self.log(f"Error saving settings: {e}", "error")
def on_closing(self):
"""Handles window close event."""
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 # Ensure flag is set
# Maybe wait a very short time for threads to potentially see the flag?
# self.after(100, self._perform_close) # Delay close slightly
self._perform_close() # Close immediately after setting flag
else:
return # Don't close
else:
self._perform_close()
def _perform_close(self):
"""Saves settings and destroys the window."""
if self.preview_timer: # Cancel any pending preview timer
self.preview_timer.cancel()
self.save_settings()
self.destroy()
# --- Main Execution ---
if __name__ == "__main__":
# Check if azure.tcl exists before starting Tkinter
theme_file_name = "azure.tcl"
script_dir = Path(__file__).parent
theme_path_check = script_dir / theme_file_name
if getattr(sys, 'frozen', False): # Check bundle root if frozen
theme_path_check = Path(sys._MEIPASS) / theme_file_name
if not theme_path_check.exists():
print(f"WARNING: Theme file '{theme_file_name}' not found.")
print(f"Expected location: {theme_path_check}")
print("The application will use a fallback theme.")
# Optionally show a Tkinter messagebox warning here if Tkinter is already safe to import/use
# root = tk.Tk()
# root.withdraw()
# messagebox.showwarning("Theme Warning", f"Theme file '{theme_file_name}' not found.\nUsing fallback theme.")
# root.destroy()
app = SVGtoPNGConverter()
app.mainloop()