Utility_Apps/Svg2PngPdf/Archived/svg_to_png_gui.py-C-v2.py

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()