1211 lines
59 KiB
Python
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()
|