1163 lines
No EOL
61 KiB
Python
1163 lines
No EOL
61 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, TclError,
|
|
BOTH, X, Y, LEFT, RIGHT, VERTICAL, HORIZONTAL, W, E, EW, END, WORD, SUNKEN, MULTIPLE,
|
|
NS, NSEW) # Import missing constants
|
|
from tkinter import ttk, filedialog, messagebox
|
|
# tkinterdnd2 needs to be installed: pip install tkinterdnd2
|
|
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 (Optional, but good practice)
|
|
try:
|
|
from PIL import Image, ImageTk
|
|
except ImportError:
|
|
print("Warning: Pillow (PIL) not found. Image operations might be limited.")
|
|
|
|
# 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):
|
|
# Updated theme colors based on BlenderBIM specification
|
|
BLENDER_THEME = {
|
|
"bg": "#323232", # --blender-bg (Primary Background) - Use for main window
|
|
"panel_bg": "#252526", # --blender-panel (Panel Background) - Use for frames, widgets
|
|
"highlight": "#4f84d1", # --blender-highlight (Highlight Color) - Active, selected, focus
|
|
"highlight_hover": "#679ae0", # --blender-highlight-hover (Not directly mappable in ttk 'active', use highlight)
|
|
"text": "#e0e0e0", # --blender-text (Text)
|
|
"text_input": "#eeeeee", # --blender-input-text (Input Text)
|
|
"border": "#1e1e1e", # --blender-border (Borders) - Crucial for definition
|
|
"button": "#535353", # --blender-button (Buttons Default)
|
|
"button_hover": "#4f84d1", # --blender-button Hover (use highlight)
|
|
"button_active": "#4f84d1",# --blender-button Active (use highlight for pressed)
|
|
"danger_button": "#a04040", # --blender-danger-button (Not used yet, but defined)
|
|
"danger_button_hover": "#c05050", # --blender-danger-button-hover (Not used yet)
|
|
"input_bg": "#444444", # --blender-input-bg (Input Background)
|
|
"disabled_text": "#888888", # --blender-disabled-text (Disabled Text)
|
|
"section_bg": "#3a3a3a", # --blender-section-bg (Section Headers) - For LabelFrame?
|
|
"subsection_bg": "#424242", # --blender-subsection-bg (Property Groups) - Could use for inner frames if needed
|
|
"scrollbar_thumb": "#535353", # Scrollbar thumb (use button color)
|
|
"scrollbar_trough": "#444444", # Scrollbar track (use input bg)
|
|
"scrollbar_hover": "#4f84d1" # Scrollbar hover (use highlight)
|
|
}
|
|
|
|
# Updated fonts based on BlenderBIM specification
|
|
BLENDER_FONTS = {
|
|
"primary": ("Segoe UI", 13), # Base Font Size: 13px
|
|
"monospace": ("Consolas", 12), # Monospace Font (adjust size if needed)
|
|
"header": ("Segoe UI", 16, "bold"), # Header h2: ~1.2em -> ~16px Bold
|
|
"section": ("Segoe UI", 13, "bold"), # Section h4: 1em -> 13px Bold (weight 600 ~ bold)
|
|
"label": ("Segoe UI", 12), # Control labels: 0.9em -> ~12px
|
|
"small": ("Segoe UI", 11), # Small text: 0.85em -> ~11px
|
|
}
|
|
|
|
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.title("SVG Converter Pro (BlenderBIM Style)") # New title
|
|
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() / ".svg_converter_pro_settings.json" # New settings file name
|
|
self.load_settings()
|
|
|
|
# Setup UI *before* applying theme
|
|
self.style = ttk.Style(self)
|
|
self.setup_ui() # UI elements are created here
|
|
|
|
# Check Inkscape availability
|
|
self.inkscape_path = self.find_inkscape()
|
|
if not self.inkscape_path:
|
|
self.log("Warning: Inkscape not found. Conversion disabled until path is valid.", "warning")
|
|
else:
|
|
self.log(f"Found Inkscape: {self.inkscape_path}", "info")
|
|
|
|
# Configure window close behavior
|
|
self.protocol("WM_DELETE_WINDOW", self.on_close)
|
|
|
|
# Apply the initial theme (light or dark)
|
|
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
|
|
return False
|
|
|
|
def create_blender_theme(self):
|
|
"""Creates the 'blenderbim' ttk theme based on the detailed spec."""
|
|
if 'blenderbim' in self.style.theme_names():
|
|
return True # Already exists
|
|
|
|
try:
|
|
# Use 'clam' as parent, it's often cleaner for custom styling
|
|
self.style.theme_create("blenderbim", parent="clam")
|
|
|
|
# Define settings based on spec
|
|
self.style.theme_settings("blenderbim", {
|
|
".": { # Root style
|
|
"configure": {
|
|
"background": self.BLENDER_THEME["panel_bg"], # Default widget bg
|
|
"foreground": self.BLENDER_THEME["text"], # Default text
|
|
"bordercolor": self.BLENDER_THEME["border"], # Default border
|
|
"focuscolor": self.BLENDER_THEME["highlight"], # Color for focus ring (theme decides how to draw)
|
|
"font": self.BLENDER_FONTS["primary"],
|
|
"lightcolor": self.BLENDER_THEME["panel_bg"], # Used in some reliefs
|
|
"darkcolor": self.BLENDER_THEME["border"], # Used in some reliefs
|
|
"relief": "flat", # Generally flat look
|
|
"borderwidth": 0, # Default to no border unless specified
|
|
}
|
|
},
|
|
"TFrame": { # Style for ttk Frames
|
|
"configure": {
|
|
"background": self.BLENDER_THEME["panel_bg"],
|
|
"relief": "flat",
|
|
"borderwidth": 0,
|
|
}
|
|
},
|
|
"TLabel": { # Style for ttk Labels
|
|
"configure": {
|
|
"background": self.BLENDER_THEME["panel_bg"],
|
|
"foreground": self.BLENDER_THEME["text"],
|
|
"font": self.BLENDER_FONTS["label"], # Use label font size
|
|
"padding": (0, 2), # Add a little vertical padding
|
|
"anchor": "w", # Default to left-align
|
|
}
|
|
},
|
|
"TLabelFrame": { # Style for LabelFrames (used for sections)
|
|
"configure": {
|
|
"background": self.BLENDER_THEME["panel_bg"], # Background *around* the frame
|
|
"foreground": self.BLENDER_THEME["text"], # Text color of the label
|
|
"font": self.BLENDER_FONTS["section"], # Use section font
|
|
"relief": "solid", # Spec implies border
|
|
"borderwidth": 1,
|
|
"bordercolor": self.BLENDER_THEME["border"],
|
|
"padding": 10, # Padding inside the frame
|
|
}
|
|
},
|
|
"TLabelFrame.Label": { # Style specifically for the Label within the LabelFrame
|
|
"configure": {
|
|
"background": self.BLENDER_THEME["panel_bg"], # Match parent frame bg
|
|
"foreground": self.BLENDER_THEME["text"],
|
|
"font": self.BLENDER_FONTS["section"],
|
|
"padding": (5, 0, 5, 2), # Adjust padding (left, top, right, bottom)
|
|
}
|
|
},
|
|
"TButton": { # Style for ttk Buttons
|
|
"configure": {
|
|
"background": self.BLENDER_THEME["button"],
|
|
"foreground": self.BLENDER_THEME["text"],
|
|
"bordercolor": self.BLENDER_THEME["border"],
|
|
"borderwidth": 1,
|
|
"font": self.BLENDER_FONTS["primary"],
|
|
"padding": (8, 4), # Horizontal, Vertical padding
|
|
"anchor": "center",
|
|
"relief": "raised", # Gives a slight 3D look, common in Blender
|
|
},
|
|
"map": {
|
|
"background": [
|
|
("pressed", "!disabled", self.BLENDER_THEME["button_active"]), # Pressed state uses active color
|
|
("active", "!disabled", self.BLENDER_THEME["button_hover"]), # Hover state ('active' in ttk)
|
|
("disabled", self.BLENDER_THEME["panel_bg"]) # Disabled background
|
|
],
|
|
"foreground": [("disabled", self.BLENDER_THEME["disabled_text"])],
|
|
"relief": [("pressed", "!disabled", "sunken")], # Sunken relief when pressed
|
|
"bordercolor": [
|
|
("focus", self.BLENDER_THEME["highlight"]), # Border becomes highlight on focus
|
|
("!focus", self.BLENDER_THEME["border"])
|
|
]
|
|
}
|
|
},
|
|
"TEntry": { # Style for ttk Entry widgets
|
|
"configure": {
|
|
"fieldbackground": self.BLENDER_THEME["input_bg"],
|
|
"foreground": self.BLENDER_THEME["text_input"],
|
|
"bordercolor": self.BLENDER_THEME["border"],
|
|
"borderwidth": 1,
|
|
"font": self.BLENDER_FONTS["primary"],
|
|
"insertcolor": self.BLENDER_THEME["text_input"], # Cursor color
|
|
"insertwidth": 1,
|
|
"padding": (5, 4), # Horizontal, Vertical padding
|
|
"relief": "solid", # Flat border
|
|
},
|
|
"map": {
|
|
"bordercolor": [("focus", self.BLENDER_THEME["highlight"])], # Highlight border on focus
|
|
"fieldbackground": [("disabled", self.BLENDER_THEME["panel_bg"])],
|
|
"foreground": [("disabled", self.BLENDER_THEME["disabled_text"])],
|
|
}
|
|
},
|
|
"TSpinbox": { # Style for ttk Spinbox widgets
|
|
"configure": {
|
|
# Inherits fieldbackground, foreground from TEntry via 'clam' parent
|
|
"bordercolor": self.BLENDER_THEME["border"],
|
|
"borderwidth": 1,
|
|
"arrowcolor": self.BLENDER_THEME["text"],
|
|
"background": self.BLENDER_THEME["button"], # Background of the arrow buttons
|
|
"relief": "solid",
|
|
"arrowsize": 10, # Adjust size if needed
|
|
"padding": (5, 4),
|
|
},
|
|
"map": {
|
|
"background": [ # Button background hover
|
|
("active", "!disabled", self.BLENDER_THEME["button_hover"]),
|
|
("disabled", self.BLENDER_THEME["panel_bg"])
|
|
],
|
|
"foreground": [("disabled", self.BLENDER_THEME["disabled_text"])], # Text color
|
|
"bordercolor": [("focus", self.BLENDER_THEME["highlight"])], # Highlight border on focus
|
|
"arrowcolor": [("disabled", self.BLENDER_THEME["disabled_text"])],
|
|
}
|
|
},
|
|
"TCombobox": { # Style for ttk Combobox widgets
|
|
"configure": {
|
|
"fieldbackground": self.BLENDER_THEME["input_bg"],
|
|
"foreground": self.BLENDER_THEME["text_input"],
|
|
"bordercolor": self.BLENDER_THEME["border"],
|
|
"borderwidth": 1,
|
|
"arrowcolor": self.BLENDER_THEME["text"],
|
|
"background": self.BLENDER_THEME["button"], # Arrow button background
|
|
"insertcolor": self.BLENDER_THEME["text_input"],
|
|
"padding": (5, 4),
|
|
"relief": "solid",
|
|
"arrowsize": 12,
|
|
},
|
|
"map": {
|
|
"bordercolor": [("focus", self.BLENDER_THEME["highlight"])],
|
|
# Background of the dropdown arrow button
|
|
"background": [
|
|
("active", "!disabled", self.BLENDER_THEME["button_hover"]), # Hover state
|
|
("readonly", self.BLENDER_THEME["button"]), # Keep button color when readonly
|
|
("disabled", self.BLENDER_THEME["panel_bg"])
|
|
],
|
|
"fieldbackground": [("disabled", self.BLENDER_THEME["panel_bg"]),
|
|
("readonly", self.BLENDER_THEME["input_bg"])],
|
|
"foreground": [("disabled", self.BLENDER_THEME["disabled_text"]),
|
|
("readonly", self.BLENDER_THEME["text_input"])],
|
|
"arrowcolor": [("disabled", self.BLENDER_THEME["disabled_text"])],
|
|
}
|
|
},
|
|
# Style the dropdown list part of the Combobox
|
|
# Note: This might not work reliably on all platforms/ttk versions
|
|
"TCombobox.Listbox": {
|
|
"configure": {
|
|
"background": self.BLENDER_THEME["input_bg"],
|
|
"foreground": self.BLENDER_THEME["text_input"],
|
|
"selectbackground": self.BLENDER_THEME["highlight"],
|
|
"selectforeground": self.BLENDER_THEME["text_input"], # Keep input text color on selection
|
|
"font": self.BLENDER_FONTS["primary"],
|
|
"borderwidth": 1,
|
|
"bordercolor": self.BLENDER_THEME["border"],
|
|
"relief":"solid",
|
|
}
|
|
},
|
|
"TCheckbutton": { # Style for ttk Checkbuttons
|
|
"configure": {
|
|
"background": self.BLENDER_THEME["panel_bg"], # Background of the label area
|
|
"foreground": self.BLENDER_THEME["text"], # Label text color
|
|
"font": self.BLENDER_FONTS["label"],
|
|
"indicatorbackground": self.BLENDER_THEME["input_bg"], # Box bg when off
|
|
"indicatorforeground": self.BLENDER_THEME["text"], # Checkmark color
|
|
"indicatormargin": 2,
|
|
"indicatorrelief": "solid",
|
|
"indicatorborderwidth": 1,
|
|
"indicatorbordercolor": self.BLENDER_THEME["border"],
|
|
"padding": (5, 3),
|
|
},
|
|
"map": {
|
|
"foreground": [("disabled", self.BLENDER_THEME["disabled_text"])],
|
|
"indicatorbackground": [
|
|
("selected", self.BLENDER_THEME["highlight"]), # Box bg when checked
|
|
("pressed", self.BLENDER_THEME["highlight"]), # Box bg while pressing
|
|
("active", self.BLENDER_THEME["input_bg"]) # Box bg on hover (keep it same as default off)
|
|
],
|
|
"indicatorbordercolor": [("disabled", self.BLENDER_THEME["disabled_text"])]
|
|
# Checkmark color could also be mapped if needed
|
|
# "indicatorforeground": [("selected", self.BLENDER_THEME["text"])]
|
|
}
|
|
},
|
|
"Vertical.TScrollbar": { # Style for Vertical Scrollbars
|
|
"configure": {
|
|
"background": self.BLENDER_THEME["scrollbar_thumb"], # Thumb color
|
|
"troughcolor": self.BLENDER_THEME["scrollbar_trough"], # Track color
|
|
"bordercolor": self.BLENDER_THEME["border"], # Border around track/thumb
|
|
"arrowcolor": self.BLENDER_THEME["text"], # Arrow color
|
|
"relief": "flat",
|
|
"borderwidth": 0,
|
|
"width": 12, # Slightly wider scrollbar
|
|
"arrowsize": 14,
|
|
},
|
|
"map": {
|
|
"background": [ # Thumb hover color
|
|
("active", self.BLENDER_THEME["scrollbar_hover"])
|
|
],
|
|
"troughcolor": [], # Prevent trough changing color on hover etc.
|
|
"bordercolor": [],
|
|
"arrowcolor": [("disabled", self.BLENDER_THEME["disabled_text"])]
|
|
}
|
|
},
|
|
"Horizontal.TScrollbar": { # Style for Horizontal Scrollbars
|
|
"configure": {
|
|
"background": self.BLENDER_THEME["scrollbar_thumb"],
|
|
"troughcolor": self.BLENDER_THEME["scrollbar_trough"],
|
|
"bordercolor": self.BLENDER_THEME["border"],
|
|
"arrowcolor": self.BLENDER_THEME["text"],
|
|
"relief": "flat",
|
|
"borderwidth": 0,
|
|
"height": 12, # Match width
|
|
"arrowsize": 14,
|
|
},
|
|
"map": {
|
|
"background": [("active", self.BLENDER_THEME["scrollbar_hover"])],
|
|
"troughcolor": [],
|
|
"bordercolor": [],
|
|
"arrowcolor": [("disabled", self.BLENDER_THEME["disabled_text"])]
|
|
}
|
|
},
|
|
"TProgressbar": { # Style for Progress bars
|
|
"configure": {
|
|
"background": self.BLENDER_THEME["highlight"], # The moving bar color
|
|
"troughcolor": self.BLENDER_THEME["input_bg"], # The background track
|
|
"bordercolor": self.BLENDER_THEME["border"],
|
|
"borderwidth": 1,
|
|
"relief": "solid",
|
|
"thickness": 20, # Height of the bar
|
|
}
|
|
}
|
|
}) # End of theme_settings
|
|
|
|
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: self.style.theme_delete("blenderbim")
|
|
except: pass
|
|
return False
|
|
|
|
def setup_ui(self):
|
|
"""Setup the main user interface elements"""
|
|
# Main container - Use standard Frame, theme handles background
|
|
main_frame = Frame(self, name="main_frame") # Give it a name for easier targeting if needed
|
|
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 horizontally
|
|
|
|
# File list - Use standard Listbox, style manually in update_theme
|
|
self.file_list = Listbox(file_frame, selectmode=MULTIPLE, height=8,
|
|
font=self.BLENDER_FONTS["primary"], # Use primary font
|
|
exportselection=False, # Prevent selection loss on focus change
|
|
# Border/bg/fg set in update_theme
|
|
)
|
|
self.file_list.grid(row=0, column=0, sticky="nsew", pady=(0, 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=(0, 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 control buttons
|
|
file_controls = ttk.Frame(file_frame, style="TFrame") # Ensure frame uses theme bg
|
|
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=(10, 2)) # Add space above remove
|
|
ttk.Button(file_controls, text="Clear All", command=self.clear_files, width=10).pack(fill=X, pady=2)
|
|
|
|
# Drag and drop setup
|
|
self.file_list.drop_target_register(DND_FILES)
|
|
self.file_list.dnd_bind('<<Drop>>', self.handle_drop)
|
|
file_frame.drop_target_register(DND_FILES)
|
|
file_frame.dnd_bind('<<Drop>>', self.handle_drop)
|
|
self.drop_target_register(DND_FILES)
|
|
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 DPI/BG inputs some space
|
|
settings_frame.columnconfigure(3, weight=1) # Allow Output entry to expand more
|
|
|
|
# Row 0: DPI, Background, Format
|
|
ttk.Label(settings_frame, text="DPI:", font=self.BLENDER_FONTS["label"]).grid(row=0, column=0, sticky=W, padx=(0, 5), pady=2)
|
|
self.dpi_var = IntVar(value=self.settings.get('dpi', 96))
|
|
# Use TSpinbox for consistency
|
|
ttk.Spinbox(settings_frame, from_=72, to=1200, increment=24, textvariable=self.dpi_var, width=7, font=self.BLENDER_FONTS["primary"]).grid(row=0, column=1, sticky=W, pady=2)
|
|
|
|
ttk.Label(settings_frame, text="Background:", font=self.BLENDER_FONTS["label"]).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 for consistent bg
|
|
bg_frame.grid(row=0, column=3, sticky=W, pady=2)
|
|
self.bg_entry = ttk.Entry(bg_frame, textvariable=self.bg_var, width=10, font=self.BLENDER_FONTS["primary"])
|
|
self.bg_entry.pack(side=LEFT)
|
|
# Color swatch - Use standard Label, style manually
|
|
self.bg_swatch = Label(bg_frame, text=" ", relief="solid", borderwidth=1)
|
|
self.bg_swatch.pack(side=LEFT, padx=(4, 5))
|
|
self.bg_var.trace_add("write", lambda *args: self.update_color_swatch())
|
|
ttk.Button(bg_frame, text="Pick", command=self.pick_color, width=5).pack(side=LEFT) # Smaller padding
|
|
|
|
ttk.Label(settings_frame, text="Format:", font=self.BLENDER_FONTS["label"]).grid(row=0, column=4, sticky=W, padx=(15, 5), pady=2)
|
|
self.format_var = StringVar(value=self.settings.get('format', 'png'))
|
|
formats = ['png', 'jpg', 'pdf', 'tiff', 'eps', 'svg', 'ps', 'webp'] # Added webp
|
|
ttk.Combobox(settings_frame, textvariable=self.format_var, values=formats, width=7, state='readonly', font=self.BLENDER_FONTS["primary"]).grid(row=0, column=5, sticky=W, pady=2)
|
|
|
|
# Row 1: Output Directory
|
|
ttk.Label(settings_frame, text="Output Dir:", font=self.BLENDER_FONTS["label"]).grid(row=1, column=0, sticky=W, padx=(0, 5), pady=(8, 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=(8, 2))
|
|
output_frame.columnconfigure(0, weight=1) # Make entry expand
|
|
|
|
self.output_entry = ttk.Entry(output_frame, textvariable=self.output_var, font=self.BLENDER_FONTS["primary"])
|
|
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))
|
|
|
|
# Row 2: Options (Checkboxes)
|
|
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 vertically
|
|
log_frame.columnconfigure(0, weight=1) # Make text area expand horizontally
|
|
|
|
# Log area - Use standard ScrolledText, style manually
|
|
self.log_area = scrolledtext.ScrolledText(log_frame, wrap=WORD, state='disabled', height=10,
|
|
font=self.BLENDER_FONTS["monospace"], # Use monospace
|
|
# Border/bg/fg set in update_theme
|
|
)
|
|
self.log_area.grid(row=0, column=0, sticky="nsew")
|
|
|
|
# Configure log tags (initial colors, updated in update_theme)
|
|
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=(8, 0)) # Increased padding top
|
|
|
|
# Status bar - Use a Label inside the log_frame
|
|
self.status_var = StringVar(value="Ready")
|
|
# Use label font for status
|
|
status_bar = ttk.Label(log_frame, textvariable=self.status_var, anchor=W, font=self.BLENDER_FONTS["label"])
|
|
status_bar.grid(row=2, column=0, sticky=EW, pady=(5, 0))
|
|
|
|
# --- Control Buttons ---
|
|
control_frame = ttk.Frame(main_frame) # Use ttk.Frame
|
|
control_frame.pack(fill=X, pady=(5, 0))
|
|
|
|
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, state='disabled')
|
|
self.retry_button.pack(side=LEFT, padx=(0, 10))
|
|
|
|
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)
|
|
|
|
# Update swatch initially
|
|
self.update_color_swatch()
|
|
|
|
|
|
def update_color_swatch(self):
|
|
"""Updates the background color preview swatch."""
|
|
try:
|
|
color = self.bg_var.get()
|
|
# Ensure valid color string before applying
|
|
self.winfo_rgb(color) # Check if color is valid
|
|
self.bg_swatch.config(bg=color,
|
|
# Match border style to theme
|
|
highlightbackground=self.BLENDER_THEME["border"] if self.dark_mode else "gray",
|
|
highlightcolor=self.BLENDER_THEME["border"] if self.dark_mode else "gray",
|
|
highlightthickness=1)
|
|
except TclError:
|
|
# Handle invalid color string temporarily entered
|
|
self.bg_swatch.config(bg=self.BLENDER_THEME["input_bg"] if self.dark_mode else "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 dark theme.")
|
|
else:
|
|
theme_to_use = 'alt' # Fallback dark theme
|
|
print("BlenderBIM theme failed or not found, falling back to 'alt'.")
|
|
self.log("Failed to apply custom dark theme, using fallback.", "warning")
|
|
|
|
# Apply ttk theme
|
|
self.style.theme_use(theme_to_use)
|
|
|
|
# --- Manual configuration for Dark Mode ---
|
|
# Configure root window background
|
|
self.configure(background=self.BLENDER_THEME["bg"])
|
|
|
|
# Configure standard Frames (like main_frame) if needed
|
|
# self.nametowidget("main_frame").configure(background=self.BLENDER_THEME["bg"]) # Can cause issues if frames inside have different bg
|
|
|
|
# Configure non-ttk widgets to match Blender theme
|
|
listbox_bg = self.BLENDER_THEME["input_bg"]
|
|
listbox_fg = self.BLENDER_THEME["text_input"]
|
|
listbox_select_bg = self.BLENDER_THEME["highlight"]
|
|
listbox_border = self.BLENDER_THEME["border"]
|
|
|
|
self.file_list.configure(
|
|
background=listbox_bg,
|
|
foreground=listbox_fg,
|
|
selectbackground=listbox_select_bg,
|
|
selectforeground=listbox_fg, # Keep text color same on selection
|
|
relief="solid",
|
|
borderwidth=1,
|
|
highlightthickness=1, # Use highlightthickness for border on non-ttk
|
|
highlightbackground=listbox_border, # Color when not focused
|
|
highlightcolor=listbox_border # Color when focused
|
|
)
|
|
|
|
log_bg = self.BLENDER_THEME["input_bg"]
|
|
log_fg = self.BLENDER_THEME["text_input"]
|
|
log_select_bg = self.BLENDER_THEME["highlight"]
|
|
log_border = self.BLENDER_THEME["border"]
|
|
|
|
self.log_area.configure(
|
|
background=log_bg,
|
|
foreground=log_fg,
|
|
insertbackground=self.BLENDER_THEME["text"], # Cursor color
|
|
selectbackground=log_select_bg,
|
|
selectforeground=log_fg, # Keep text color same on selection
|
|
relief="solid",
|
|
borderwidth=1,
|
|
highlightthickness=1,
|
|
highlightbackground=log_border,
|
|
highlightcolor=log_border,
|
|
# Ensure scrollbar frame matches
|
|
# wrap=WORD # Already set
|
|
)
|
|
# Style the frame holding the ScrolledText's scrollbar
|
|
# This is internal to ScrolledText, might be fragile
|
|
try:
|
|
# Access the frame Tkinter automatically creates around the Text widget
|
|
log_text_widget = self.log_area.winfo_children()[0] # Should be the Text widget
|
|
log_text_widget.configure(background=log_bg, foreground=log_fg, insertbackground=self.BLENDER_THEME["text"])
|
|
# The scrollbar is usually a sibling within the ScrolledText frame
|
|
# We rely on ttk theme for scrollbar styling here
|
|
except Exception as e:
|
|
print(f"Minor issue styling log area internals: {e}")
|
|
|
|
|
|
# Update tag colors for dark mode readability
|
|
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' # Use clam for light mode
|
|
self.style.theme_use(theme_to_use)
|
|
print(f"Using {theme_to_use} light theme.")
|
|
|
|
# Configure root window background to system default
|
|
self.configure(background='SystemButtonFace')
|
|
|
|
# Configure non-ttk widgets for standard light theme look
|
|
listbox_bg = 'white'
|
|
listbox_fg = 'black'
|
|
listbox_select_bg = '#0078d7' # Standard Windows blue selection
|
|
listbox_select_fg = 'white'
|
|
listbox_border = 'gray'
|
|
|
|
self.file_list.configure(
|
|
background=listbox_bg,
|
|
foreground=listbox_fg,
|
|
selectbackground=listbox_select_bg,
|
|
selectforeground=listbox_select_fg,
|
|
relief="solid",
|
|
borderwidth=1,
|
|
highlightthickness=1,
|
|
highlightbackground=listbox_border,
|
|
highlightcolor=listbox_border
|
|
)
|
|
|
|
log_bg = 'white'
|
|
log_fg = 'black'
|
|
log_select_bg = '#0078d7'
|
|
log_select_fg = 'white'
|
|
log_border = 'gray'
|
|
|
|
self.log_area.configure(
|
|
background=log_bg,
|
|
foreground=log_fg,
|
|
insertbackground=log_fg, # Black cursor
|
|
selectbackground=log_select_bg,
|
|
selectforeground=log_select_fg,
|
|
relief="solid",
|
|
borderwidth=1,
|
|
highlightthickness=1,
|
|
highlightbackground=log_border,
|
|
highlightcolor=log_border
|
|
)
|
|
# Style the frame holding the ScrolledText's scrollbar
|
|
try:
|
|
log_text_widget = self.log_area.winfo_children()[0]
|
|
log_text_widget.configure(background=log_bg, foreground=log_fg, insertbackground=log_fg)
|
|
except Exception as e:
|
|
print(f"Minor issue styling log area internals (light): {e}")
|
|
|
|
# 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="🌙")
|
|
|
|
# Update color swatch after theme change
|
|
self.update_color_swatch()
|
|
# Force redraw of the whole window to apply changes
|
|
self.update_idletasks()
|
|
|
|
|
|
def toggle_theme(self):
|
|
"""Toggle between dark (BlenderBIM) and light (clam) mode"""
|
|
self.dark_mode = not self.dark_mode
|
|
self.log(f"Switched to {'Dark (BlenderBIM)' if self.dark_mode else 'Light (Clam)'} Mode", "info")
|
|
self.update_theme()
|
|
# Settings are saved on close
|
|
|
|
def load_settings(self):
|
|
"""Load settings from file"""
|
|
default_settings = {
|
|
'dpi': 96,
|
|
'background': '#FFFFFF',
|
|
'format': 'png',
|
|
'output_dir': 'Same as source',
|
|
'overwrite': False,
|
|
'auto_open': False,
|
|
'window_geometry': '900x700',
|
|
'dark_mode': self._detect_dark_mode(),
|
|
'inkscape_path': None # Store user-provided path
|
|
}
|
|
|
|
try:
|
|
if self.settings_file.exists():
|
|
with open(self.settings_file, 'r') as f:
|
|
loaded_settings = json.load(f)
|
|
self.settings = {**default_settings, **loaded_settings}
|
|
# Allow saved dark_mode preference to override detection *if* darkdetect fails or is absent
|
|
if 'force_dark_mode' in loaded_settings:
|
|
self.settings['dark_mode'] = loaded_settings['force_dark_mode']
|
|
else: # Otherwise re-detect
|
|
self.settings['dark_mode'] = self._detect_dark_mode()
|
|
else:
|
|
self.settings = default_settings
|
|
self.log("No settings file found, using defaults.", "info")
|
|
|
|
self.dark_mode = self.settings.get('dark_mode', False)
|
|
self.geometry(self.settings.get('window_geometry', '900x700'))
|
|
# Use saved inkscape path if available
|
|
self.inkscape_path = self.settings.get('inkscape_path', None)
|
|
|
|
except Exception as e:
|
|
self.log(f"Error loading settings: {str(e)}. Using defaults.", "error")
|
|
self.settings = default_settings
|
|
self.dark_mode = self.settings.get('dark_mode', False)
|
|
self.geometry(self.settings.get('window_geometry', '900x700'))
|
|
self.inkscape_path = None
|
|
|
|
|
|
def save_settings(self):
|
|
"""Save current settings to file"""
|
|
settings_to_save = {}
|
|
# Ensure UI elements exist before saving from them
|
|
if hasattr(self, 'dpi_var'): settings_to_save['dpi'] = self.dpi_var.get()
|
|
if hasattr(self, 'bg_var'): settings_to_save['background'] = self.bg_var.get()
|
|
if hasattr(self, 'format_var'): settings_to_save['format'] = self.format_var.get()
|
|
if hasattr(self, 'output_var'): settings_to_save['output_dir'] = self.output_var.get()
|
|
if hasattr(self, 'overwrite_var'): settings_to_save['overwrite'] = self.overwrite_var.get()
|
|
if hasattr(self, 'auto_open_var'): settings_to_save['auto_open'] = self.auto_open_var.get()
|
|
try: settings_to_save['window_geometry'] = self.geometry()
|
|
except TclError: pass # Window might be destroyed
|
|
# Save the user's *current* dark mode choice
|
|
settings_to_save['force_dark_mode'] = self.dark_mode
|
|
# Save the inkscape path (might be None or user-provided)
|
|
settings_to_save['inkscape_path'] = self.inkscape_path
|
|
|
|
# Merge with existing settings to preserve any unknown keys
|
|
self.settings.update(settings_to_save)
|
|
|
|
try:
|
|
with open(self.settings_file, 'w') as f:
|
|
json.dump(self.settings, f, indent=4)
|
|
except Exception as e:
|
|
# Log to console as GUI might be closing
|
|
print(f"Error saving settings: {str(e)}")
|
|
|
|
|
|
def find_inkscape(self):
|
|
"""Find Inkscape executable, using saved path first."""
|
|
# 0. Use path from settings if it exists and is valid
|
|
if self.inkscape_path and Path(self.inkscape_path).is_file():
|
|
print(f"Using Inkscape path from settings: {self.inkscape_path}")
|
|
return self.inkscape_path
|
|
|
|
# 1. Check PATH
|
|
inkscape_exe = "inkscape.exe" if platform.system() == "Windows" else "inkscape"
|
|
try:
|
|
cmd = ["where", "inkscape"] if platform.system() == "Windows" else ["which", "inkscape"]
|
|
# Prevent console window pop-up on Windows when running 'where'
|
|
startupinfo = None
|
|
if platform.system() == "Windows":
|
|
startupinfo = subprocess.STARTUPINFO()
|
|
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
|
startupinfo.wShowWindow = subprocess.SW_HIDE
|
|
|
|
result = subprocess.run(cmd, capture_output=True, text=True, check=False, startupinfo=startupinfo)
|
|
if result.returncode == 0:
|
|
paths = result.stdout.splitlines()
|
|
for p in paths:
|
|
p_strip = p.strip()
|
|
if p_strip and Path(p_strip).is_file() and p_strip.lower().endswith(inkscape_exe.lower()):
|
|
self.log(f"Found Inkscape in PATH: {p_strip}", "info")
|
|
self.inkscape_path = p_strip # Save found path
|
|
return p_strip
|
|
except Exception as e:
|
|
self.log(f"Error checking PATH for Inkscape: {e}", "warning")
|
|
|
|
# 2. Check common locations
|
|
common_paths = []
|
|
# (Same common paths as before)
|
|
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_x86) / "Inkscape" / "bin" / inkscape_exe,
|
|
Path(local_app_data) / "Programs" / "Inkscape" / "bin" / inkscape_exe if local_app_data else None,
|
|
Path(program_files) / "Inkscape" / inkscape_exe, # Older?
|
|
])
|
|
elif platform.system() == "Darwin": # macOS
|
|
common_paths.extend([
|
|
Path("/Applications/Inkscape.app/Contents/MacOS/inkscape"),
|
|
Path("/usr/local/bin/inkscape"), # Homebrew
|
|
Path("/opt/homebrew/bin/inkscape"), # Apple Silicon 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("/app/bin/inkscape"), # Flatpak
|
|
Path("/opt/inkscape/bin/inkscape"),
|
|
])
|
|
|
|
for path in filter(None, common_paths):
|
|
if path.exists() and path.is_file():
|
|
self.log(f"Found Inkscape via common path: {path}", "info")
|
|
self.inkscape_path = str(path) # Save found path
|
|
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?", parent=self):
|
|
exe_filter = [("Executable files", f"inkscape{'.exe' if platform.system() == 'Windows' else ''}"), ("All files", "*.*")]
|
|
manual_path = filedialog.askopenfilename(parent=self, title="Locate Inkscape Executable", filetypes=exe_filter)
|
|
if manual_path and Path(manual_path).is_file():
|
|
self.log(f"User provided Inkscape path: {manual_path}", "info")
|
|
self.inkscape_path = manual_path # Save user path
|
|
# No need to save settings here, saved on close
|
|
return manual_path
|
|
# If user cancels or provides invalid path
|
|
self.inkscape_path = None
|
|
return None
|
|
|
|
# --- Log, File Handling, Conversion Logic (Mostly unchanged) ---
|
|
|
|
def log(self, message, level="info"):
|
|
"""Add a message to the log area"""
|
|
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)
|
|
self.update_idletasks()
|
|
except Exception as e:
|
|
print(f"Error logging message '{message}': {e}") # Fallback
|
|
|
|
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", "*.*")]
|
|
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")
|
|
|
|
def handle_drop(self, event):
|
|
"""Handle files/folders dropped onto the window"""
|
|
try: files_or_folders = self.tk.splitlist(event.data)
|
|
except Exception: files_or_folders = [event.data]
|
|
valid_files = []
|
|
for item_path in files_or_folders:
|
|
path = Path(item_path)
|
|
if path.is_dir():
|
|
self.log(f"Scanning dropped folder: {path}", "info")
|
|
try:
|
|
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, avoiding duplicates"""
|
|
existing_files = set(self.selected_files)
|
|
added_count = 0
|
|
for file in new_files:
|
|
try:
|
|
file_path = str(Path(file).resolve())
|
|
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). Total: {len(self.selected_files)}", "info")
|
|
elif new_files: self.log("No new files added (duplicates ignored).", "info")
|
|
|
|
def refresh_file_list(self):
|
|
"""Refresh the file list display"""
|
|
self.file_list.delete(0, END)
|
|
# self.selected_files.sort() # Optional sort
|
|
for file_path in self.selected_files: self.file_list.insert(END, file_path)
|
|
|
|
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
|
|
for i in sorted(selected_indices, reverse=True):
|
|
try: self.selected_files.pop(i); self.file_list.delete(i); removed_count += 1
|
|
except IndexError: self.log(f"Error removing index {i}.", "error")
|
|
if removed_count > 0: self.log(f"Removed {removed_count} file(s).", "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.", "info")
|
|
|
|
def pick_color(self):
|
|
"""Open color picker dialog"""
|
|
initial_color = self.bg_var.get()
|
|
result = colorchooser.askcolor(parent=self, title="Select Background Color", initialcolor=initial_color)
|
|
if result and result[1]: self.bg_var.set(result[1].upper()); self.update_color_swatch()
|
|
|
|
def choose_output_dir(self):
|
|
"""Choose output directory"""
|
|
initial_dir = self.output_var.get()
|
|
if initial_dir.lower() == 'same as source' or not os.path.isdir(initial_dir): initial_dir = os.getcwd()
|
|
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'
|
|
widgets_to_toggle = [
|
|
self.convert_button, self.retry_button, self.clear_log_button, self.theme_button,
|
|
self.file_list # Listbox uses 'disabled' state correctly
|
|
]
|
|
# Toggle all children within settings and file frames
|
|
for frame in [self.winfo_children()[0].winfo_children()[0], self.winfo_children()[0].winfo_children()[1]]: # File frame, Settings frame
|
|
for widget in frame.winfo_children():
|
|
# Check if widget supports 'state' configuration
|
|
if isinstance(widget, (ttk.Button, ttk.Entry, ttk.Spinbox, ttk.Combobox, ttk.Checkbutton, ttk.Scrollbar, Listbox)):
|
|
widgets_to_toggle.append(widget)
|
|
elif isinstance(widget, Frame): # Recurse into standard frames if needed (like bg_frame)
|
|
for sub_widget in widget.winfo_children():
|
|
if isinstance(sub_widget, (ttk.Button, ttk.Entry, Label)): # Include Label swatch
|
|
widgets_to_toggle.append(sub_widget)
|
|
|
|
|
|
for widget in widgets_to_toggle:
|
|
try:
|
|
# Special handling for retry button
|
|
if widget == self.retry_button:
|
|
widget.config(state='normal' if enabled and self.failed_files else 'disabled')
|
|
else:
|
|
widget.config(state=state)
|
|
except (TclError, AttributeError): pass # Ignore widgets without 'state' or if already destroyed
|
|
|
|
|
|
def start_conversion(self):
|
|
"""Start the conversion process"""
|
|
if self.conversion_running: self.log("Conversion already running.", "warning"); return
|
|
if not self.selected_files: self.log("No files selected.", "warning"); messagebox.showwarning("No Files", "Add SVG files first.", parent=self); return
|
|
|
|
# Re-check Inkscape *before* starting thread
|
|
current_inkscape_path = self.find_inkscape()
|
|
if not current_inkscape_path:
|
|
self.log("Cannot start: Inkscape path not valid.", "error")
|
|
messagebox.showerror("Inkscape Error", "Inkscape not found or path is invalid.\nPlease ensure Inkscape is installed and locate it if prompted.", parent=self)
|
|
return
|
|
self.inkscape_path = current_inkscape_path # Ensure instance variable is up-to-date
|
|
|
|
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(); self.progress_var.set(0)
|
|
self.status_var.set("Starting conversion..."); self.set_ui_state(False)
|
|
self.log(f"--- Starting conversion ({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 files"""
|
|
total_files = len(self.selected_files); success_count = 0; first_output_dir = None
|
|
files_to_process = list(self.selected_files) # Local copy
|
|
|
|
for i, input_file in enumerate(files_to_process, 1):
|
|
if not self.conversion_running: self.log("Conversion cancelled.", "warning"); break
|
|
base_name = os.path.basename(input_file)
|
|
self.status_var.set(f"Converting {i}/{total_files}: {base_name}")
|
|
# self.log(f"Processing ({i}/{total_files}): {input_file}", "info") # Can be noisy
|
|
|
|
try:
|
|
output_path_obj, output_dir = self.get_output_path(input_file, self.current_settings)
|
|
output_file = str(output_path_obj)
|
|
if first_output_dir is None and output_dir: first_output_dir = output_dir
|
|
|
|
if not self.current_settings['overwrite'] and output_path_obj.exists():
|
|
self.log(f"Skipped (exists): {base_name} -> {output_file}", "warning"); continue
|
|
|
|
try: output_dir.mkdir(parents=True, exist_ok=True)
|
|
except OSError as e: self.log(f"Error creating dir {output_dir}: {e}. Skipping.", "error"); self.failed_files.append(input_file); continue
|
|
|
|
success = self.convert_single_file(input_file, output_file, self.current_settings)
|
|
if success: success_count += 1 #; self.log(f"Success: {base_name} -> {output_file}", "success") # Log success can be noisy
|
|
else: self.failed_files.append(input_file); self.log(f"Failed: {base_name}", "error") # Error already logged
|
|
|
|
except Exception as e: self.failed_files.append(input_file); self.log(f"Error processing {base_name}: {e}", "error")
|
|
self.progress_var.set((i / total_files) * 100)
|
|
|
|
# --- Post Conversion ---
|
|
final_message = f"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")
|
|
# Optionally list failed files again here if needed
|
|
elif success_count == 0 and not self.failed_files: self.log("--- Finished. No files processed (all skipped?). ---", "info")
|
|
else: self.log("--- Conversion finished. ---", "info")
|
|
|
|
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))
|
|
|
|
self.after(100, self._finalize_conversion)
|
|
|
|
|
|
def _finalize_conversion(self):
|
|
"""Actions on main thread after conversion finishes."""
|
|
self.conversion_running = False
|
|
self.set_ui_state(True) # Re-enable UI
|
|
self.progress_var.set(100 if not self.failed_files else self.progress_var.get()) # Show 100% or final progress
|
|
|
|
def get_output_path(self, input_file, settings):
|
|
"""Determine output Path object and directory Path."""
|
|
input_path = Path(input_file); output_dir_setting = settings['output_dir_setting']
|
|
output_dir = input_path.parent if output_dir_setting.lower() == 'same as source' else Path(output_dir_setting)
|
|
fmt = settings['format'].lower()
|
|
valid_formats = ['png', 'jpg', 'jpeg', 'pdf', 'tiff', 'tif', 'eps', 'svg', 'ps', 'webp']
|
|
if fmt not in valid_formats: fmt = 'png'; self.log(f"Invalid format, using 'png'.", "warning")
|
|
fmt = 'jpg' if fmt == 'jpeg' else 'tiff' if fmt == 'tif' else fmt # Normalize
|
|
output_filename = f"{input_path.stem}.{fmt}"
|
|
return output_dir / output_filename, 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 missing.", "error")
|
|
return False
|
|
|
|
cmd = [
|
|
self.inkscape_path, input_file,
|
|
f"--export-filename={output_file}",
|
|
f"--export-type={settings['format']}",
|
|
f"--export-dpi={settings['dpi']}",
|
|
]
|
|
if settings['format'].lower() in ['png', 'jpg', 'jpeg', 'tiff', 'tif', 'webp']: # Background for raster formats
|
|
cmd.append(f"--export-background={settings['background']}")
|
|
# cmd.append("--export-background-opacity=1.0") # Ensure full opacity
|
|
|
|
# self.log(f"CMD: {' '.join(cmd)}", "info") # Verbose logging
|
|
|
|
try:
|
|
startupinfo = None
|
|
if platform.system() == "Windows":
|
|
startupinfo = subprocess.STARTUPINFO()
|
|
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
|
startupinfo.wShowWindow = subprocess.SW_HIDE
|
|
|
|
result = subprocess.run(cmd, capture_output=True, text=True, check=False,
|
|
encoding='utf-8', errors='replace', startupinfo=startupinfo, timeout=120) # Add timeout (e.g., 120 seconds)
|
|
|
|
if result.returncode != 0:
|
|
self.log(f"Inkscape error (code {result.returncode}) for {os.path.basename(input_file)}.", "error")
|
|
stderr_lines = result.stderr.strip().splitlines()
|
|
stdout_lines = result.stdout.strip().splitlines()
|
|
if stderr_lines:
|
|
self.log(f" stderr: {' / '.join(stderr_lines[-3:])}", "error") # Log last few lines
|
|
if stdout_lines:
|
|
self.log(f" stdout: {' / '.join(stdout_lines[-3:])}", "info")
|
|
# Attempt delete incomplete file
|
|
try:
|
|
if Path(output_file).exists():
|
|
Path(output_file).unlink()
|
|
except Exception:
|
|
pass
|
|
return False
|
|
|
|
if result.stderr: # Log warnings even on success
|
|
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)}: {' / '.join(warnings[:2])}", "warning")
|
|
|
|
out_path = Path(output_file)
|
|
if not out_path.exists():
|
|
self.log(f"Output file missing after Inkscape success: {output_file}", "error")
|
|
return False
|
|
if out_path.stat().st_size == 0:
|
|
self.log(f"Output file is empty: {output_file}", "error")
|
|
try:
|
|
out_path.unlink()
|
|
except:
|
|
pass
|
|
return False
|
|
return True
|
|
|
|
except FileNotFoundError:
|
|
self.log(f"Inkscape command not found at '{self.inkscape_path}'.", "error")
|
|
self.conversion_running = False
|
|
self.after(100, self._finalize_conversion)
|
|
return False
|
|
except subprocess.TimeoutExpired:
|
|
self.log(f"Inkscape timed out for {os.path.basename(input_file)}.", "error")
|
|
return False
|
|
except Exception as e:
|
|
self.log(f"Inkscape execution error for {os.path.basename(input_file)}: {e}", "error")
|
|
import traceback
|
|
self.log(traceback.format_exc(), "error")
|
|
return False
|
|
|
|
def retry_failed(self):
|
|
"""Retry conversion of failed files"""
|
|
if not self.failed_files: self.log("No failed files to retry.", "info"); messagebox.showinfo("Retry Failed", "No files failed in the last run.", parent=self); return
|
|
if self.conversion_running: self.log("Cannot retry while conversion running.", "warning"); return
|
|
self.log(f"--- Retrying {len(self.failed_files)} failed file(s) ---", "info")
|
|
self.selected_files = list(self.failed_files); self.refresh_file_list()
|
|
self.start_conversion()
|
|
|
|
def open_output_folder(self, directory):
|
|
"""Open the output folder in the system file manager"""
|
|
path = Path(directory)
|
|
if not path.is_dir(): self.log(f"Cannot open folder (not found): '{directory}'", "warning"); return
|
|
try:
|
|
if platform.system() == "Windows": os.startfile(path)
|
|
elif platform.system() == "Darwin": subprocess.run(["open", str(path)], check=True)
|
|
else: subprocess.run(["xdg-open", str(path)], check=True)
|
|
except Exception as e: self.log(f"Could not open output folder '{directory}': {e}", "error"); messagebox.showerror("Error", f"Failed to open folder:\n{directory}\n\nError: {e}", parent=self)
|
|
|
|
def on_close(self):
|
|
"""Handle window close event"""
|
|
if self.conversion_running:
|
|
if messagebox.askokcancel("Conversion in Progress", "Conversion is running. Quit anyway?", parent=self):
|
|
self.log("User aborted via window close.", "warning"); self.conversion_running = False
|
|
# Settings save might fail if thread is mid-file-write, but try anyway
|
|
self.save_settings(); self.destroy()
|
|
else: return # Don't close
|
|
else:
|
|
self.save_settings(); self.destroy()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
# Set DPI awareness (Windows)
|
|
try:
|
|
if platform.system() == "Windows": from ctypes import windll; windll.shcore.SetProcessDpiAwareness(1)
|
|
except Exception as e: print(f"Note: Could not set DPI awareness: {e}")
|
|
|
|
app = SVGtoPNGConverter()
|
|
app.mainloop() |