Utility_Apps/Svg2PngPdf/Archived/svg_to_png_gui_D-v2-NewTheme.py

1211 lines
59 KiB
Python

#!/usr/bin/env python3
import os
import sys
import subprocess
import platform
import json
import threading
from pathlib import Path
from tkinter import (Tk, Frame, Label, Button, Listbox, Scrollbar, Entry, Spinbox,
Checkbutton, LabelFrame, scrolledtext, Toplevel, colorchooser,
IntVar, StringVar, BooleanVar, DoubleVar,
BOTH, X, Y, LEFT, RIGHT, VERTICAL, HORIZONTAL, W, E, EW, END, WORD, SUNKEN, MULTIPLE)
from tkinter import ttk, filedialog, messagebox
# tkinterdnd2 needs to be installed: pip install tkinterdnd2
# Make sure to import the TkinterDnD.Tk class correctly
try:
from tkinterdnd2 import TkinterDnD, DND_FILES
except ImportError:
messagebox.showerror("Error", "tkinterdnd2 library not found.\nPlease install it using: pip install tkinterdnd2")
sys.exit(1)
# Pillow needs to be installed: pip install Pillow
try:
from PIL import Image, ImageTk
except ImportError:
# PIL might not be strictly necessary if not displaying images, but good to check
print("Warning: Pillow (PIL) not found. Image operations might be limited.")
# messagebox.showwarning("Warning", "Pillow (PIL) library not found.\nInstall it using: pip install Pillow")
# sys.exit(1) # Don't exit, maybe it's not critical
# darkdetect needs to be installed: pip install darkdetect
try:
import darkdetect
except ImportError:
print("Warning: darkdetect library not found. Automatic dark mode detection disabled.")
darkdetect = None # Set to None to handle gracefully
class SVGtoPNGConverter(TkinterDnD.Tk):
BLENDER_THEME = {
"bg": "#323232", # Main background (less used in ttk)
"panel_bg": "#252526", # Frame/widget backgrounds
"highlight": "#4f84d1", # Selection/active elements
"text": "#e0e0e0", # Default text color
"border": "#1e1e1e", # Borders (less directly controllable in ttk)
"button_bg": "#535353", # Button background
"button_hover": "#606060", # Button hover (use map for active)
"danger_button": "#a04040", # Not implemented in this theme directly
"input_bg": "#444444", # Entry/Combobox background
"input_text": "#eeeeee", # Entry/Combobox text
"disabled": "#888888", # Disabled text color
}
BLENDER_FONTS = {
"primary": ("Segoe UI", 12), # Adjusted size slightly
"monospace": ("Consolas", 12),
"header": ("Segoe UI", 14, "bold"),
"subsection": ("Segoe UI", 12, "bold")
}
def __init__(self):
# Use TkinterDnD.Tk as the main window class
super().__init__()
self.title("SVG to PNG/Format Converter")
self.geometry("900x700")
self.minsize(800, 600)
# Initialize variables
self.selected_files = []
self.failed_files = []
self.conversion_running = False
self.dark_mode = self._detect_dark_mode()
# Load settings
self.settings_file = Path.home() / ".svg2png_converter_settings.json" # More specific name
self.load_settings()
# Setup UI *before* applying theme
self.style = ttk.Style(self)
self.setup_ui()
# Check Inkscape availability
self.inkscape_path = self.find_inkscape()
if not self.inkscape_path:
self.log("Warning: Inkscape not found in PATH or common locations. Conversion will not work until Inkscape is correctly installed and its path is found.", "warning")
else:
self.log(f"Found Inkscape at: {self.inkscape_path}", "info")
# Configure window close behavior
self.protocol("WM_DELETE_WINDOW", self.on_close)
# Apply the initial theme
self.update_theme()
def _detect_dark_mode(self):
if darkdetect and hasattr(darkdetect, 'isDark'):
try:
return darkdetect.isDark()
except Exception as e:
print(f"Error detecting dark mode: {e}")
return False # Default to light mode on error
return False # Default to light mode if darkdetect is not available
def create_blender_theme(self):
"""Attempts to create the BlenderBIM theme configuration."""
if 'blenderbim' in self.style.theme_names():
return True # Already exists
try:
# Use 'clam' or 'alt' as parent, 'clam' might be slightly better visually sometimes
self.style.theme_create("blenderbim", parent="clam", settings={
".": {
"configure": {
"background": self.BLENDER_THEME["panel_bg"],
"foreground": self.BLENDER_THEME["text"],
"font": self.BLENDER_FONTS["primary"],
"bordercolor": self.BLENDER_THEME["border"], # General border color
"lightcolor": self.BLENDER_THEME["panel_bg"], # Used in some states
"darkcolor": self.BLENDER_THEME["bg"], # Used in some states
}
},
"TFrame": {
"configure": {"background": self.BLENDER_THEME["panel_bg"]}
},
"TLabel": {
"configure": {
"background": self.BLENDER_THEME["panel_bg"],
"foreground": self.BLENDER_THEME["text"],
"font": self.BLENDER_FONTS["primary"]
}
},
"TLabelFrame": {
"configure": {
"background": self.BLENDER_THEME["panel_bg"],
"foreground": self.BLENDER_THEME["text"],
"font": self.BLENDER_FONTS["subsection"],
"relief": "groove", # More visible border
"borderwidth": 1,
"bordercolor": self.BLENDER_THEME["border"]
}
},
"TLabelFrame.Label": {
"configure": {
"background": self.BLENDER_THEME["panel_bg"],
"foreground": self.BLENDER_THEME["text"],
"font": self.BLENDER_FONTS["subsection"]
}
},
"TButton": {
"configure": {
"background": self.BLENDER_THEME["button_bg"],
"foreground": self.BLENDER_THEME["text"],
"bordercolor": self.BLENDER_THEME["border"],
"borderwidth": 1,
"font": self.BLENDER_FONTS["primary"],
"padding": 5,
"anchor": "center",
"relief": "raised"
},
"map": {
# "background": [("pressed", "!disabled", self.BLENDER_THEME["highlight"]), # Pressed state
# ("active", self.BLENDER_THEME["button_hover"])], # Hover state
"background": [("active", self.BLENDER_THEME["button_hover"]), # Hover state
("disabled", self.BLENDER_THEME["panel_bg"])], # Disabled state
"foreground": [("disabled", self.BLENDER_THEME["disabled"])],
"relief": [("pressed", "!disabled", "sunken")]
}
},
"TEntry": {
"configure": {
"fieldbackground": self.BLENDER_THEME["input_bg"],
"foreground": self.BLENDER_THEME["input_text"],
"insertbackground": self.BLENDER_THEME["text"], # Cursor color
"bordercolor": self.BLENDER_THEME["border"],
"borderwidth": 1,
"font": self.BLENDER_FONTS["primary"]
},
"map": {
"foreground": [("disabled", self.BLENDER_THEME["disabled"])],
"fieldbackground": [("disabled", self.BLENDER_THEME["panel_bg"])]
}
},
"TCombobox": {
"configure": {
"fieldbackground": self.BLENDER_THEME["input_bg"],
"background": self.BLENDER_THEME["button_bg"], # Arrow button background
"foreground": self.BLENDER_THEME["input_text"],
"arrowcolor": self.BLENDER_THEME["text"],
"bordercolor": self.BLENDER_THEME["border"],
"insertbackground": self.BLENDER_THEME["text"],
"font": self.BLENDER_FONTS["primary"]
},
"map": {
# TODO: Combobox styling can be tricky across platforms
"background": [("active", self.BLENDER_THEME["button_hover"])],
"foreground": [("disabled", self.BLENDER_THEME["disabled"])],
"fieldbackground": [("disabled", self.BLENDER_THEME["panel_bg"])]
}
},
# Style dropdown list of Combobox
"TCombobox.Listbox": {
"configure": {
"background": self.BLENDER_THEME["input_bg"],
"foreground": self.BLENDER_THEME["input_text"],
"selectbackground": self.BLENDER_THEME["highlight"],
"selectforeground": self.BLENDER_THEME["text"],
"font": self.BLENDER_FONTS["primary"]
}
},
"Vertical.TScrollbar": {
"configure": {
"background": self.BLENDER_THEME["button_bg"], # Scrollbar handle
"troughcolor": self.BLENDER_THEME["panel_bg"], # Scrollbar track
"bordercolor": self.BLENDER_THEME["border"],
"arrowcolor": self.BLENDER_THEME["text"],
"relief": "flat"
},
"map": {
"background": [("active", self.BLENDER_THEME["button_hover"])]
}
},
"Horizontal.TScrollbar": {
"configure": {
"background": self.BLENDER_THEME["button_bg"],
"troughcolor": self.BLENDER_THEME["panel_bg"],
"bordercolor": self.BLENDER_THEME["border"],
"arrowcolor": self.BLENDER_THEME["text"],
"relief": "flat"
},
"map": {
"background": [("active", self.BLENDER_THEME["button_hover"])]
}
},
"TCheckbutton": {
"configure": {
"background": self.BLENDER_THEME["panel_bg"],
"foreground": self.BLENDER_THEME["text"],
"font": self.BLENDER_FONTS["primary"],
"indicatorcolor": self.BLENDER_THEME["input_bg"], # Box color when off
},
"map": {
"foreground": [("disabled", self.BLENDER_THEME["disabled"])],
"indicatorcolor": [("selected", self.BLENDER_THEME["highlight"]), # Box color when checked
("active", self.BLENDER_THEME["input_bg"])], # Box color on hover
}
},
"TSpinbox": {
"configure": {
# Inherits TEntry settings for the field
"arrowcolor": self.BLENDER_THEME["text"],
"background": self.BLENDER_THEME["button_bg"] # Button background
},
"map": {
"background": [("active", self.BLENDER_THEME["button_hover"])]
}
},
"TProgressbar": {
"configure": {
"background": self.BLENDER_THEME["highlight"], # Color of the bar itself
"troughcolor": self.BLENDER_THEME["input_bg"], # Background of the bar
"bordercolor": self.BLENDER_THEME["border"],
"thickness": 20
}
}
})
print("BlenderBIM theme created successfully.")
return True
except Exception as e:
self.log(f"Error creating BlenderBIM theme: {e}", "error")
# Optionally remove partially created theme if it fails
if 'blenderbim' in self.style.theme_names():
try:
# This might not exist depending on the Tk version/platform
self.style.theme_delete("blenderbim")
except Exception:
pass
return False
def setup_ui(self):
"""Setup the main user interface"""
# Main container - Use standard Frame, let theme handle background
main_frame = Frame(self)
main_frame.pack(fill=BOTH, expand=True, padx=10, pady=10)
# Top panel - File selection
file_frame = ttk.LabelFrame(main_frame, text="Files to Convert", padding=10)
file_frame.pack(fill=X, pady=(0, 10))
file_frame.grid_columnconfigure(0, weight=1) # Make listbox expand
# File list with scrollbar - Use standard Listbox
self.file_list = Listbox(file_frame, selectmode=MULTIPLE, height=8,
font=self.BLENDER_FONTS["primary"],
borderwidth=1, relief="solid") # Give it a border manually
self.file_list.grid(row=0, column=0, sticky="nsew", pady=2)
list_scroll_y = ttk.Scrollbar(file_frame, orient=VERTICAL, command=self.file_list.yview)
list_scroll_y.grid(row=0, column=1, sticky="ns", pady=2)
self.file_list.config(yscrollcommand=list_scroll_y.set)
list_scroll_x = ttk.Scrollbar(file_frame, orient=HORIZONTAL, command=self.file_list.xview)
list_scroll_x.grid(row=1, column=0, sticky="ew")
self.file_list.config(xscrollcommand=list_scroll_x.set)
# File controls
file_controls = ttk.Frame(file_frame)
# Place controls in a separate column
file_controls.grid(row=0, column=2, rowspan=2, sticky="ns", padx=(10, 0))
ttk.Button(file_controls, text="Add Files", command=self.add_files, width=10).pack(fill=X, pady=2)
ttk.Button(file_controls, text="Add Folder", command=self.add_folder, width=10).pack(fill=X, pady=2)
ttk.Button(file_controls, text="Remove", command=self.remove_files, width=10).pack(fill=X, pady=2)
ttk.Button(file_controls, text="Clear All", command=self.clear_files, width=10).pack(fill=X, pady=2)
# Enable drag and drop on the listbox and the frame around it
self.file_list.drop_target_register(DND_FILES)
self.file_list.dnd_bind('<<Drop>>', self.handle_drop)
file_frame.drop_target_register(DND_FILES) # Allow dropping on the whole frame too
file_frame.dnd_bind('<<Drop>>', self.handle_drop)
self.drop_target_register(DND_FILES) # Allow dropping anywhere on the main window
self.dnd_bind('<<Drop>>', self.handle_drop)
# Middle panel - Settings
settings_frame = ttk.LabelFrame(main_frame, text="Conversion Settings", padding=10)
settings_frame.pack(fill=X, pady=(0, 10))
settings_frame.columnconfigure(1, weight=1) # Allow output path entry to expand
settings_frame.columnconfigure(3, weight=1) # Balance spacing
# DPI setting
ttk.Label(settings_frame, text="DPI:").grid(row=0, column=0, sticky=W, padx=(0, 5), pady=2)
self.dpi_var = IntVar(value=self.settings.get('dpi', 96))
ttk.Spinbox(settings_frame, from_=72, to=1200, increment=24, textvariable=self.dpi_var, width=7).grid(row=0, column=1, sticky=W, pady=2)
# Background color
ttk.Label(settings_frame, text="Background:").grid(row=0, column=2, sticky=W, padx=(15, 5), pady=2)
self.bg_var = StringVar(value=self.settings.get('background', '#FFFFFF'))
bg_frame = ttk.Frame(settings_frame) # Use ttk.Frame
bg_frame.grid(row=0, column=3, sticky=W, pady=2)
self.bg_entry = ttk.Entry(bg_frame, textvariable=self.bg_var, width=10)
self.bg_entry.pack(side=LEFT)
# Add a color preview swatch
self.bg_swatch = Label(bg_frame, text=" ", relief="solid", borderwidth=1)
self.bg_swatch.pack(side=LEFT, padx=(2, 5))
self.update_color_swatch() # Initial color
self.bg_var.trace_add("write", lambda *args: self.update_color_swatch()) # Update on change
ttk.Button(bg_frame, text="Pick", command=self.pick_color, width=5).pack(side=LEFT, padx=(0, 0))
# Output format
ttk.Label(settings_frame, text="Format:").grid(row=0, column=4, sticky=W, padx=(15, 5), pady=2)
self.format_var = StringVar(value=self.settings.get('format', 'png'))
# Common export formats supported by Inkscape CLI
formats = ['png', 'jpg', 'pdf', 'tiff', 'eps', 'svg', 'ps']
ttk.Combobox(settings_frame, textvariable=self.format_var, values=formats, width=7, state='readonly').grid(row=0, column=5, sticky=W, pady=2)
# Output directory
ttk.Label(settings_frame, text="Output Dir:").grid(row=1, column=0, sticky=W, padx=(0, 5), pady=(10, 2))
self.output_var = StringVar(value=self.settings.get('output_dir', 'Same as source'))
output_frame = ttk.Frame(settings_frame)
output_frame.grid(row=1, column=1, columnspan=5, sticky=EW, pady=(10, 2)) # Span all columns
output_frame.columnconfigure(0, weight=1) # Make entry expand
self.output_entry = ttk.Entry(output_frame, textvariable=self.output_var)
self.output_entry.grid(row=0, column=0, sticky=EW)
ttk.Button(output_frame, text="Browse", command=self.choose_output_dir, width=8).grid(row=0, column=1, sticky=W, padx=(5, 0))
# Additional options
options_frame = ttk.Frame(settings_frame)
options_frame.grid(row=2, column=0, columnspan=6, sticky=W, pady=(10, 0))
self.overwrite_var = BooleanVar(value=self.settings.get('overwrite', False))
ttk.Checkbutton(options_frame, text="Overwrite existing files", variable=self.overwrite_var).pack(side=LEFT, padx=(0, 15))
self.auto_open_var = BooleanVar(value=self.settings.get('auto_open', False))
ttk.Checkbutton(options_frame, text="Open output folder on completion", variable=self.auto_open_var).pack(side=LEFT)
# Bottom panel - Log and controls
log_frame = ttk.LabelFrame(main_frame, text="Conversion Log", padding=10)
log_frame.pack(fill=BOTH, expand=True, pady=(0,10))
log_frame.rowconfigure(0, weight=1) # Make text area expand
log_frame.columnconfigure(0, weight=1) # Make text area expand
self.log_area = scrolledtext.ScrolledText(log_frame, wrap=WORD, state='disabled', height=10,
borderwidth=1, relief="solid", # Manual border
font=self.BLENDER_FONTS["monospace"])
self.log_area.grid(row=0, column=0, sticky="nsew")
# Configure log tags for colored text (initial colors)
self.log_area.tag_config("info", foreground="blue")
self.log_area.tag_config("success", foreground="green")
self.log_area.tag_config("warning", foreground="orange")
self.log_area.tag_config("error", foreground="red")
# Progress bar
self.progress_var = DoubleVar()
self.progress_bar = ttk.Progressbar(log_frame, variable=self.progress_var, maximum=100)
self.progress_bar.grid(row=1, column=0, sticky=EW, pady=(5, 0))
# Status bar - Use a Label inside the log_frame
self.status_var = StringVar(value="Ready")
status_bar = ttk.Label(log_frame, textvariable=self.status_var, anchor=W) # relief=SUNKEN removed, use theme
status_bar.grid(row=2, column=0, sticky=EW, pady=(5, 0))
# Control buttons Frame
control_frame = ttk.Frame(main_frame)
control_frame.pack(fill=X, pady=(5, 0)) # Add padding top
self.convert_button = ttk.Button(control_frame, text="Convert", command=self.start_conversion, width=12)
self.convert_button.pack(side=LEFT, padx=(0, 10))
self.retry_button = ttk.Button(control_frame, text="Retry Failed", command=self.retry_failed, width=12)
self.retry_button.pack(side=LEFT, padx=(0, 10))
self.retry_button.configure(state='disabled') # Initially disabled
self.clear_log_button = ttk.Button(control_frame, text="Clear Log", command=self.clear_log, width=10)
self.clear_log_button.pack(side=LEFT)
# Theme toggle button - Place it at the far right
self.theme_button = ttk.Button(control_frame, text="☀️" if self.dark_mode else "🌙", command=self.toggle_theme, width=3)
self.theme_button.pack(side=RIGHT)
def update_color_swatch(self):
"""Updates the background color preview swatch."""
try:
color = self.bg_var.get()
self.bg_swatch.config(bg=color)
except TclError:
# Handle invalid color string temporarily entered
self.bg_swatch.config(bg="white") # Default fallback
def update_theme(self):
"""Update the theme based on dark mode setting"""
theme_to_use = 'clam' # Default light theme
if self.dark_mode:
theme_created = self.create_blender_theme()
if theme_created and 'blenderbim' in self.style.theme_names():
theme_to_use = 'blenderbim'
print("Using BlenderBIM theme.")
else:
# Fallback to 'alt' or 'clam' if custom theme fails
theme_to_use = 'alt'
print("BlenderBIM theme failed or not found, falling back to 'alt'.")
self.log("Failed to apply custom dark theme, using fallback.", "warning")
# Apply theme first
self.style.theme_use(theme_to_use)
# Configure elements not fully covered by theme or needing Blender colors
self.configure(background=self.BLENDER_THEME["panel_bg"]) # Main window background
for frame in self.winfo_children(): # Apply to immediate children frames too
if isinstance(frame, (Frame, ttk.Frame, LabelFrame, ttk.LabelFrame)):
frame.configure(background=self.BLENDER_THEME["panel_bg"])
self.log_area.configure(
bg=self.BLENDER_THEME["input_bg"],
fg=self.BLENDER_THEME["text"],
insertbackground=self.BLENDER_THEME["text"], # Cursor color
selectbackground=self.BLENDER_THEME["highlight"],
selectforeground=self.BLENDER_THEME["input_text"],
relief="solid", borderwidth=1, # Ensure border style is consistent
bd=1, highlightthickness=1, highlightcolor=self.BLENDER_THEME["border"], highlightbackground=self.BLENDER_THEME["border"]
)
self.file_list.configure(
bg=self.BLENDER_THEME["input_bg"],
fg=self.BLENDER_THEME["text"],
selectbackground=self.BLENDER_THEME["highlight"],
selectforeground=self.BLENDER_THEME["text"],
relief="solid", borderwidth=1, # Ensure border style is consistent
bd=1, highlightthickness=1, highlightcolor=self.BLENDER_THEME["border"], highlightbackground=self.BLENDER_THEME["border"]
)
# Update tag colors for dark mode
self.log_area.tag_config("info", foreground="#77b0ff") # Lighter blue
self.log_area.tag_config("success", foreground="#77dd77") # Lighter green
self.log_area.tag_config("warning", foreground="#ffcc66") # Lighter orange/yellow
self.log_area.tag_config("error", foreground="#ff7777") # Lighter red
# Update theme button text
self.theme_button.config(text="☀️")
else: # Light mode
theme_to_use = 'clam' # Or 'vista' on Windows, 'aqua' on macOS if preferred
self.style.theme_use(theme_to_use)
print(f"Using {theme_to_use} theme.")
# Configure elements for standard light theme look
# Use system default colors where possible
self.configure(background='SystemButtonFace')
for frame in self.winfo_children():
if isinstance(frame, (Frame, ttk.Frame, LabelFrame, ttk.LabelFrame)):
try: frame.configure(background='SystemButtonFace')
except: pass # Ignore if not applicable
self.log_area.configure(
bg='white', fg='black', insertbackground='black',
selectbackground='#3399ff', selectforeground='white', # Standard selection
relief="solid", borderwidth=1, # Ensure border style is consistent
bd=1, highlightthickness=1, highlightcolor='gray', highlightbackground='gray'
)
self.file_list.configure(
bg='white', fg='black',
selectbackground='#3399ff', selectforeground='white', # Standard selection
relief="solid", borderwidth=1, # Ensure border style is consistent
bd=1, highlightthickness=1, highlightcolor='gray', highlightbackground='gray'
)
# Update tag colors for light mode
self.log_area.tag_config("info", foreground="blue")
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")
# Update theme button text
self.theme_button.config(text="🌙")
self.update_color_swatch() # Ensure swatch matches entry bg/fg
def toggle_theme(self):
"""Toggle between dark and light mode"""
self.dark_mode = not self.dark_mode
self.log(f"Switched to {'Dark' if self.dark_mode else 'Light'} Mode", "info")
self.update_theme()
# No need to save here, save on close
def load_settings(self):
"""Load settings from file"""
# Sensible defaults
default_settings = {
'dpi': 96,
'background': '#FFFFFF', # Default white for PNG, often ignored by PDF/EPS
'format': 'png',
'output_dir': 'Same as source',
'overwrite': False,
'auto_open': False,
'window_geometry': '900x700',
# Load dark_mode preference, but override with detection if available
'dark_mode': self._detect_dark_mode()
}
try:
if self.settings_file.exists():
with open(self.settings_file, 'r') as f:
loaded_settings = json.load(f)
# Merge defaults with loaded, loaded takes precedence
self.settings = {**default_settings, **loaded_settings}
# Crucially, re-detect OS preference unless darkdetect is unavailable
if darkdetect:
self.settings['dark_mode'] = self._detect_dark_mode()
# Allow saved preference to override detection *only* if specifically saved
if 'force_dark_mode' in loaded_settings:
self.settings['dark_mode'] = loaded_settings['force_dark_mode']
else:
self.settings = default_settings
self.log("No settings file found, using defaults.", "info")
# Apply loaded/default settings
self.dark_mode = self.settings.get('dark_mode', False)
self.geometry(self.settings.get('window_geometry', '900x700'))
except Exception as e:
self.log(f"Error loading settings: {str(e)}. Using defaults.", "error")
self.settings = default_settings
# Apply defaults even on error
self.dark_mode = self.settings.get('dark_mode', False)
self.geometry(self.settings.get('window_geometry', '900x700'))
def save_settings(self):
"""Save current settings to file"""
# Ensure UI variables exist before trying to get() - safeguard
settings_to_save = {}
try: settings_to_save['dpi'] = self.dpi_var.get()
except: pass
try: settings_to_save['background'] = self.bg_var.get()
except: pass
try: settings_to_save['format'] = self.format_var.get()
except: pass
try: settings_to_save['output_dir'] = self.output_var.get()
except: pass
try: settings_to_save['overwrite'] = self.overwrite_var.get()
except: pass
try: settings_to_save['auto_open'] = self.auto_open_var.get()
except: pass
try: settings_to_save['window_geometry'] = self.geometry()
except: pass
# Save the *current* mode being used, allowing override of detection
settings_to_save['force_dark_mode'] = self.dark_mode
# Merge with any existing settings loaded initially (preserves unknown keys)
self.settings.update(settings_to_save)
try:
with open(self.settings_file, 'w') as f:
json.dump(self.settings, f, indent=4) # Use indent for readability
# self.log("Settings saved.", "info") # Can be noisy, maybe omit
except Exception as e:
self.log(f"Error saving settings: {str(e)}", "error")
def find_inkscape(self):
"""Find Inkscape executable in common locations, prioritizing PATH."""
inkscape_exe = "inkscape"
if platform.system() == "Windows":
inkscape_exe += ".exe"
# 1. Check PATH using 'where' (Windows) or 'which' (Unix-like)
try:
cmd = ["where", "inkscape"] if platform.system() == "Windows" else ["which", "inkscape"]
result = subprocess.run(cmd, capture_output=True, text=True, check=False, startupinfo=None) # No console window on Win
if platform.system() == "Windows" and result.returncode == 0 :
# 'where' can return multiple paths, use the first one that's likely the .exe
paths = result.stdout.splitlines()
for p in paths:
if p.strip().lower().endswith("inkscape.exe"):
return p.strip()
elif result.returncode == 0: # Unix 'which' succeeded
return result.stdout.strip()
except FileNotFoundError:
# 'where' or 'which' command not found, shouldn't happen usually
pass
except Exception as e:
self.log(f"Error checking PATH for Inkscape: {e}", "warning")
# 2. Check common installation paths if not found in PATH
common_paths = []
if platform.system() == "Windows":
program_files = os.environ.get("ProgramFiles", "C:\\Program Files")
program_files_x86 = os.environ.get("ProgramFiles(x86)", "C:\\Program Files (x86)")
local_app_data = os.environ.get("LocalAppData", "")
common_paths.extend([
Path(program_files) / "Inkscape" / "bin" / inkscape_exe,
Path(program_files) / "Inkscape" / inkscape_exe, # Older versions?
Path(program_files_x86) / "Inkscape" / "bin" / inkscape_exe,
# C:\Users\USER\AppData\Local\Programs\Inkscape\bin\inkscape.exe (scoop/winget?)
Path(local_app_data) / "Programs" / "Inkscape" / "bin" / inkscape_exe if local_app_data else None,
])
elif platform.system() == "Darwin": # macOS
common_paths.extend([
Path("/Applications/Inkscape.app/Contents/MacOS/inkscape"),
Path("/Applications/Inkscape.app/Contents/Resources/bin/inkscape"), # Older?
Path("/usr/local/bin/inkscape"), # Homebrew
])
else: # Linux
common_paths.extend([
Path("/usr/bin/inkscape"),
Path("/usr/local/bin/inkscape"),
Path("/snap/bin/inkscape"),
Path(os.path.expanduser("~/.local/bin/inkscape")),
Path("/opt/inkscape/bin/inkscape"), # Less common
Path("/app/bin/inkscape"), # Flatpak?
])
for path in filter(None, common_paths): # Filter out None paths
if path.exists() and path.is_file():
self.log(f"Found Inkscape via common path: {path}", "info")
return str(path)
# 3. Ask user if still not found
self.log("Inkscape not found automatically.", "warning")
if messagebox.askyesno("Inkscape Not Found", "Could not automatically find the Inkscape executable.\n\nWould you like to locate it manually?"):
manual_path = filedialog.askopenfilename(title="Locate Inkscape Executable", filetypes=[("Executable files", inkscape_exe), ("All files", "*.*")])
if manual_path and Path(manual_path).exists():
self.settings['inkscape_path'] = manual_path # Save for next time
self.save_settings()
return manual_path
return None
def log(self, message, level="info"):
"""Add a message to the log area with specified level (info, success, warning, error)"""
try:
self.log_area.configure(state='normal')
self.log_area.insert(END, f"[{level.upper()}] {message}\n", level)
self.log_area.configure(state='disabled')
self.log_area.see(END) # Scroll to the end
self.update_idletasks() # Force GUI update
except Exception as e:
print(f"Error logging message '{message}': {e}") # Fallback print
def clear_log(self):
"""Clear the log area"""
self.log_area.configure(state='normal')
self.log_area.delete(1.0, END)
self.log_area.configure(state='disabled')
self.log("Log cleared.", "info")
def add_files(self):
"""Add files through file dialog"""
filetypes = [("SVG Files", "*.svg"), ("All Files", "*.*")]
# Use parent=self for dialog modality
files = filedialog.askopenfilenames(parent=self, title="Select SVG Files", filetypes=filetypes)
if files:
self.update_file_list(files)
def add_folder(self):
"""Add all SVG files from a folder (non-recursive)"""
folder = filedialog.askdirectory(parent=self, title="Select Folder Containing SVG Files")
if folder:
svg_files = []
try:
for item in os.listdir(folder):
item_path = os.path.join(folder, item)
if os.path.isfile(item_path) and item.lower().endswith('.svg'):
svg_files.append(item_path)
except Exception as e:
self.log(f"Error reading folder {folder}: {e}", "error")
return
if svg_files:
self.log(f"Found {len(svg_files)} SVG files in {folder}.", "info")
self.update_file_list(svg_files)
else:
self.log(f"No SVG files found directly in {folder}.", "warning")
# Optionally add recursive search here if desired
def handle_drop(self, event):
"""Handle files/folders dropped onto the window"""
# event.data contains a string, potentially tcl-formatted list
try:
files_or_folders = self.tk.splitlist(event.data)
except Exception:
# Fallback for simple path strings
files_or_folders = [event.data]
valid_files = []
for item_path in files_or_folders:
path = Path(item_path) # Use pathlib
if path.is_dir():
self.log(f"Scanning dropped folder: {path}", "info")
try:
# Add files directly in the folder (non-recursive)
for sub_item in path.iterdir():
if sub_item.is_file() and sub_item.suffix.lower() == '.svg':
valid_files.append(str(sub_item))
except Exception as e:
self.log(f"Error scanning dropped folder {path}: {e}", "error")
elif path.is_file() and path.suffix.lower() == '.svg':
valid_files.append(str(path))
else:
self.log(f"Ignoring dropped non-SVG item: {item_path}", "warning")
if valid_files:
self.update_file_list(valid_files)
else:
self.log("No valid SVG files found in dropped items.", "warning")
def update_file_list(self, new_files):
"""Update the file list with new files, avoiding duplicates"""
existing_files = set(self.selected_files)
added_count = 0
for file in new_files:
try:
file_path = str(Path(file).resolve()) # Get absolute, resolved path
if file_path not in existing_files:
self.selected_files.append(file_path)
existing_files.add(file_path)
added_count += 1
except Exception as e:
self.log(f"Error processing file path '{file}': {e}", "warning")
if added_count > 0:
self.refresh_file_list()
self.log(f"Added {added_count} file(s) to the list.", "info")
elif new_files: # Only log if files were provided but none were new
self.log("No new files were added (duplicates ignored).", "info")
def refresh_file_list(self):
"""Refresh the file list display"""
self.file_list.delete(0, END)
# Sort files for consistent display? Optional.
# self.selected_files.sort()
for file_path in self.selected_files:
# Display only filename or relative path? For now, full path.
display_name = os.path.basename(file_path) # Simpler display
self.file_list.insert(END, file_path) # Store full path, display name later?
# self.file_list.insert(END, display_name) # Or display shorter name
def remove_files(self):
"""Remove selected files from the list"""
selected_indices = self.file_list.curselection()
if not selected_indices:
self.log("No files selected to remove.", "warning")
return
removed_count = 0
# Iterate backwards through selected indices to avoid index shifting issues
for i in sorted(selected_indices, reverse=True):
try:
removed_file = self.selected_files.pop(i)
self.file_list.delete(i)
removed_count += 1
except IndexError:
self.log(f"Error removing file at index {i}. List may be out of sync.", "error")
if removed_count > 0:
# No need to call refresh_file_list() as we deleted directly
self.log(f"Removed {removed_count} file(s) from the list.", "info")
def clear_files(self):
"""Clear all files from the list"""
if not self.selected_files:
self.log("File list is already empty.", "info")
return
count = len(self.selected_files)
self.selected_files.clear()
self.refresh_file_list()
self.log(f"Cleared all {count} files from the list.", "info")
def pick_color(self):
"""Open color picker dialog"""
# Pass parent=self for modality
initial_color = self.bg_var.get()
# colorchooser returns ((r,g,b), '#rrggbb') or (None, None)
result = colorchooser.askcolor(parent=self, title="Select Background Color", initialcolor=initial_color)
if result and result[1]: # Check if a color was chosen (result[1] is the hex string)
self.bg_var.set(result[1].upper()) # Store as uppercase hex
self.update_color_swatch() # Update preview immediately
def choose_output_dir(self):
"""Choose output directory"""
# Pass parent=self for modality
initial_dir = self.output_var.get()
if initial_dir.lower() == 'same as source' or not os.path.isdir(initial_dir):
initial_dir = os.getcwd() # Start in current dir if default or invalid
directory = filedialog.askdirectory(parent=self, title="Select Output Directory", initialdir=initial_dir)
if directory:
self.output_var.set(directory)
def set_ui_state(self, enabled: bool):
"""Enable or disable UI elements during conversion."""
state = 'normal' if enabled else 'disabled'
# Buttons
self.convert_button.config(state=state)
# Only enable retry if there are failed files *and* UI is enabled
retry_state = 'normal' if enabled and self.failed_files else 'disabled'
self.retry_button.config(state=retry_state)
self.clear_log_button.config(state=state)
self.theme_button.config(state=state)
# Settings Controls (disable everything in settings frame)
for child in self.winfo_children():
if isinstance(child, (ttk.LabelFrame)): # Find Settings and File frames
if child.cget('text') in ("Files to Convert", "Conversion Settings"):
for widget in child.winfo_children():
try: widget.config(state=state)
except TclError: pass # Some widgets might not have 'state'
# Special handling for listbox and text area (make them read-only)
if enabled:
self.file_list.config(state='normal')
# self.log_area.config(state='normal') # Log area is handled by log() method
else:
self.file_list.config(state='disabled')
# self.log_area.config(state='disabled')
def start_conversion(self):
"""Start the conversion process in a separate thread"""
if self.conversion_running:
self.log("Conversion is already in progress.", "warning")
return
if not self.selected_files:
self.log("No files selected for conversion.", "warning")
messagebox.showwarning("No Files", "Please add SVG files to the list before converting.", parent=self)
return
# Re-check Inkscape path just before conversion
self.inkscape_path = self.find_inkscape()
if not self.inkscape_path:
self.log("Cannot start conversion: Inkscape path not configured or invalid.", "error")
messagebox.showerror("Inkscape Error", "Inkscape executable not found or path is invalid.\nPlease ensure Inkscape is installed and its location is known (you might be prompted to find it).", parent=self)
return
# Get settings *before* starting thread
self.current_settings = {
'dpi': self.dpi_var.get(),
'background': self.bg_var.get(),
'format': self.format_var.get(),
'output_dir_setting': self.output_var.get(),
'overwrite': self.overwrite_var.get(),
'auto_open': self.auto_open_var.get()
}
self.conversion_running = True
self.failed_files = [] # Clear previous failures
self.progress_var.set(0)
self.status_var.set("Starting conversion...")
self.set_ui_state(False) # Disable UI
# Start conversion in a separate thread
self.log(f"--- Starting conversion of {len(self.selected_files)} files ---", "info")
self.conversion_thread = threading.Thread(target=self.convert_files_thread, daemon=True)
self.conversion_thread.start()
def convert_files_thread(self):
"""Worker thread for converting all selected files"""
total_files = len(self.selected_files)
success_count = 0
first_output_dir = None
# Make a copy of the list to iterate over, in case the main list is modified (though UI is disabled)
files_to_process = list(self.selected_files)
for i, input_file in enumerate(files_to_process, 1):
# Allow stopping mid-process if needed (e.g., future cancel button)
if not self.conversion_running:
self.log("Conversion cancelled.", "warning")
break
self.status_var.set(f"Converting {i}/{total_files}: {os.path.basename(input_file)}")
self.log(f"Processing ({i}/{total_files}): {input_file}", "info")
try:
output_path_obj, output_dir = self.get_output_path(input_file, self.current_settings)
output_file = str(output_path_obj)
# Store the first valid output directory for auto-open
if first_output_dir is None and output_dir:
first_output_dir = output_dir
# Check for overwrite
if not self.current_settings['overwrite'] and output_path_obj.exists():
self.log(f"Skipped (overwrite disabled and file exists): {output_file}", "warning")
# Don't count as success or failure? Or count as skipped success? For now, just skip.
continue # Move to next file
# Ensure output directory exists
try:
output_dir.mkdir(parents=True, exist_ok=True)
except OSError as e:
self.log(f"Error creating output directory {output_dir}: {e}. Skipping file.", "error")
self.failed_files.append(input_file)
continue # Move to next file
# Perform the conversion
success = self.convert_single_file(input_file, output_file, self.current_settings)
if success:
success_count += 1
self.log(f"Success: -> {output_file}", "success")
else:
self.failed_files.append(input_file)
# Error logged within convert_single_file
self.log(f"Failed: {os.path.basename(input_file)}", "error")
except Exception as e:
# Catch errors in path generation or other unexpected issues
self.failed_files.append(input_file)
self.log(f"Unexpected error processing {os.path.basename(input_file)}: {str(e)}", "error")
# Update progress bar (ensure update happens on main thread if needed, but often works)
# Using update_idletasks in log() helps push updates
self.progress_var.set((i / total_files) * 100)
# --- Conversion Loop Finished ---
# Final status update
final_message = f"Conversion complete: {success_count} succeeded, {len(self.failed_files)} failed."
self.status_var.set(final_message)
if not self.failed_files and success_count > 0:
self.log(f"--- All {success_count} files converted successfully! ---", "success")
elif self.failed_files:
self.log(f"--- Conversion finished with {len(self.failed_files)} failures. ---", "warning")
self.log("Failed files listed below:", "warning")
for failed in self.failed_files:
self.log(f" - {failed}", "error")
self.log("Use 'Retry Failed' to try converting these again.", "warning")
elif success_count == 0 and not self.failed_files:
self.log("--- Conversion finished. No files were processed (e.g., all skipped). ---", "info")
else: # Should not happen
self.log("--- Conversion finished. ---", "info")
# Auto-open output folder if requested and successful conversions happened
if success_count > 0 and self.current_settings['auto_open'] and first_output_dir:
self.log(f"Opening output folder: {first_output_dir}", "info")
self.open_output_folder(str(first_output_dir))
# Re-enable UI elements from the main thread using 'after'
self.after(100, self._finalize_conversion)
def _finalize_conversion(self):
"""Actions to perform on the main thread after conversion finishes."""
self.conversion_running = False
self.set_ui_state(True) # Re-enable UI
# Ensure progress bar is at 100 if successful, or reflects partial if stopped/failed
# self.progress_var.set(100) # Or keep the final percentage
def get_output_path(self, input_file, settings):
"""Determine the output Path object and directory Path object for a given input file."""
input_path = Path(input_file)
output_dir_setting = settings['output_dir_setting']
# Determine output directory
if output_dir_setting.lower() == 'same as source':
output_dir = input_path.parent
else:
output_dir = Path(output_dir_setting)
# Get format extension
fmt = settings['format'].lower()
# Basic validation, default to png if invalid from combo somehow
valid_formats = ['png', 'jpg', 'jpeg', 'pdf', 'tiff', 'tif', 'eps', 'svg', 'ps']
if fmt not in valid_formats:
self.log(f"Invalid format '{fmt}', defaulting to 'png'.", "warning")
fmt = 'png'
if fmt == 'jpeg': fmt = 'jpg' # Normalize jpeg to jpg
if fmt == 'tif': fmt = 'tiff' # Normalize tif to tiff
output_filename = f"{input_path.stem}.{fmt}"
output_path = output_dir / output_filename
return output_path, output_dir
def convert_single_file(self, input_file, output_file, settings):
"""Convert a single file using Inkscape CLI."""
if not self.inkscape_path:
self.log("Inkscape path is not set. Cannot convert.", "error")
return False
# Base command
cmd = [
self.inkscape_path,
input_file, # Already absolute path
f"--export-filename={output_file}",
f"--export-type={settings['format']}",
f"--export-dpi={settings['dpi']}",
]
# Add background only if format supports it (e.g., PNG, JPG, TIFF)
# PDF, EPS, SVG usually handle transparency differently or ignore background color export option.
if settings['format'].lower() in ['png', 'jpg', 'jpeg', 'tiff', 'tif']:
cmd.append(f"--export-background={settings['background']}")
# Optionally add background opacity if needed, e.g. "--export-background-opacity=1.0"
# Inkscape 1.0+ uses actions (more complex, but more powerful)
# Older Inkscape (0.9x) uses direct flags like above.
# The command above *should* work for Inkscape 1.0+ too for these basic options.
# If using actions:
# cmd = [ self.inkscape_path,
# f"--actions=open:{input_file};export-filename:{output_file};export-dpi:{settings['dpi']};export-background:{settings['background']};export-do",
# "--batch-process" ]
# Need to check Inkscape version to use the best method. For simplicity, stick to older flags first.
self.log(f"Executing: {' '.join(cmd)}", "info") # Log the command being run
try:
# Prevent console window popping up on Windows
startupinfo = None
if platform.system() == "Windows":
startupinfo = subprocess.STARTUPINFO()
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
startupinfo.wShowWindow = subprocess.SW_HIDE
# Run Inkscape
result = subprocess.run(cmd, capture_output=True, text=True, check=False, # check=False to handle errors manually
encoding='utf-8', errors='replace', # Handle potential encoding issues
startupinfo=startupinfo)
# Check return code
if result.returncode != 0:
self.log(f"Inkscape process failed for {os.path.basename(input_file)} (code {result.returncode}).", "error")
# Log stderr if it contains useful info
if result.stderr:
error_lines = result.stderr.strip().splitlines()
# Filter out common verbose/debug messages if needed
for line in error_lines[-5:]: # Log last few lines of error
self.log(f" Inkscape stderr: {line}", "error")
# Log stdout too, might contain clues
if result.stdout:
for line in result.stdout.strip().splitlines()[-5:]:
self.log(f" Inkscape stdout: {line}", "info")
# Attempt to delete potentially incomplete output file
try:
if Path(output_file).exists():
Path(output_file).unlink()
self.log(f"Deleted potentially incomplete output: {output_file}", "warning")
except Exception as del_e:
self.log(f"Could not delete incomplete output {output_file}: {del_e}", "warning")
return False
# Inkscape sometimes prints warnings to stderr even on success
if result.stderr:
# Filter known benign warnings if necessary
warnings = [line for line in result.stderr.strip().splitlines() if 'warning' in line.lower()]
if warnings:
self.log(f"Inkscape warnings for {os.path.basename(input_file)}:", "warning")
for warning in warnings[:3]: # Log first few warnings
self.log(f" {warning}", "warning")
# Final check: Does the output file actually exist?
if not Path(output_file).exists():
self.log(f"Inkscape process finished but output file not found: {output_file}", "error")
return False
if Path(output_file).stat().st_size == 0:
self.log(f"Inkscape process finished but output file is empty: {output_file}", "error")
try: Path(output_file).unlink() # Delete empty file
except: pass
return False
return True # Success
except FileNotFoundError:
self.log(f"Error: Inkscape command not found at '{self.inkscape_path}'. Please check the path.", "error")
# Stop the whole conversion? Or just fail this file?
# For now, just fail this file. Consider stopping if it happens repeatedly.
self.conversion_running = False # Stop if Inkscape itself is missing
self.after(100, self._finalize_conversion)
return False
except subprocess.TimeoutExpired:
self.log(f"Error: Inkscape process timed out for {os.path.basename(input_file)}.", "error")
return False
except Exception as e:
self.log(f"Unexpected error running Inkscape for {os.path.basename(input_file)}: {str(e)}", "error")
# Log traceback for debugging if needed
import traceback
self.log(traceback.format_exc(), "error")
return False
def retry_failed(self):
"""Retry conversion of files that failed in the last run."""
if not self.failed_files:
self.log("No failed files from the previous run to retry.", "info")
messagebox.showinfo("Retry Failed", "There are no files marked as failed from the last conversion attempt.", parent=self)
return
if self.conversion_running:
self.log("Cannot retry failed files while another conversion is running.", "warning")
return
self.log(f"--- Retrying conversion for {len(self.failed_files)} failed file(s) ---", "info")
# Set the list of files to process to only the failed ones
self.selected_files = list(self.failed_files) # Use a copy
self.refresh_file_list() # Update UI list
# Start a new conversion process for these files
self.start_conversion()
def open_output_folder(self, directory):
"""Open the specified directory in the system file manager."""
path = Path(directory)
if not path.exists() or not path.is_dir():
self.log(f"Cannot open folder: Directory '{directory}' does not exist.", "warning")
return
try:
if platform.system() == "Windows":
# Use os.startfile for broader compatibility
os.startfile(path)
elif platform.system() == "Darwin": # macOS
subprocess.run(["open", str(path)], check=True)
else: # Linux and other Unix-like
# xdg-open is the standard Freedesktop way
subprocess.run(["xdg-open", str(path)], check=True)
except FileNotFoundError as e:
# Handle case where 'open' or 'xdg-open' isn't found
self.log(f"Could not open output folder: Command not found ({e.filename}).", "error")
messagebox.showerror("Error", f"Could not find the command ('{e.filename}') to open the folder.\nPlease navigate to the folder manually:\n{directory}", parent=self)
except subprocess.CalledProcessError as e:
# Handle errors from the 'open' or 'xdg-open' command itself
self.log(f"Error opening output folder '{directory}': {e}", "error")
messagebox.showerror("Error", f"Failed to open the output folder.\nPlease navigate to it manually:\n{directory}", parent=self)
except Exception as e:
# Catch-all for other unexpected errors
self.log(f"An unexpected error occurred while trying to open the output folder: {str(e)}", "error")
messagebox.showerror("Error", f"An unexpected error occurred opening the folder.\nPlease navigate to it manually:\n{directory}", parent=self)
def on_close(self):
"""Handle window close event: confirm if conversion running, save settings."""
if self.conversion_running:
if messagebox.askokcancel(
"Conversion in Progress",
"A conversion process is currently running.\nQuitting now will stop the process.\n\nAre you sure you want to quit?",
parent=self # Make modal to this window
):
self.log("User aborted conversion by closing window.", "warning")
self.conversion_running = False # Signal thread to stop (if thread checks it)
# Wait briefly for thread to potentially finish current file? Optional.
# time.sleep(0.5)
self.save_settings() # Still try to save settings
self.destroy()
else:
return # Don't close the window
# If not running or user confirmed quit while running
self.save_settings()
self.destroy()
if __name__ == "__main__":
# Set up high DPI awareness on Windows if possible
try:
if platform.system() == "Windows":
from ctypes import windll
# Query DPI Awareness before setting it
# awareness = windll.shcore.GetProcessDpiAwareness(0)
# print(f"Current DPI Awareness: {awareness}") # 0=unaware, 1=System Aware, 2=Per Monitor Aware
windll.shcore.SetProcessDpiAwareness(1) # 1 = System Aware
except Exception as e:
print(f"Could not set DPI awareness: {e}")
# Create and run the application
app = SVGtoPNGConverter()
app.mainloop()