475 lines
19 KiB
Python
475 lines
19 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
svg_converter_app.py
|
|
|
|
A Tkinter-based GUI application for converting SVG files to various formats (PNG, JPG, PDF, TIFF)
|
|
using Inkscape's command-line interface. Supports:
|
|
- Multi-file selection & folder selection (recursive)
|
|
- Drag-and-Drop (via tkinterdnd2)
|
|
- Threaded conversions for responsive UI
|
|
- Customizable DPI and background color
|
|
- Optional user-selected output folder
|
|
- Displays only filenames in the list, with a detail label for full path
|
|
- Multiple output formats: PNG, JPG, PDF, TIFF
|
|
|
|
Author: ChatGPT
|
|
"""
|
|
import os
|
|
import sys
|
|
import subprocess
|
|
import threading
|
|
import queue
|
|
import tkinter as tk
|
|
from tkinter import ttk, filedialog, messagebox
|
|
|
|
# ------------- DRAG-AND-DROP SUPPORT -------------
|
|
# tkinterdnd2 requires separate install: pip install tkinterdnd2
|
|
try:
|
|
import tkinterdnd2 as tkdnd
|
|
TkClass = tkdnd.TkinterDnD.Tk
|
|
except ImportError:
|
|
# If tkinterdnd2 is not available, fall back to normal Tk for those who don't need drag-and-drop
|
|
TkClass = tk.Tk
|
|
|
|
|
|
class SVGConverterApp(TkClass):
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.title("SVG Converter")
|
|
self.geometry("680x500")
|
|
self.resizable(False, False)
|
|
|
|
# Use a nicer-looking ttk theme if available
|
|
style = ttk.Style(self)
|
|
# Attempt a modern theme; fallback if not present
|
|
available_themes = style.theme_names()
|
|
for candidate_theme in ("vista", "xpnative", "clam", "alt", "default"):
|
|
if candidate_theme in available_themes:
|
|
style.theme_use(candidate_theme)
|
|
break
|
|
|
|
# ------------- Variables -------------
|
|
self.selected_files = [] # full paths
|
|
self.file_display_names = [] # just filenames
|
|
self.output_format_var = tk.StringVar(value="png") # 'png', 'jpg', 'pdf', 'tiff'
|
|
self.dpi_var = tk.StringVar(value="150")
|
|
self.bg_color_var = tk.StringVar(value="white")
|
|
self.use_same_folder_var = tk.BooleanVar(value=True) # whether to save next to original or not
|
|
self.output_folder_var = tk.StringVar(value="")
|
|
self.inkscape_path_var = tk.StringVar(value=self.find_inkscape_path() or "")
|
|
|
|
self.msg_queue = queue.Queue() # for thread-safe logging / progress
|
|
self.convert_thread = None
|
|
|
|
# Stats
|
|
self.success_count = 0
|
|
self.fail_count = 0
|
|
|
|
# ------------- GUI Layout -------------
|
|
self.create_top_frame()
|
|
self.create_center_frame()
|
|
self.create_bottom_frame()
|
|
|
|
# Start polling the queue for messages from worker threads
|
|
self.poll_queue()
|
|
|
|
# For Drag-and-Drop, we must register drop targets
|
|
if isinstance(self, tkdnd.TkinterDnD.Tk):
|
|
self.register_dnd()
|
|
|
|
def register_dnd(self):
|
|
"""Register drop target for the main window. (Requires tkinterdnd2)"""
|
|
self.drop_target_register(tkdnd.DND_FILES)
|
|
self.dnd_bind("<<Drop>>", self.handle_drop)
|
|
|
|
def handle_drop(self, event):
|
|
"""Handle file drops onto the window."""
|
|
# event.data may be a string of file paths
|
|
filepaths = self.tk.splitlist(event.data)
|
|
# Typically, filepaths could be something like {C:/path/to/file1.svg} {C:/path/to/file2.svg}
|
|
self.update_file_list(filepaths)
|
|
|
|
# ==========================================================
|
|
# GUI CREATION
|
|
# ==========================================================
|
|
def create_top_frame(self):
|
|
top_frame = ttk.Frame(self, padding=(10, 10))
|
|
top_frame.pack(fill=tk.X)
|
|
|
|
# Add File Button
|
|
add_file_btn = ttk.Button(top_frame, text="Add File(s)", command=self.add_files)
|
|
add_file_btn.pack(side=tk.LEFT, padx=5)
|
|
|
|
# Add Folder Button
|
|
add_folder_btn = ttk.Button(top_frame, text="Add Folder", command=self.add_folder)
|
|
add_folder_btn.pack(side=tk.LEFT)
|
|
|
|
# Inkscape Path Label
|
|
label_inkscape = ttk.Label(top_frame, text="Inkscape Path:")
|
|
label_inkscape.pack(side=tk.LEFT, padx=(30, 5))
|
|
|
|
# Inkscape Path Entry
|
|
entry_inkscape = ttk.Entry(top_frame, textvariable=self.inkscape_path_var, width=30)
|
|
entry_inkscape.pack(side=tk.LEFT)
|
|
|
|
# Browse Inkscape Button
|
|
browse_inkscape_btn = ttk.Button(top_frame, text="Browse...", command=self.browse_inkscape)
|
|
browse_inkscape_btn.pack(side=tk.LEFT, padx=5)
|
|
|
|
def create_center_frame(self):
|
|
center_frame = ttk.Frame(self, padding=(10, 0, 10, 10))
|
|
center_frame.pack(fill=tk.BOTH, expand=True)
|
|
|
|
# ------------------ FILE LIST FRAME ------------------
|
|
file_frame = ttk.LabelFrame(center_frame, text="Selected Files")
|
|
file_frame.pack(side=tk.LEFT, fill=tk.Y, padx=(0, 10), pady=5)
|
|
|
|
self.file_listbox = tk.Listbox(file_frame, width=35, height=15, selectmode=tk.EXTENDED)
|
|
self.file_listbox.pack(side=tk.LEFT, fill=tk.Y)
|
|
|
|
# Scrollbar
|
|
scrollbar = ttk.Scrollbar(file_frame, orient=tk.VERTICAL, command=self.file_listbox.yview)
|
|
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
|
|
self.file_listbox.config(yscrollcommand=scrollbar.set)
|
|
|
|
# Bind list selection
|
|
self.file_listbox.bind("<<ListboxSelect>>", self.on_file_select)
|
|
|
|
# ------------------ SETTINGS FRAME ------------------
|
|
settings_frame = ttk.LabelFrame(center_frame, text="Settings")
|
|
settings_frame.pack(side=tk.TOP, fill=tk.X, pady=5)
|
|
|
|
# Output Format
|
|
ttk.Label(settings_frame, text="Format:").grid(row=0, column=0, padx=5, pady=3, sticky=tk.E)
|
|
format_menu = ttk.OptionMenu(settings_frame, self.output_format_var, "png", "png", "jpg", "pdf", "tiff")
|
|
format_menu.grid(row=0, column=1, padx=5, pady=3, sticky=tk.W)
|
|
|
|
# DPI
|
|
ttk.Label(settings_frame, text="DPI:").grid(row=1, column=0, padx=5, pady=3, sticky=tk.E)
|
|
dpi_entry = ttk.Entry(settings_frame, textvariable=self.dpi_var, width=8)
|
|
dpi_entry.grid(row=1, column=1, padx=5, pady=3, sticky=tk.W)
|
|
|
|
# Background Color
|
|
ttk.Label(settings_frame, text="Background:").grid(row=2, column=0, padx=5, pady=3, sticky=tk.E)
|
|
bg_entry = ttk.Entry(settings_frame, textvariable=self.bg_color_var, width=8)
|
|
bg_entry.grid(row=2, column=1, padx=5, pady=3, sticky=tk.W)
|
|
|
|
# Choose Output Folder or Same
|
|
folder_frame = ttk.Frame(settings_frame)
|
|
folder_frame.grid(row=0, column=2, rowspan=3, padx=(20, 5), pady=3, sticky=tk.NW)
|
|
|
|
radio_same = ttk.Radiobutton(folder_frame, text="Use same folder", variable=self.use_same_folder_var, value=True)
|
|
radio_same.pack(anchor=tk.W)
|
|
radio_custom = ttk.Radiobutton(folder_frame, text="Choose folder:", variable=self.use_same_folder_var, value=False)
|
|
radio_custom.pack(anchor=tk.W)
|
|
|
|
self.output_folder_btn = ttk.Button(folder_frame, text="Browse...", command=self.browse_output_folder)
|
|
self.output_folder_btn.pack(anchor=tk.W, pady=3)
|
|
|
|
self.output_folder_label = ttk.Label(folder_frame, textvariable=self.output_folder_var, width=22)
|
|
self.output_folder_label.pack(anchor=tk.W)
|
|
|
|
# Progress Bar
|
|
self.progress = ttk.Progressbar(settings_frame, orient='horizontal', length=120, mode='determinate')
|
|
self.progress.grid(row=0, column=3, rowspan=3, padx=(30, 5), pady=5)
|
|
|
|
# ------------------ LOG FRAME ------------------
|
|
log_frame = ttk.LabelFrame(center_frame, text="Log")
|
|
log_frame.pack(side=tk.BOTTOM, fill=tk.BOTH, expand=True)
|
|
|
|
self.log_text = tk.Text(log_frame, height=8, width=60, state=tk.DISABLED)
|
|
self.log_text.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
|
|
|
log_scroll = ttk.Scrollbar(log_frame, orient=tk.VERTICAL, command=self.log_text.yview)
|
|
log_scroll.pack(side=tk.RIGHT, fill=tk.Y)
|
|
self.log_text.config(yscrollcommand=log_scroll.set)
|
|
|
|
# ------------------ DETAIL LABEL ------------------
|
|
# A label below the file list to show the full path of the selected file
|
|
self.detail_label = ttk.Label(center_frame, text="Select a file to see full path.")
|
|
self.detail_label.pack(side=tk.BOTTOM, fill=tk.X, padx=10, pady=5)
|
|
|
|
def create_bottom_frame(self):
|
|
bottom_frame = ttk.Frame(self, padding=(10, 10))
|
|
bottom_frame.pack(fill=tk.X)
|
|
|
|
remove_btn = ttk.Button(bottom_frame, text="Remove Selected", command=self.remove_selected)
|
|
remove_btn.pack(side=tk.LEFT, padx=5)
|
|
|
|
clear_btn = ttk.Button(bottom_frame, text="Clear All", command=self.clear_all)
|
|
clear_btn.pack(side=tk.LEFT, padx=5)
|
|
|
|
convert_btn = ttk.Button(bottom_frame, text="Convert", command=self.start_conversion)
|
|
convert_btn.pack(side=tk.RIGHT, padx=5)
|
|
|
|
# ==========================================================
|
|
# FILE ACTIONS
|
|
# ==========================================================
|
|
def add_files(self):
|
|
"""Open file dialog to select multiple SVG files and add them to the list."""
|
|
filepaths = filedialog.askopenfilenames(
|
|
title="Select File(s)",
|
|
filetypes=[("SVG Files", "*.svg"), ("All Files", "*.*")]
|
|
)
|
|
self.update_file_list(filepaths)
|
|
|
|
def add_folder(self):
|
|
"""Open directory dialog and add all SVG files from the folder (recursively)."""
|
|
folder_path = filedialog.askdirectory(title="Select Folder with SVG Files")
|
|
if not folder_path:
|
|
return
|
|
|
|
svg_files = []
|
|
for root, dirs, files in os.walk(folder_path):
|
|
for fname in files:
|
|
# You could add additional file types if you prefer
|
|
if fname.lower().endswith(".svg"):
|
|
svg_files.append(os.path.join(root, fname))
|
|
|
|
self.update_file_list(svg_files)
|
|
|
|
def update_file_list(self, filepaths):
|
|
"""Add new files to the internal list and the listbox. De-duplicates if repeated."""
|
|
for f in filepaths:
|
|
# Basic check to see if it's an actual file
|
|
if os.path.isfile(f) and f.lower().endswith(".svg"):
|
|
if f not in self.selected_files:
|
|
self.selected_files.append(f)
|
|
base = os.path.basename(f)
|
|
self.file_display_names.append(base)
|
|
self.file_listbox.insert(tk.END, base)
|
|
|
|
def remove_selected(self):
|
|
"""Remove selected items from the listbox and internal lists."""
|
|
selection = self.file_listbox.curselection()
|
|
if not selection:
|
|
return
|
|
|
|
# Because we remove items from the lists, iterate in reverse
|
|
for idx in reversed(selection):
|
|
self.file_listbox.delete(idx)
|
|
self.selected_files.pop(idx)
|
|
self.file_display_names.pop(idx)
|
|
|
|
self.detail_label.config(text="Select a file to see full path.")
|
|
|
|
def clear_all(self):
|
|
"""Clear all files from the list."""
|
|
self.file_listbox.delete(0, tk.END)
|
|
self.selected_files.clear()
|
|
self.file_display_names.clear()
|
|
self.detail_label.config(text="Select a file to see full path.")
|
|
|
|
def on_file_select(self, event):
|
|
"""When the user selects a file in the list, show the full path below."""
|
|
sel = self.file_listbox.curselection()
|
|
if not sel:
|
|
self.detail_label.config(text="Select a file to see full path.")
|
|
return
|
|
idx = sel[0]
|
|
full_path = self.selected_files[idx]
|
|
self.detail_label.config(text=full_path)
|
|
|
|
# ==========================================================
|
|
# OUTPUT FOLDER
|
|
# ==========================================================
|
|
def browse_output_folder(self):
|
|
folder = filedialog.askdirectory(title="Select Output Folder")
|
|
if folder:
|
|
self.output_folder_var.set(folder)
|
|
|
|
# ==========================================================
|
|
# INKSCAPE PATH DETECTION
|
|
# ==========================================================
|
|
def browse_inkscape(self):
|
|
"""Manually select Inkscape executable if auto-detection fails."""
|
|
path = filedialog.askopenfilename(title="Select Inkscape Executable")
|
|
if path:
|
|
self.inkscape_path_var.set(path)
|
|
|
|
def find_inkscape_path(self):
|
|
"""
|
|
Attempt to locate Inkscape in common paths or from system PATH.
|
|
Return the path if found, else None.
|
|
"""
|
|
possible_locations = []
|
|
# Windows
|
|
if os.name == 'nt':
|
|
possible_locations += [
|
|
r"C:\Program Files\Inkscape\bin\inkscape.exe",
|
|
r"C:\Program Files\Inkscape\inkscape.exe",
|
|
r"C:\Program Files (x86)\Inkscape\inkscape.exe",
|
|
]
|
|
# macOS / Linux guesses
|
|
possible_locations += [
|
|
"/Applications/Inkscape.app/Contents/MacOS/inkscape",
|
|
"/usr/local/bin/inkscape",
|
|
"/usr/bin/inkscape",
|
|
"/opt/homebrew/bin/inkscape",
|
|
]
|
|
# Also check PATH
|
|
for p in os.environ.get("PATH", "").split(os.pathsep):
|
|
possible_locations.append(os.path.join(p, "inkscape"))
|
|
possible_locations.append(os.path.join(p, "inkscape.exe"))
|
|
|
|
for loc in possible_locations:
|
|
if os.path.isfile(loc) and os.access(loc, os.X_OK):
|
|
return loc
|
|
return None
|
|
|
|
# ==========================================================
|
|
# THREADING & CONVERSION LOGIC
|
|
# ==========================================================
|
|
def start_conversion(self):
|
|
"""Triggered when user clicks Convert. Validates input, spawns a thread."""
|
|
if not self.selected_files:
|
|
messagebox.showerror("Error", "No SVG files selected.")
|
|
return
|
|
|
|
inkscape_path = self.inkscape_path_var.get().strip()
|
|
if not inkscape_path or not os.path.exists(inkscape_path):
|
|
messagebox.showerror("Error", "Inkscape executable not found.\nPlease specify the correct path.")
|
|
return
|
|
|
|
if not self.dpi_var.get().isdigit():
|
|
messagebox.showerror("Error", "DPI must be a positive integer.")
|
|
return
|
|
|
|
# Start fresh
|
|
self.success_count = 0
|
|
self.fail_count = 0
|
|
self.progress['value'] = 0
|
|
self.log_text.config(state=tk.NORMAL)
|
|
self.log_text.delete("1.0", tk.END)
|
|
self.log_text.config(state=tk.DISABLED)
|
|
|
|
# Kick off the worker thread
|
|
self.convert_thread = threading.Thread(target=self.do_conversion)
|
|
self.convert_thread.daemon = True
|
|
self.convert_thread.start()
|
|
|
|
def do_conversion(self):
|
|
"""Performs the actual conversion in a separate thread."""
|
|
total_files = len(self.selected_files)
|
|
dpi_val = self.dpi_var.get().strip()
|
|
bg_color = self.bg_color_var.get().strip()
|
|
inkscape_path = self.inkscape_path_var.get().strip()
|
|
fmt = self.output_format_var.get().lower()
|
|
|
|
# Let user know we started
|
|
self.msg_queue.put(("log", "Starting conversion...\n"))
|
|
|
|
for i, svg_file in enumerate(self.selected_files, start=1):
|
|
# Determine output directory
|
|
if self.use_same_folder_var.get():
|
|
# Use same folder as source
|
|
base, ext = os.path.splitext(svg_file)
|
|
if fmt == "jpg":
|
|
out_file = base + ".jpg"
|
|
elif fmt == "tiff":
|
|
out_file = base + ".tiff"
|
|
elif fmt == "pdf":
|
|
out_file = base + ".pdf"
|
|
else: # default png
|
|
out_file = base + ".png"
|
|
else:
|
|
# Use user-selected folder
|
|
base_name = os.path.splitext(os.path.basename(svg_file))[0]
|
|
out_dir = self.output_folder_var.get()
|
|
extension = f".{fmt}"
|
|
out_file = os.path.join(out_dir, base_name + extension)
|
|
|
|
msg = f"Converting: {os.path.basename(svg_file)} → {os.path.basename(out_file)}"
|
|
self.msg_queue.put(("log", msg))
|
|
|
|
# Build command. For Inkscape 1.2+ we can do --export-type=jpg or tiff.
|
|
# Check for PDF with --export-type=pdf
|
|
cmd = [
|
|
inkscape_path,
|
|
svg_file,
|
|
f"--export-type={fmt}",
|
|
f"--export-filename={out_file}",
|
|
f"--export-dpi={dpi_val}",
|
|
f"--export-background={bg_color}",
|
|
]
|
|
|
|
try:
|
|
subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
if os.path.exists(out_file):
|
|
self.msg_queue.put(("log", " [OK] Conversion successful.\n"))
|
|
self.success_count += 1
|
|
else:
|
|
self.msg_queue.put(("log", " [ERROR] Output file not found after conversion.\n", "error"))
|
|
self.fail_count += 1
|
|
except subprocess.CalledProcessError as e:
|
|
self.msg_queue.put(("log", f" [ERROR] Conversion failed for {svg_file}\n {e}\n", "error"))
|
|
self.fail_count += 1
|
|
|
|
# Update progress
|
|
self.msg_queue.put(("progress", i, total_files))
|
|
|
|
# All done
|
|
self.msg_queue.put(("done", self.success_count, self.fail_count))
|
|
|
|
# ==========================================================
|
|
# GUI MESSAGE QUEUE POLLING (THREAD-SAFE)
|
|
# ==========================================================
|
|
def poll_queue(self):
|
|
"""Check for messages from the worker thread and update the UI accordingly."""
|
|
try:
|
|
while True:
|
|
msg = self.msg_queue.get_nowait()
|
|
self.handle_worker_message(msg)
|
|
except queue.Empty:
|
|
pass
|
|
|
|
# keep polling
|
|
self.after(100, self.poll_queue)
|
|
|
|
def handle_worker_message(self, msg):
|
|
"""
|
|
Handle a message from the background thread.
|
|
Possible messages:
|
|
("log", message, optional_level)
|
|
("progress", current_idx, total)
|
|
("done", success_count, fail_count)
|
|
"""
|
|
msg_type = msg[0]
|
|
if msg_type == "log":
|
|
self.append_log(*msg[1:])
|
|
elif msg_type == "progress":
|
|
current, total = msg[1], msg[2]
|
|
self.update_progress(current, total)
|
|
elif msg_type == "done":
|
|
success, fail = msg[1], msg[2]
|
|
self.finish_conversion(success, fail)
|
|
|
|
def append_log(self, message, level="info"):
|
|
"""Append a log message to the log_text box, color for 'error' if needed."""
|
|
self.log_text.config(state=tk.NORMAL)
|
|
if level == "error":
|
|
self.log_text.insert(tk.END, message, "error")
|
|
else:
|
|
self.log_text.insert(tk.END, message)
|
|
self.log_text.insert(tk.END, "") # for spacing
|
|
self.log_text.see(tk.END)
|
|
self.log_text.config(state=tk.DISABLED)
|
|
|
|
def update_progress(self, current, total):
|
|
percent = int((current / total) * 100)
|
|
self.progress['value'] = percent
|
|
|
|
def finish_conversion(self, success, fail):
|
|
msg = f"\nConversion finished.\nSuccess: {success}, Fail: {fail}\n"
|
|
self.append_log(msg)
|
|
self.progress['value'] = 100
|
|
|
|
# ==============================================================
|
|
# APP ENTRY POINT
|
|
# ==============================================================
|
|
def main():
|
|
app = SVGConverterApp()
|
|
app.mainloop()
|
|
|
|
if __name__ == "__main__":
|
|
main()
|