528 lines
No EOL
21 KiB
Python
528 lines
No EOL
21 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 *
|
|
from tkinter import ttk, filedialog, messagebox, scrolledtext
|
|
from tkinterdnd2 import TkinterDnD, DND_FILES
|
|
from PIL import Image, ImageTk
|
|
import darkdetect
|
|
|
|
class SVGtoPNGConverter(TkinterDnD.Tk):
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.title("SVG to PNG Converter")
|
|
self.geometry("900x700")
|
|
self.minsize(800, 600)
|
|
|
|
# Initialize variables
|
|
self.selected_files = []
|
|
self.failed_files = []
|
|
self.conversion_running = False
|
|
self.dark_mode = darkdetect.isDark() if hasattr(darkdetect, 'isDark') else False
|
|
|
|
# Load settings
|
|
self.settings_file = Path.home() / ".svg2png_settings.json"
|
|
self.load_settings()
|
|
|
|
# Setup UI
|
|
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 installed.", "warning")
|
|
|
|
# Configure window close behavior
|
|
self.protocol("WM_DELETE_WINDOW", self.on_close)
|
|
|
|
def setup_ui(self):
|
|
"""Setup the main user interface"""
|
|
# Configure style
|
|
self.style = ttk.Style()
|
|
self.update_theme()
|
|
|
|
# Main container
|
|
main_frame = ttk.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 list with scrollbar
|
|
self.file_list = Listbox(file_frame, selectmode=MULTIPLE, height=8)
|
|
self.file_list.pack(side=LEFT, fill=BOTH, expand=True)
|
|
scrollbar = ttk.Scrollbar(file_frame, orient=VERTICAL, command=self.file_list.yview)
|
|
scrollbar.pack(side=RIGHT, fill=Y)
|
|
self.file_list.config(yscrollcommand=scrollbar.set)
|
|
|
|
# File controls
|
|
file_controls = ttk.Frame(file_frame)
|
|
file_controls.pack(side=RIGHT, fill=Y, padx=(10, 0))
|
|
|
|
ttk.Button(file_controls, text="Add Files", command=self.add_files).pack(fill=X, pady=2)
|
|
ttk.Button(file_controls, text="Add Folder", command=self.add_folder).pack(fill=X, pady=2)
|
|
ttk.Button(file_controls, text="Remove", command=self.remove_files).pack(fill=X, pady=2)
|
|
ttk.Button(file_controls, text="Clear All", command=self.clear_files).pack(fill=X, pady=2)
|
|
|
|
# Enable drag and 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))
|
|
|
|
# DPI setting
|
|
ttk.Label(settings_frame, text="DPI:").grid(row=0, column=0, sticky=W, padx=(0, 5))
|
|
self.dpi_var = IntVar(value=self.settings.get('dpi', 96))
|
|
ttk.Spinbox(settings_frame, from_=72, to=600, increment=24, textvariable=self.dpi_var, width=6).grid(row=0, column=1, sticky=W)
|
|
|
|
# Background color
|
|
ttk.Label(settings_frame, text="Background:").grid(row=0, column=2, sticky=W, padx=(10, 5))
|
|
self.bg_var = StringVar(value=self.settings.get('background', '#FFFFFF'))
|
|
bg_frame = ttk.Frame(settings_frame)
|
|
bg_frame.grid(row=0, column=3, sticky=W)
|
|
ttk.Entry(bg_frame, textvariable=self.bg_var, width=10).pack(side=LEFT)
|
|
ttk.Button(bg_frame, text="Pick", command=self.pick_color, width=5).pack(side=LEFT, padx=(5, 0))
|
|
|
|
# Output format
|
|
ttk.Label(settings_frame, text="Format:").grid(row=0, column=4, sticky=W, padx=(10, 5))
|
|
self.format_var = StringVar(value=self.settings.get('format', 'png'))
|
|
ttk.Combobox(settings_frame, textvariable=self.format_var, values=['png', 'jpg', 'pdf', 'tiff'], width=6).grid(row=0, column=5, sticky=W)
|
|
|
|
# Output directory
|
|
ttk.Label(settings_frame, text="Output:").grid(row=1, column=0, sticky=W, padx=(0, 5), pady=(10, 0))
|
|
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=4, sticky=EW, pady=(10, 0))
|
|
ttk.Entry(output_frame, textvariable=self.output_var).pack(side=LEFT, fill=X, expand=True)
|
|
ttk.Button(output_frame, text="Browse", command=self.choose_output_dir, width=8).pack(side=LEFT, 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", variable=self.overwrite_var).pack(side=LEFT, padx=(0, 10))
|
|
|
|
self.auto_open_var = BooleanVar(value=self.settings.get('auto_open', False))
|
|
ttk.Checkbutton(options_frame, text="Open output folder", 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)
|
|
|
|
self.log_area = scrolledtext.ScrolledText(log_frame, wrap=WORD, state='normal', height=10)
|
|
self.log_area.pack(fill=BOTH, expand=True)
|
|
|
|
# Configure log tags for colored text
|
|
self.log_area.tag_config("info", foreground="blue" if not self.dark_mode else "lightblue")
|
|
self.log_area.tag_config("success", foreground="green" if not self.dark_mode else "lightgreen")
|
|
self.log_area.tag_config("warning", foreground="orange" if not self.dark_mode else "yellow")
|
|
self.log_area.tag_config("error", foreground="red" if not self.dark_mode else "pink")
|
|
|
|
# Progress bar
|
|
self.progress_var = DoubleVar()
|
|
self.progress_bar = ttk.Progressbar(log_frame, variable=self.progress_var, maximum=100)
|
|
self.progress_bar.pack(fill=X, pady=(5, 0))
|
|
|
|
# Status bar
|
|
self.status_var = StringVar(value="Ready")
|
|
status_bar = ttk.Label(log_frame, textvariable=self.status_var, relief=SUNKEN)
|
|
status_bar.pack(fill=X, pady=(5, 0))
|
|
|
|
# Control buttons
|
|
control_frame = ttk.Frame(main_frame)
|
|
control_frame.pack(fill=X, pady=(10, 0))
|
|
|
|
ttk.Button(control_frame, text="Convert", command=self.start_conversion).pack(side=LEFT, padx=(0, 10))
|
|
ttk.Button(control_frame, text="Retry Failed", command=self.retry_failed).pack(side=LEFT, padx=(0, 10))
|
|
ttk.Button(control_frame, text="Clear Log", command=self.clear_log).pack(side=LEFT)
|
|
|
|
# Theme toggle button
|
|
theme_btn = ttk.Button(control_frame, text="☀️" if self.dark_mode else "🌙", command=self.toggle_theme, width=3)
|
|
theme_btn.pack(side=RIGHT)
|
|
|
|
# Configure grid weights for resizing
|
|
settings_frame.columnconfigure(1, weight=1)
|
|
settings_frame.columnconfigure(3, weight=1)
|
|
|
|
def update_theme(self):
|
|
"""Update the theme based on dark mode setting"""
|
|
if self.dark_mode:
|
|
self.style.theme_use('alt')
|
|
self.configure(background='#2d2d2d')
|
|
self.log_area.configure(bg='#2d2d2d', fg='white', insertbackground='white')
|
|
else:
|
|
self.style.theme_use('clam')
|
|
self.configure(background='SystemButtonFace')
|
|
self.log_area.configure(bg='white', fg='black', insertbackground='black')
|
|
|
|
def toggle_theme(self):
|
|
"""Toggle between dark and light mode"""
|
|
self.dark_mode = not self.dark_mode
|
|
self.update_theme()
|
|
self.settings['dark_mode'] = self.dark_mode
|
|
self.save_settings()
|
|
|
|
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,
|
|
'dark_mode': self.dark_mode
|
|
}
|
|
|
|
try:
|
|
if self.settings_file.exists():
|
|
with open(self.settings_file, 'r') as f:
|
|
self.settings = {**default_settings, **json.load(f)}
|
|
else:
|
|
self.settings = default_settings
|
|
except Exception as e:
|
|
self.log(f"Error loading settings: {str(e)}", "error")
|
|
self.settings = default_settings
|
|
|
|
def save_settings(self):
|
|
"""Save settings to file"""
|
|
self.settings.update({
|
|
'dpi': self.dpi_var.get(),
|
|
'background': self.bg_var.get(),
|
|
'format': self.format_var.get(),
|
|
'output_dir': self.output_var.get(),
|
|
'overwrite': self.overwrite_var.get(),
|
|
'auto_open': self.auto_open_var.get(),
|
|
'dark_mode': self.dark_mode
|
|
})
|
|
|
|
try:
|
|
with open(self.settings_file, 'w') as f:
|
|
json.dump(self.settings, f, indent=2)
|
|
except Exception as e:
|
|
self.log(f"Error saving settings: {str(e)}", "error")
|
|
|
|
def find_inkscape(self):
|
|
"""Find Inkscape executable in common locations"""
|
|
# Check if Inkscape is in PATH
|
|
try:
|
|
if platform.system() == "Windows":
|
|
result = subprocess.run(["where", "inkscape"], capture_output=True, text=True)
|
|
else:
|
|
result = subprocess.run(["which", "inkscape"], capture_output=True, text=True)
|
|
|
|
if result.returncode == 0:
|
|
return result.stdout.splitlines()[0].strip()
|
|
except:
|
|
pass
|
|
|
|
# Check common installation paths
|
|
common_paths = []
|
|
if platform.system() == "Windows":
|
|
program_files = os.environ.get("ProgramFiles", "C:\\Program Files")
|
|
common_paths.extend([
|
|
os.path.join(program_files, "Inkscape", "bin", "inkscape.exe"),
|
|
os.path.join(program_files, "Inkscape", "inkscape.exe"),
|
|
os.path.join(os.environ.get("LocalAppData", ""), "Programs", "Inkscape", "bin", "inkscape.exe")
|
|
])
|
|
elif platform.system() == "Darwin": # macOS
|
|
common_paths.extend([
|
|
"/Applications/Inkscape.app/Contents/MacOS/inkscape",
|
|
"/Applications/Inkscape.app/Contents/Resources/bin/inkscape"
|
|
])
|
|
else: # Linux
|
|
common_paths.extend([
|
|
"/usr/bin/inkscape",
|
|
"/usr/local/bin/inkscape",
|
|
"/snap/bin/inkscape",
|
|
"/opt/inkscape/bin/inkscape"
|
|
])
|
|
|
|
for path in common_paths:
|
|
if os.path.exists(path):
|
|
return path
|
|
|
|
return None
|
|
|
|
def log(self, message, level="info"):
|
|
"""Add a message to the log area with specified level (info, success, warning, error)"""
|
|
self.log_area.configure(state='normal')
|
|
self.log_area.insert(END, message + "\n", level)
|
|
self.log_area.configure(state='disabled')
|
|
self.log_area.see(END)
|
|
self.update_idletasks()
|
|
|
|
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')
|
|
|
|
def add_files(self):
|
|
"""Add files through file dialog"""
|
|
filetypes = [("SVG Files", "*.svg"), ("All Files", "*.*")]
|
|
files = filedialog.askopenfilenames(title="Select SVG Files", filetypes=filetypes)
|
|
if files:
|
|
self.update_file_list(files)
|
|
|
|
def add_folder(self):
|
|
"""Add all SVG files from a folder"""
|
|
folder = filedialog.askdirectory(title="Select Folder with SVG Files")
|
|
if folder:
|
|
svg_files = []
|
|
for root, _, files in os.walk(folder):
|
|
for file in files:
|
|
if file.lower().endswith('.svg'):
|
|
svg_files.append(os.path.join(root, file))
|
|
if svg_files:
|
|
self.update_file_list(svg_files)
|
|
else:
|
|
self.log("No SVG files found in the selected folder.", "warning")
|
|
|
|
def handle_drop(self, event):
|
|
"""Handle files dropped onto the window"""
|
|
files = self.tk.splitlist(event.data)
|
|
valid_files = []
|
|
|
|
for file in files:
|
|
# Handle both file paths and folder paths
|
|
if os.path.isdir(file):
|
|
for root, _, files_in_dir in os.walk(file):
|
|
for f in files_in_dir:
|
|
if f.lower().endswith('.svg'):
|
|
valid_files.append(os.path.join(root, f))
|
|
elif file.lower().endswith('.svg'):
|
|
valid_files.append(file)
|
|
|
|
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 = 0
|
|
|
|
for file in new_files:
|
|
file_path = os.path.abspath(file)
|
|
if file_path not in existing_files:
|
|
self.selected_files.append(file_path)
|
|
existing_files.add(file_path)
|
|
added += 1
|
|
|
|
if added > 0:
|
|
self.refresh_file_list()
|
|
self.log(f"Added {added} file(s) to conversion list.", "info")
|
|
else:
|
|
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)
|
|
for file in self.selected_files:
|
|
self.file_list.insert(END, file)
|
|
|
|
def remove_files(self):
|
|
"""Remove selected files from the list"""
|
|
selected = self.file_list.curselection()
|
|
if selected:
|
|
# Remove in reverse order to maintain correct indices
|
|
for i in sorted(selected, reverse=True):
|
|
self.selected_files.pop(i)
|
|
self.refresh_file_list()
|
|
self.log(f"Removed {len(selected)} file(s) from conversion list.", "info")
|
|
else:
|
|
self.log("No files selected to remove.", "warning")
|
|
|
|
def clear_files(self):
|
|
"""Clear all files from the list"""
|
|
if self.selected_files:
|
|
self.selected_files.clear()
|
|
self.refresh_file_list()
|
|
self.log("Cleared all files from conversion list.", "info")
|
|
else:
|
|
self.log("File list is already empty.", "info")
|
|
|
|
def pick_color(self):
|
|
"""Open color picker dialog"""
|
|
color = filedialog.askcolor(title="Select Background Color", initialcolor=self.bg_var.get())
|
|
if color[1]: # color[1] is the hex string
|
|
self.bg_var.set(color[1])
|
|
|
|
def choose_output_dir(self):
|
|
"""Choose output directory"""
|
|
directory = filedialog.askdirectory(title="Select Output Directory")
|
|
if directory:
|
|
self.output_var.set(directory)
|
|
|
|
def start_conversion(self):
|
|
"""Start the conversion process in a separate thread"""
|
|
if not self.selected_files:
|
|
self.log("No files selected for conversion.", "warning")
|
|
return
|
|
|
|
if not self.inkscape_path:
|
|
self.log("Error: Inkscape not found. Please install Inkscape and ensure it's in your PATH.", "error")
|
|
return
|
|
|
|
if self.conversion_running:
|
|
self.log("Conversion is already in progress.", "warning")
|
|
return
|
|
|
|
self.conversion_running = True
|
|
self.failed_files = []
|
|
self.progress_var.set(0)
|
|
self.status_var.set("Converting...")
|
|
|
|
# Start conversion in a separate thread
|
|
threading.Thread(target=self.convert_files, daemon=True).start()
|
|
|
|
def convert_files(self):
|
|
"""Convert all selected files"""
|
|
total_files = len(self.selected_files)
|
|
success_count = 0
|
|
|
|
for i, input_file in enumerate(self.selected_files, 1):
|
|
if not self.conversion_running:
|
|
break
|
|
|
|
self.log(f"Processing file {i} of {total_files}: {input_file}")
|
|
self.status_var.set(f"Converting {i}/{total_files}: {os.path.basename(input_file)}")
|
|
|
|
try:
|
|
output_file = self.get_output_path(input_file)
|
|
if not self.overwrite_var.get() and os.path.exists(output_file):
|
|
self.log(f"Skipping (file exists): {output_file}", "warning")
|
|
continue
|
|
|
|
success = self.convert_file(input_file, output_file)
|
|
if success:
|
|
success_count += 1
|
|
self.log(f"Successfully converted: {output_file}", "success")
|
|
else:
|
|
self.failed_files.append(input_file)
|
|
self.log(f"Conversion failed: {input_file}", "error")
|
|
except Exception as e:
|
|
self.failed_files.append(input_file)
|
|
self.log(f"Error converting {input_file}: {str(e)}", "error")
|
|
|
|
# Update progress
|
|
self.progress_var.set((i / total_files) * 100)
|
|
self.update_idletasks()
|
|
|
|
# Conversion complete
|
|
self.conversion_running = False
|
|
self.status_var.set(f"Complete: {success_count} of {total_files} succeeded")
|
|
|
|
if success_count == total_files:
|
|
self.log(f"All {total_files} files converted successfully!", "success")
|
|
else:
|
|
self.log(f"Conversion complete with {total_files - success_count} failures.", "warning")
|
|
|
|
# Auto-open output folder if requested
|
|
if success_count > 0 and self.auto_open_var.get():
|
|
output_dir = self.get_output_dir(self.selected_files[0])
|
|
self.open_output_folder(output_dir)
|
|
|
|
def get_output_path(self, input_file):
|
|
"""Determine the output path for a given input file"""
|
|
input_path = Path(input_file)
|
|
output_dir = self.get_output_dir(input_file)
|
|
|
|
# Get format extension (default to png if not specified)
|
|
fmt = self.format_var.get().lower()
|
|
if fmt not in ['png', 'jpg', 'pdf', 'tiff']:
|
|
fmt = 'png'
|
|
|
|
output_filename = f"{input_path.stem}.{fmt}"
|
|
return str(output_dir / output_filename)
|
|
|
|
def get_output_dir(self, input_file):
|
|
"""Get the output directory for a given input file"""
|
|
output_dir = self.output_var.get()
|
|
if output_dir.lower() == 'same as source':
|
|
return Path(input_file).parent
|
|
else:
|
|
return Path(output_dir)
|
|
|
|
def convert_file(self, input_file, output_file):
|
|
"""Convert a single file using Inkscape"""
|
|
cmd = [
|
|
self.inkscape_path,
|
|
input_file,
|
|
"--export-type=" + self.format_var.get(),
|
|
"--export-filename=" + output_file,
|
|
f"--export-dpi={self.dpi_var.get()}",
|
|
f"--export-background={self.bg_var.get()}"
|
|
]
|
|
|
|
try:
|
|
# Run Inkscape and capture output
|
|
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
|
|
|
# Log any warnings from Inkscape
|
|
if result.stderr:
|
|
self.log(f"Inkscape output for {input_file}: {result.stderr}", "warning")
|
|
|
|
return os.path.exists(output_file)
|
|
except subprocess.CalledProcessError as e:
|
|
self.log(f"Inkscape error: {e.stderr}", "error")
|
|
return False
|
|
except Exception as e:
|
|
self.log(f"Unexpected error: {str(e)}", "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")
|
|
return
|
|
|
|
self.selected_files = self.failed_files.copy()
|
|
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"""
|
|
try:
|
|
if platform.system() == "Windows":
|
|
os.startfile(directory)
|
|
elif platform.system() == "Darwin":
|
|
subprocess.run(["open", directory])
|
|
else: # Linux
|
|
subprocess.run(["xdg-open", directory])
|
|
except Exception as e:
|
|
self.log(f"Could not open output folder: {str(e)}", "warning")
|
|
|
|
def on_close(self):
|
|
"""Handle window close event"""
|
|
if self.conversion_running:
|
|
if messagebox.askokcancel(
|
|
"Conversion in Progress",
|
|
"A conversion is currently running. Are you sure you want to quit?"
|
|
):
|
|
self.conversion_running = False
|
|
self.destroy()
|
|
else:
|
|
self.save_settings()
|
|
self.destroy()
|
|
|
|
if __name__ == "__main__":
|
|
# Set up high DPI awareness on Windows
|
|
if platform.system() == "Windows":
|
|
from ctypes import windll
|
|
windll.shcore.SetProcessDpiAwareness(1)
|
|
|
|
app = SVGtoPNGConverter()
|
|
app.mainloop() |