1201 lines
No EOL
57 KiB
Python
1201 lines
No EOL
57 KiB
Python
# #############################################
|
|
# # SVG to Image Converter (using Inkscape) #
|
|
# # Version: 1.3.0 (G-v3-Derived) #
|
|
# #############################################
|
|
#
|
|
# Prerequisites:
|
|
# - Python 3.6+
|
|
# - Inkscape 1.0+ installed and preferably in the system PATH or a standard location.
|
|
# (Older versions might not support all export options like JPEG quality).
|
|
#
|
|
# Required libraries (for development/running from source):
|
|
# pip install Pillow tkinterdnd2
|
|
#
|
|
# To create a standalone executable (after installing PyInstaller: pip install pyinstaller):
|
|
# Windows: pyinstaller --onefile --windowed --name SVG_Converter --icon=icon.ico --add-data="path/to/tkinterdnd2;tkinterdnd2" svg_converter_app_v1_3.py
|
|
# macOS: pyinstaller --onefile --windowed --name SVG_Converter --icon=icon.icns --add-data="path/to/tkinterdnd2:tkinterdnd2" svg_converter_app_v1_3.py
|
|
# Linux: pyinstaller --onefile --windowed --name SVG_Converter --add-data="path/to/tkinterdnd2:tkinterdnd2" svg_converter_app_v1_3.py
|
|
#
|
|
# Notes on PyInstaller:
|
|
# - Replace 'path/to/tkinterdnd2' with the actual path.
|
|
#
|
|
# #############################################
|
|
|
|
import tkinter as tk
|
|
from tkinter import ttk, filedialog, messagebox, scrolledtext, colorchooser
|
|
from tkinterdnd2 import DND_FILES, TkinterDnD # Requires: pip install tkinterdnd2
|
|
import subprocess
|
|
import os
|
|
import sys
|
|
import shutil
|
|
import threading
|
|
import queue
|
|
import json
|
|
from pathlib import Path
|
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
from PIL import Image, ImageTk # Requires: pip install Pillow
|
|
import time # For debounce
|
|
|
|
# --- Constants ---
|
|
APP_NAME = "SVG Converter"
|
|
VERSION = "1.3.0"
|
|
SETTINGS_FILE = "svg_converter_settings.json"
|
|
MAX_CONCURRENT_TASKS = 4 # Default max parallel Inkscape processes
|
|
PREVIEW_DEBOUNCE_MS = 700 # Delay for auto-preview refresh (slightly increased)
|
|
|
|
# --- Helper Functions ---
|
|
|
|
def find_inkscape_executable():
|
|
"""Attempts to find the Inkscape executable."""
|
|
common_paths = []
|
|
names = ["inkscape", "inkscape.exe", "inkscape.com"]
|
|
|
|
# 1. Check PATH
|
|
for name in names:
|
|
inkscape_path = shutil.which(name)
|
|
if inkscape_path:
|
|
return inkscape_path
|
|
|
|
# 2. Check common installation directories
|
|
if sys.platform == "win32":
|
|
program_files = os.environ.get("ProgramFiles", "C:\\Program Files")
|
|
program_files_x86 = os.environ.get("ProgramFiles(x86)", "C:\\Program Files (x86)")
|
|
common_paths.extend([
|
|
Path(program_files) / "Inkscape" / "bin" / "inkscape.exe",
|
|
Path(program_files_x86) / "Inkscape" / "bin" / "inkscape.exe",
|
|
Path(os.environ.get("LOCALAPPDATA", "")) / "Programs" / "Inkscape" / "bin" / "inkscape.exe",
|
|
Path(os.environ.get("ProgramW6432", "")) / "Inkscape" / "bin" / "inkscape.exe",
|
|
])
|
|
elif sys.platform == "darwin": # macOS
|
|
common_paths.extend([
|
|
Path("/Applications/Inkscape.app/Contents/MacOS/inkscape"),
|
|
Path("/usr/local/bin/inkscape"),
|
|
])
|
|
else: # Linux
|
|
common_paths.extend([
|
|
Path("/usr/bin/inkscape"),
|
|
Path("/usr/local/bin/inkscape"),
|
|
Path(os.path.expanduser("~/.local/bin/inkscape")),
|
|
Path("/var/lib/flatpak/exports/bin/org.inkscape.Inkscape"),
|
|
Path("/snap/bin/inkscape"),
|
|
])
|
|
|
|
for path in common_paths:
|
|
try:
|
|
if path.is_file():
|
|
return str(path)
|
|
except OSError:
|
|
continue
|
|
|
|
return None
|
|
|
|
def get_platform_open_command(path):
|
|
"""Returns the command to open a folder in the default file explorer."""
|
|
path = os.path.abspath(path)
|
|
if sys.platform == "win32":
|
|
return lambda: os.startfile(path)
|
|
elif sys.platform == "darwin":
|
|
return lambda: subprocess.run(["open", path], check=True)
|
|
else: # Linux and other POSIX
|
|
return lambda: subprocess.run(["xdg-open", path], check=True)
|
|
|
|
# Simple Tooltip Class
|
|
class Tooltip:
|
|
def __init__(self, widget, text):
|
|
self.widget = widget
|
|
self.text = text
|
|
self.tooltip = None
|
|
self.widget.bind("<Enter>", self.show_tooltip)
|
|
self.widget.bind("<Leave>", self.hide_tooltip)
|
|
self.widget.bind("<ButtonPress>", self.hide_tooltip) # Hide on click too
|
|
|
|
def show_tooltip(self, event=None):
|
|
if self.tooltip: return # Don't stack tooltips
|
|
|
|
x, y, _, _ = self.widget.bbox("insert")
|
|
# Adjust position slightly below and to the right of the cursor
|
|
x = event.x_root + 10
|
|
y = event.y_root + 10
|
|
|
|
self.tooltip = tk.Toplevel(self.widget)
|
|
self.tooltip.wm_overrideredirect(True)
|
|
self.tooltip.wm_geometry(f"+{x}+{y}")
|
|
|
|
# Use style foreground/background for tooltip
|
|
style = ttk.Style()
|
|
bg = style.lookup('TLabel', 'background')
|
|
fg = style.lookup('TLabel', 'foreground')
|
|
border = style.lookup('TFrame', 'bordercolor', default='black') # Get border color if possible
|
|
|
|
label = tk.Label(self.tooltip, text=self.text, background=bg, foreground=fg, relief="solid", borderwidth=1, justify='left', padx=4, pady=2)
|
|
# Configure border color if possible (tk Label, not ttk)
|
|
try:
|
|
label.config(highlightbackground=border, highlightthickness=1)
|
|
except tk.TclError:
|
|
pass # Ignore if options aren't available
|
|
label.pack()
|
|
|
|
def hide_tooltip(self, event=None):
|
|
if self.tooltip:
|
|
self.tooltip.destroy()
|
|
self.tooltip = None
|
|
|
|
|
|
# --- Main Application Class ---
|
|
|
|
class SVGtoPNGConverter(TkinterDnD.Tk):
|
|
def __init__(self):
|
|
super().__init__()
|
|
|
|
# --- Theme Setup (Using 'clam') ---
|
|
self.style = ttk.Style(self)
|
|
available_themes = self.style.theme_names()
|
|
if 'clam' in available_themes:
|
|
self.style.theme_use('clam')
|
|
elif 'vista' in available_themes and sys.platform == 'win32': # Fallback for Windows
|
|
self.style.theme_use('vista')
|
|
# If 'clam' or 'vista' isn't available, it uses the system default
|
|
|
|
self.title(f"{APP_NAME} v{VERSION}")
|
|
# self.geometry("850x700")
|
|
|
|
# --- Detect Inkscape ---
|
|
self.inkscape_path = find_inkscape_executable()
|
|
# Log Inkscape path on startup *before* potentially showing error
|
|
print(f"DEBUG: Using Inkscape found at: {self.inkscape_path or 'Not Found'}")
|
|
if not self.inkscape_path:
|
|
messagebox.showerror("Inkscape Not Found",
|
|
"Inkscape executable could not be found.\n\n"
|
|
"Please ensure Inkscape 1.0+ is installed and:\n"
|
|
" - Added to your system's PATH, OR\n"
|
|
" - Installed in a standard location.\n\n"
|
|
"Conversion will not work without Inkscape.")
|
|
|
|
|
|
# --- Variables ---
|
|
self.selected_files = [] # Stores absolute paths
|
|
self.output_dir = tk.StringVar(value="<Same as source>")
|
|
self.dpi = tk.IntVar(value=150)
|
|
self.background = tk.StringVar(value="#ffffff")
|
|
self.output_format = tk.StringVar(value="png")
|
|
self.export_width = tk.StringVar(value="")
|
|
self.export_height = tk.StringVar(value="")
|
|
self.jpeg_quality = tk.IntVar(value=90)
|
|
self.auto_open_folder = tk.BooleanVar(value=False)
|
|
self.concurrent_tasks = tk.IntVar(value=MAX_CONCURRENT_TASKS)
|
|
self.live_preview_enabled = tk.BooleanVar(value=True) # New variable for live preview toggle
|
|
self.conversion_active = False
|
|
self.cancel_requested = False
|
|
self.failed_files = [] # Stores absolute paths of failed files
|
|
self.log_queue = queue.Queue()
|
|
self.preview_image = None
|
|
self.preview_timer = None
|
|
self.last_selected_index_for_preview = None # Remember last selection
|
|
|
|
# --- Load Settings ---
|
|
self.load_settings()
|
|
|
|
# --- UI Setup ---
|
|
self.setup_ui()
|
|
|
|
# --- Bindings & Traces ---
|
|
self.file_list.bind("<<ListboxSelect>>", self._handle_listbox_select) # Route selection through handler
|
|
self.dpi.trace_add("write", lambda *_: self.debounced_preview_refresh())
|
|
self.background.trace_add("write", lambda *_: self.debounced_preview_refresh())
|
|
self.export_width.trace_add("write", lambda *_: self.debounced_preview_refresh())
|
|
self.export_height.trace_add("write", lambda *_: self.debounced_preview_refresh())
|
|
|
|
# --- Drag and Drop Setup ---
|
|
self.drop_target_register(DND_FILES)
|
|
self.dnd_bind('<<Drop>>', self.handle_drop)
|
|
|
|
# --- Protocol Handlers ---
|
|
self.protocol("WM_DELETE_WINDOW", self.on_closing)
|
|
|
|
# --- Start Log Queue Monitor ---
|
|
self.after(100, self.process_log_queue)
|
|
|
|
# Log Inkscape path clearly in the log area now that it's set up
|
|
self.log(f"Using Inkscape: {self.inkscape_path or 'Not Found - Conversion Disabled'}", "debug" if self.inkscape_path else "error")
|
|
if not self.inkscape_path:
|
|
self.log("Please install Inkscape 1.0+ and ensure it's in your PATH.", "error")
|
|
|
|
|
|
def setup_ui(self):
|
|
"""Creates and arranges the GUI elements with the new layout."""
|
|
|
|
# Main container frame
|
|
self.main_frame = ttk.Frame(self, padding=(15, 10))
|
|
self.main_frame.pack(fill=tk.BOTH, expand=True)
|
|
self.main_frame.columnconfigure(0, weight=1)
|
|
self.main_frame.columnconfigure(1, weight=0) # Preview column less weight
|
|
self.main_frame.rowconfigure(2, weight=1) # Log area expands
|
|
|
|
# --- Left Column ---
|
|
|
|
# Top Frame: File Selection
|
|
file_frame = ttk.LabelFrame(self.main_frame, text="Input SVG Files", padding=10)
|
|
file_frame.grid(row=0, column=0, padx=(0, 10), pady=(0, 10), sticky="ew")
|
|
file_frame.columnconfigure(0, weight=1)
|
|
|
|
btn_frame = ttk.Frame(file_frame)
|
|
btn_frame.grid(row=0, column=0, columnspan=2, pady=(0, 5), sticky="w")
|
|
|
|
ttk.Button(btn_frame, text="Add Files", command=self.add_files).pack(side=tk.LEFT, padx=(0, 5))
|
|
Tooltip(btn_frame.winfo_children()[-1], "Add individual SVG files.")
|
|
ttk.Button(btn_frame, text="Add Folder", command=self.add_folder).pack(side=tk.LEFT, padx=5)
|
|
Tooltip(btn_frame.winfo_children()[-1], "Add all SVG files from a folder (recursive).")
|
|
ttk.Button(btn_frame, text="Remove Selected", command=self.remove_selected_files).pack(side=tk.LEFT, padx=5) # New Button
|
|
Tooltip(btn_frame.winfo_children()[-1], "Remove the highlighted file(s) from the list.")
|
|
ttk.Button(btn_frame, text="Clear List", command=self.clear_list).pack(side=tk.LEFT, padx=5)
|
|
Tooltip(btn_frame.winfo_children()[-1], "Remove all files from the list.")
|
|
|
|
list_frame = ttk.Frame(file_frame)
|
|
list_frame.grid(row=1, column=0, sticky="nsew")
|
|
list_frame.columnconfigure(0, weight=1)
|
|
list_frame.rowconfigure(0, weight=1)
|
|
|
|
self.file_list_scrollbar = ttk.Scrollbar(list_frame, orient=tk.VERTICAL)
|
|
self.file_list = tk.Listbox(list_frame, selectmode=tk.EXTENDED, height=10,
|
|
yscrollcommand=self.file_list_scrollbar.set,
|
|
exportselection=False) # Keep selection on focus loss
|
|
self.file_list_scrollbar.config(command=self.file_list.yview)
|
|
|
|
self.file_list_scrollbar.grid(row=0, column=1, sticky="ns")
|
|
self.file_list.grid(row=0, column=0, sticky="nsew")
|
|
|
|
|
|
# Middle Frame: Settings Tabs
|
|
settings_notebook = ttk.Notebook(self.main_frame, padding=(0, 5))
|
|
settings_notebook.grid(row=1, column=0, padx=(0, 10), pady=(0, 10), sticky="ew")
|
|
|
|
# -- Output Tab --
|
|
output_tab = ttk.Frame(settings_notebook, padding=10)
|
|
settings_notebook.add(output_tab, text=" Output Settings ")
|
|
output_tab.columnconfigure(1, weight=1)
|
|
|
|
ttk.Label(output_tab, text="Format:").grid(row=0, column=0, padx=5, pady=5, sticky="w")
|
|
format_menu = ttk.OptionMenu(output_tab, self.output_format, "png", "png", "jpg", "tiff", "pdf", command=self.update_quality_slider_visibility)
|
|
format_menu.grid(row=0, column=1, columnspan=2, padx=5, pady=5, sticky="ew")
|
|
Tooltip(format_menu, "Choose the output image format.")
|
|
|
|
self.quality_label = ttk.Label(output_tab, text="JPEG Quality:")
|
|
self.quality_scale = ttk.Scale(output_tab, from_=1, to=100, orient=tk.HORIZONTAL, variable=self.jpeg_quality, length=150)
|
|
self.quality_value_label = ttk.Label(output_tab, textvariable=self.jpeg_quality, width=4)
|
|
|
|
ttk.Label(output_tab, text="Output Folder:").grid(row=2, column=0, padx=5, pady=5, sticky="w")
|
|
output_entry = ttk.Entry(output_tab, textvariable=self.output_dir, state='readonly')
|
|
output_entry.grid(row=2, column=1, padx=5, pady=5, sticky="ew")
|
|
Tooltip(output_entry, "Directory where converted files will be saved.\n'<Same as source>' saves alongside original SVG.")
|
|
output_browse_btn = ttk.Button(output_tab, text="Browse...", command=self.choose_output_dir)
|
|
output_browse_btn.grid(row=2, column=2, padx=5, pady=5)
|
|
Tooltip(output_browse_btn, "Choose the output directory.")
|
|
|
|
|
|
tasks_label = ttk.Label(output_tab, text="Parallel Tasks:")
|
|
tasks_label.grid(row=3, column=0, padx=5, pady=5, sticky="w")
|
|
tasks_spinbox = ttk.Spinbox(output_tab, from_=1, to=os.cpu_count() or 4, increment=1, textvariable=self.concurrent_tasks, width=8)
|
|
tasks_spinbox.grid(row=3, column=1, padx=5, pady=5, sticky="w")
|
|
Tooltip(tasks_label, "Number of Inkscape processes to run at once (max = CPU cores).\nHigher might be faster but uses more CPU/RAM.")
|
|
Tooltip(tasks_spinbox, "Number of Inkscape processes to run at once (max = CPU cores).\nHigher might be faster but uses more CPU/RAM.")
|
|
|
|
auto_open_check = ttk.Checkbutton(output_tab, text="Auto-open folder on completion", variable=self.auto_open_folder)
|
|
auto_open_check.grid(row=4, column=0, columnspan=3, padx=5, pady=10, sticky="w")
|
|
Tooltip(auto_open_check, "Automatically open the output folder in your file explorer after conversion finishes.")
|
|
|
|
|
|
# -- Image Tab --
|
|
image_tab = ttk.Frame(settings_notebook, padding=10)
|
|
settings_notebook.add(image_tab, text=" Image Settings ")
|
|
image_tab.columnconfigure(1, weight=1)
|
|
image_tab.columnconfigure(3, weight=1)
|
|
|
|
dpi_label = ttk.Label(image_tab, text="DPI:")
|
|
dpi_label.grid(row=0, column=0, padx=5, pady=5, sticky="w")
|
|
dpi_spinbox = ttk.Spinbox(image_tab, from_=10, to=1200, increment=10, textvariable=self.dpi, width=8)
|
|
dpi_spinbox.grid(row=0, column=1, padx=5, pady=5, sticky="ew")
|
|
Tooltip(dpi_label, "Dots Per Inch - affects raster image resolution (PNG, JPG, TIFF).\nHigher DPI = larger file size & better print quality (e.g., 300 for print).")
|
|
Tooltip(dpi_spinbox, "Dots Per Inch - affects raster image resolution (PNG, JPG, TIFF).\nHigher DPI = larger file size & better print quality (e.g., 300 for print).")
|
|
|
|
bg_label = ttk.Label(image_tab, text="Background:")
|
|
bg_label.grid(row=0, column=2, padx=(20, 5), pady=5, sticky="w")
|
|
bg_entry = ttk.Entry(image_tab, textvariable=self.background, width=10)
|
|
bg_entry.grid(row=0, column=3, padx=5, pady=5, sticky="ew")
|
|
bg_color_btn = ttk.Button(image_tab, text="Color...", command=self.choose_color)
|
|
bg_color_btn.grid(row=0, column=4, padx=(0, 5), pady=5)
|
|
Tooltip(bg_label, "Background color (e.g., #ffffff, transparent, black).\n'transparent' works best for PNG (no quotes needed).")
|
|
Tooltip(bg_entry, "Background color (e.g., #ffffff, transparent, black).\n'transparent' works best for PNG (no quotes needed).")
|
|
Tooltip(bg_color_btn, "Choose background color visually.")
|
|
|
|
width_label = ttk.Label(image_tab, text="Width (px):")
|
|
width_label.grid(row=1, column=0, padx=5, pady=5, sticky="w")
|
|
width_entry = ttk.Entry(image_tab, textvariable=self.export_width, width=8)
|
|
width_entry.grid(row=1, column=1, padx=5, pady=5, sticky="ew")
|
|
Tooltip(width_label, "Specify output width in pixels (optional).\nOverrides DPI scaling if set. Leave blank for auto.")
|
|
Tooltip(width_entry, "Specify output width in pixels (optional).\nOverrides DPI scaling if set. Leave blank for auto.")
|
|
|
|
height_label = ttk.Label(image_tab, text="Height (px):")
|
|
height_label.grid(row=1, column=2, padx=(20, 5), pady=5, sticky="w")
|
|
height_entry = ttk.Entry(image_tab, textvariable=self.export_height, width=8)
|
|
height_entry.grid(row=1, column=3, padx=5, pady=5, sticky="ew")
|
|
Tooltip(height_label, "Specify output height in pixels (optional).\nOverrides DPI scaling if set. Leave blank for auto.")
|
|
Tooltip(height_entry, "Specify output height in pixels (optional).\nOverrides DPI scaling if set. Leave blank for auto.")
|
|
|
|
ttk.Label(image_tab, text="(Optional)").grid(row=1, column=4, padx=(0, 5), pady=5, sticky="w")
|
|
|
|
self.update_quality_slider_visibility() # Set initial visibility
|
|
|
|
|
|
# Bottom Frame: Log and Controls
|
|
log_control_frame = ttk.Frame(self.main_frame)
|
|
log_control_frame.grid(row=2, column=0, padx=(0, 10), pady=(10, 0), sticky="nsew")
|
|
log_control_frame.rowconfigure(1, weight=1)
|
|
log_control_frame.columnconfigure(0, weight=1)
|
|
|
|
control_frame = ttk.Frame(log_control_frame)
|
|
control_frame.grid(row=0, column=0, sticky="ew", pady=(0,5))
|
|
control_frame.columnconfigure(2, weight=1)
|
|
|
|
self.convert_button = ttk.Button(control_frame, text="Start Conversion", command=self.start_conversion)
|
|
self.convert_button.grid(row=0, column=0, padx=(0, 5))
|
|
Tooltip(self.convert_button, "Start converting the files in the list using current settings, or cancel if running.")
|
|
self.retry_button = ttk.Button(control_frame, text="Retry Failed", command=self.retry_failed, state=tk.DISABLED)
|
|
self.retry_button.grid(row=0, column=1, padx=5)
|
|
Tooltip(self.retry_button, "Re-attempt conversion only for files that failed in the last run.")
|
|
|
|
self.progress_label = ttk.Label(control_frame, text="Progress:")
|
|
self.progress_label.grid(row=0, column=2, padx=(10, 5), sticky='e')
|
|
self.progress = ttk.Progressbar(control_frame, orient='horizontal', length=200, mode='determinate')
|
|
self.progress.grid(row=0, column=3, padx=0, sticky="ew")
|
|
self.status_label = ttk.Label(control_frame, text="", width=18, anchor="e")
|
|
self.status_label.grid(row=0, column=4, padx=(5, 0), sticky='e')
|
|
|
|
log_frame = ttk.LabelFrame(log_control_frame, text="Log", padding=5)
|
|
log_frame.grid(row=1, column=0, sticky="nsew")
|
|
log_frame.columnconfigure(0, weight=1)
|
|
log_frame.rowconfigure(0, weight=1)
|
|
|
|
self.log_area = scrolledtext.ScrolledText(log_frame, height=8, wrap=tk.WORD, state='disabled', bd=0) # Use bd=0 for theme consistency
|
|
self.log_area.grid(row=0, column=0, sticky="nsew")
|
|
# Configure tags for log levels
|
|
self.log_area.tag_config("info", foreground=self.style.lookup('TLabel', 'foreground')) # Use theme's default text color
|
|
self.log_area.tag_config("success", foreground="green")
|
|
self.log_area.tag_config("warning", foreground="orange")
|
|
self.log_area.tag_config("error", foreground="red")
|
|
self.log_area.tag_config("debug", foreground="grey")
|
|
|
|
|
|
# --- Right Column: Preview ---
|
|
preview_frame = ttk.LabelFrame(self.main_frame, text="Preview", padding=10)
|
|
preview_frame.grid(row=0, column=1, rowspan=3, padx=5, pady=0, sticky="nsew")
|
|
preview_frame.columnconfigure(0, weight=1)
|
|
preview_frame.rowconfigure(0, weight=1) # Let canvas expand
|
|
|
|
canvas_bg = self.style.lookup('TFrame', 'background') # Use theme background
|
|
self.preview_canvas = tk.Canvas(preview_frame, width=150, height=150, bg=canvas_bg, relief="sunken", bd=1, highlightthickness=0) # Added highlightthickness=0
|
|
self.preview_canvas.grid(row=0, column=0, pady=(0, 5), sticky="nsew")
|
|
|
|
preview_controls = ttk.Frame(preview_frame)
|
|
preview_controls.grid(row=1, column=0, sticky="ew")
|
|
preview_controls.columnconfigure(0, weight=1) # Center controls below canvas
|
|
|
|
# Live Preview Checkbox
|
|
self.live_preview_check = ttk.Checkbutton(preview_controls, text="Live Preview", variable=self.live_preview_enabled)
|
|
self.live_preview_check.grid(row=0, column=0, pady=(0, 5), padx=5, sticky="w")
|
|
Tooltip(self.live_preview_check, "Automatically update preview when selecting files or changing settings (can be slow).")
|
|
|
|
# Refresh Button
|
|
self.refresh_button = ttk.Button(preview_controls, text="Refresh Now", command=lambda: self.show_preview(force_refresh=True))
|
|
self.refresh_button.grid(row=0, column=1, pady=(0, 5), padx=5, sticky="e")
|
|
Tooltip(self.refresh_button, "Manually generate a preview for the selected file using current settings.")
|
|
|
|
|
|
def update_quality_slider_visibility(self, *args):
|
|
"""Show JPEG quality slider only when JPEG format is selected."""
|
|
# Find the 'Output Settings' tab frame dynamically (less fragile)
|
|
output_tab = None
|
|
try:
|
|
settings_notebook = self.main_frame.winfo_children()[1] # Assume notebook is 2nd child
|
|
for tab_id in settings_notebook.tabs():
|
|
if settings_notebook.tab(tab_id, "text").strip() == "Output Settings":
|
|
output_tab = settings_notebook.nametowidget(tab_id)
|
|
break
|
|
except Exception:
|
|
self.log("Could not find Output Settings tab to update JPEG quality slider.", "error")
|
|
return # Cannot proceed
|
|
|
|
if not output_tab: return # Should not happen if UI setup is correct
|
|
|
|
if self.output_format.get().lower() == "jpg":
|
|
self.quality_label.grid(in_=output_tab, row=1, column=0, padx=5, pady=5, sticky="w")
|
|
self.quality_scale.grid(in_=output_tab, row=1, column=1, padx=5, pady=5, sticky="ew")
|
|
self.quality_value_label.grid(in_=output_tab, row=1, column=2, padx=(0, 5), pady=5, sticky="w")
|
|
Tooltip(self.quality_label, "JPEG compression quality (1-100).\nHigher = better quality, larger file.\nRequires Inkscape 1.0+.")
|
|
Tooltip(self.quality_scale, "JPEG compression quality (1-100).\nHigher = better quality, larger file.\nRequires Inkscape 1.0+.")
|
|
else:
|
|
self.quality_label.grid_remove()
|
|
self.quality_scale.grid_remove()
|
|
self.quality_value_label.grid_remove()
|
|
|
|
def choose_color(self):
|
|
"""Open color chooser dialog to select background color."""
|
|
initial_color = self.background.get()
|
|
if initial_color.lower() == 'transparent':
|
|
initial_color = '#ffffff'
|
|
|
|
try:
|
|
# Use the main window as parent for the color chooser
|
|
color_code = colorchooser.askcolor(parent=self, title ="Choose background color", initialcolor=initial_color)
|
|
if color_code and color_code[1]:
|
|
self.background.set(color_code[1])
|
|
self.debounced_preview_refresh() # Trigger preview update after color change
|
|
except tk.TclError:
|
|
color_code = colorchooser.askcolor(parent=self, title ="Choose background color")
|
|
if color_code and color_code[1]:
|
|
self.background.set(color_code[1])
|
|
self.debounced_preview_refresh()
|
|
|
|
def log(self, message, level="info"):
|
|
"""Adds a message to the log queue for thread-safe display."""
|
|
self.log_queue.put((message, level))
|
|
|
|
def process_log_queue(self):
|
|
"""Processes messages from the log queue and updates the GUI."""
|
|
# Update default info color tag based on current theme fg
|
|
try:
|
|
default_fg = self.style.lookup('TLabel', 'foreground', default='black')
|
|
self.log_area.tag_config("info", foreground=default_fg)
|
|
except tk.TclError: pass # Ignore theme errors
|
|
|
|
while not self.log_queue.empty():
|
|
try:
|
|
message, level = self.log_queue.get_nowait()
|
|
self.log_area.configure(state='normal')
|
|
self.log_area.insert(tk.END, f"{message}\n", level)
|
|
self.log_area.configure(state='disabled')
|
|
self.log_area.see(tk.END)
|
|
except queue.Empty:
|
|
break
|
|
except Exception as e:
|
|
print(f"Log Error: {e}")
|
|
self.after(100, self.process_log_queue)
|
|
|
|
def handle_drop(self, event):
|
|
"""Handles files dropped onto the application window."""
|
|
files_to_add = []
|
|
try:
|
|
raw_files = self.tk.splitlist(event.data)
|
|
for item in raw_files:
|
|
cleaned_item = item.strip().strip('{}')
|
|
if not cleaned_item: continue
|
|
|
|
path = Path(cleaned_item)
|
|
if path.is_file() and path.suffix.lower() == ".svg":
|
|
files_to_add.append(str(path.resolve()))
|
|
elif path.is_dir():
|
|
self.log(f"Scanning dropped folder: {path}", "debug")
|
|
for sub_item in path.rglob('*.svg'):
|
|
if sub_item.is_file():
|
|
files_to_add.append(str(sub_item.resolve()))
|
|
if files_to_add:
|
|
self.update_file_list(files_to_add)
|
|
else:
|
|
self.log("Drop contained no valid SVG files or folders.", "warning")
|
|
except Exception as e:
|
|
self.log(f"Error processing dropped files: {e}", "error")
|
|
messagebox.showerror("Drop Error", f"Could not process dropped files:\n{e}")
|
|
|
|
|
|
def update_file_list(self, new_files):
|
|
"""Adds new files to the listbox, avoiding duplicates."""
|
|
count = 0
|
|
current_files_set = set(self.selected_files)
|
|
original_failed = set(self.failed_files)
|
|
|
|
for file_path in new_files:
|
|
abs_path = str(Path(file_path).resolve())
|
|
if abs_path not in current_files_set:
|
|
self.selected_files.append(abs_path)
|
|
display_name = os.path.basename(abs_path)
|
|
self.file_list.insert(tk.END, display_name)
|
|
|
|
index = self.file_list.size() - 1
|
|
# Check if this newly added file was previously failed and highlight it
|
|
if abs_path in original_failed:
|
|
self.file_list.itemconfig(index, fg="red")
|
|
# Optionally select the first added file to trigger initial preview
|
|
# if count == 0 and self.file_list.size() > 0:
|
|
# self.file_list.selection_clear(0, tk.END)
|
|
# self.file_list.selection_set(index)
|
|
# self.file_list.activate(index)
|
|
# self._handle_listbox_select() # Manually trigger preview if live enabled
|
|
|
|
current_files_set.add(abs_path)
|
|
count += 1
|
|
|
|
if count > 0:
|
|
self.log(f"Added {count} new SVG file(s). Total: {len(self.selected_files)}")
|
|
# Select the first *newly* added file if nothing was selected before
|
|
if not self.file_list.curselection() and self.file_list.size() > 0:
|
|
first_new_index = self.file_list.size() - count
|
|
self.file_list.selection_set(first_new_index)
|
|
self.file_list.activate(first_new_index)
|
|
self._handle_listbox_select() # Trigger potential preview
|
|
|
|
self.update_status()
|
|
|
|
def add_files(self):
|
|
"""Opens file dialog to select SVG files."""
|
|
files = filedialog.askopenfilenames(
|
|
title="Select SVG Files",
|
|
filetypes=[("SVG files", "*.svg"), ("All files", "*.*")]
|
|
)
|
|
if files:
|
|
self.update_file_list(files)
|
|
|
|
def add_folder(self):
|
|
"""Opens directory dialog to select a folder containing SVGs."""
|
|
folder = filedialog.askdirectory(title="Select Folder Containing SVGs")
|
|
if folder:
|
|
self.log(f"Scanning folder: {folder}")
|
|
svg_files = [str(p.resolve()) for p in Path(folder).rglob('*.svg') if p.is_file()] # Store resolved paths
|
|
if svg_files:
|
|
self.update_file_list(svg_files)
|
|
else:
|
|
self.log(f"No SVG files found in {folder}", "warning")
|
|
|
|
def remove_selected_files(self):
|
|
"""Removes the selected files from the list."""
|
|
selected_indices = self.file_list.curselection()
|
|
if not selected_indices:
|
|
messagebox.showwarning("No Selection", "Please select files in the list to remove.")
|
|
return
|
|
|
|
# Remove from listbox and internal list (iterate backwards)
|
|
removed_count = 0
|
|
# Get the paths *before* deleting from the listbox
|
|
paths_to_remove = {self.selected_files[i] for i in selected_indices}
|
|
|
|
for index in reversed(selected_indices):
|
|
try:
|
|
# Remove from internal list first using index
|
|
del self.selected_files[index]
|
|
# Remove from listbox
|
|
self.file_list.delete(index)
|
|
removed_count += 1
|
|
except IndexError:
|
|
self.log(f"Error removing item at index {index}.", "error")
|
|
|
|
# Remove from failed files list if any removed files were there
|
|
self.failed_files = [f for f in self.failed_files if f not in paths_to_remove]
|
|
|
|
if removed_count > 0:
|
|
self.log(f"Removed {removed_count} file(s).")
|
|
self.update_status()
|
|
# Clear preview if the previewed item was removed
|
|
if self.last_selected_index_for_preview in selected_indices:
|
|
self.preview_canvas.delete("all")
|
|
self.preview_image = None
|
|
self.last_selected_index_for_preview = None
|
|
# Or if nothing is left selected
|
|
if not self.file_list.curselection():
|
|
self.preview_canvas.delete("all")
|
|
self.preview_image = None
|
|
self.last_selected_index_for_preview = None
|
|
# Ensure retry button state is correct
|
|
self.retry_button.config(state=tk.NORMAL if self.failed_files else tk.DISABLED)
|
|
|
|
def clear_list(self):
|
|
"""Clears the file list and internal selection."""
|
|
self.selected_files.clear()
|
|
self.file_list.delete(0, tk.END)
|
|
self.failed_files.clear()
|
|
self.retry_button.config(state=tk.DISABLED)
|
|
self.log("File list cleared.")
|
|
self.update_status()
|
|
self.progress['value'] = 0
|
|
self.preview_canvas.delete("all")
|
|
self.preview_image = None
|
|
self.last_selected_index_for_preview = None
|
|
|
|
def choose_output_dir(self):
|
|
"""Allows user to select an output directory."""
|
|
directory = filedialog.askdirectory(title="Select Output Directory")
|
|
if directory:
|
|
self.output_dir.set(directory)
|
|
|
|
def _handle_listbox_select(self, event=None):
|
|
"""Handles selection changes in the listbox, triggering preview if enabled."""
|
|
if self.live_preview_enabled.get():
|
|
# Get current selection *before* debouncing
|
|
current_selection = self.file_list.curselection()
|
|
if current_selection:
|
|
self.last_selected_index_for_preview = current_selection[0]
|
|
self.debounced_preview_refresh(use_last_selected=True)
|
|
else:
|
|
# Nothing selected, clear preview maybe? Or do nothing?
|
|
self.last_selected_index_for_preview = None
|
|
self.preview_canvas.delete("all")
|
|
self.preview_image = None
|
|
|
|
def debounced_preview_refresh(self, use_last_selected=False):
|
|
"""Cancels existing timer and schedules a new preview refresh if live preview is enabled."""
|
|
if not self.live_preview_enabled.get() and not use_last_selected: # Allow selection changes even if live settings changes are off
|
|
return # Don't auto-refresh if live preview is off for settings changes
|
|
|
|
if self.preview_timer:
|
|
self.preview_timer.cancel()
|
|
|
|
# The actual call to show_preview needs to happen on the Tk main thread
|
|
self.preview_timer = threading.Timer(PREVIEW_DEBOUNCE_MS / 1000.0,
|
|
lambda: self.after(0, self.show_preview, use_last_selected))
|
|
self.preview_timer.daemon = True
|
|
self.preview_timer.start()
|
|
|
|
|
|
def show_preview(self, use_last_selected=False, force_refresh=False):
|
|
"""
|
|
Shows a preview of the selected SVG.
|
|
use_last_selected=True: forces preview of the item stored in self.last_selected_index_for_preview
|
|
force_refresh=True: forces preview generation even if live preview is off (used by button)
|
|
"""
|
|
if not force_refresh and not self.live_preview_enabled.get() and not use_last_selected:
|
|
return # Don't show if live preview is off unless forced or triggered by selection
|
|
|
|
selected_index = None
|
|
if force_refresh or use_last_selected:
|
|
# Use the last known selected index if forced or requested
|
|
if self.last_selected_index_for_preview is not None and self.last_selected_index_for_preview < len(self.selected_files):
|
|
selected_index = self.last_selected_index_for_preview
|
|
elif self.file_list.curselection(): # Fallback to current selection if last is invalid
|
|
selected_index = self.file_list.curselection()[0]
|
|
elif len(self.selected_files) > 0: # Fallback to first item if nothing selected
|
|
selected_index = 0
|
|
else: # Normal selection-based trigger (if live preview is on)
|
|
selection_indices = self.file_list.curselection()
|
|
if selection_indices:
|
|
selected_index = selection_indices[0]
|
|
else: # No selection, clear preview
|
|
self.preview_canvas.delete("all")
|
|
self.preview_image = None
|
|
return
|
|
|
|
if selected_index is None or selected_index >= len(self.selected_files):
|
|
# self.log("Preview requested but no valid item selected.", "debug")
|
|
self.preview_canvas.delete("all")
|
|
self.preview_image = None
|
|
return
|
|
|
|
# Store the index we are actually previewing now
|
|
self.last_selected_index_for_preview = selected_index
|
|
|
|
self.preview_canvas.delete("all")
|
|
self.preview_image = None
|
|
|
|
try:
|
|
svg_path = self.selected_files[selected_index]
|
|
filename = os.path.basename(svg_path)
|
|
|
|
if not self.inkscape_path:
|
|
self.preview_canvas.create_text(75, 75, text="Inkscape\nNot Found", fill="red", width=140, anchor="center", justify="center")
|
|
return
|
|
|
|
self.preview_canvas.create_text(75, 75, text=f"Loading\n{filename}...", fill="grey", width=140, anchor="center", justify="center")
|
|
self.update_idletasks() # Ensure placeholder text is drawn
|
|
|
|
temp_png_path = Path(os.path.expanduser("~")) / f".svg_converter_preview_{os.getpid()}.png"
|
|
|
|
cmd = [
|
|
self.inkscape_path,
|
|
f"--export-filename={temp_png_path}",
|
|
"--export-type=png",
|
|
f"--export-dpi={self.dpi.get()}",
|
|
f"--export-background={self.background.get()}",
|
|
]
|
|
bg_color_str = self.background.get().lower()
|
|
if bg_color_str != 'transparent' and bg_color_str: # Add opacity if background is set and not transparent
|
|
cmd.append("--export-background-opacity=1")
|
|
else: # Explicitly set transparent if bg is 'transparent' or empty
|
|
cmd.append("--export-background-opacity=0")
|
|
|
|
|
|
w = self.export_width.get()
|
|
h = self.export_height.get()
|
|
if w: cmd.append(f"--export-width={w}")
|
|
if h: cmd.append(f"--export-height={h}")
|
|
|
|
cmd.append(svg_path)
|
|
|
|
try:
|
|
# Use timeout
|
|
# Hide console window on Windows when running subprocess
|
|
creationflags = 0
|
|
if sys.platform == "win32":
|
|
creationflags = subprocess.CREATE_NO_WINDOW
|
|
|
|
result = subprocess.run(cmd, capture_output=True, text=True, check=False, timeout=10, encoding='utf-8', creationflags=creationflags)
|
|
|
|
self.preview_canvas.delete("all") # Clear placeholder
|
|
|
|
if result.returncode == 0 and temp_png_path.exists() and temp_png_path.stat().st_size > 0:
|
|
img = Image.open(temp_png_path)
|
|
img.thumbnail((150, 150), Image.Resampling.LANCZOS)
|
|
self.preview_image = ImageTk.PhotoImage(img)
|
|
self.preview_canvas.create_image(75, 75, image=self.preview_image, anchor="center")
|
|
else:
|
|
self.preview_canvas.create_text(75, 75, text="Preview\nFailed", fill="orange", width=140, anchor="center", justify="center")
|
|
err_log = result.stderr.strip()[:100] if result.stderr else result.stdout.strip()[:100]
|
|
self.log(f"Preview failed for {filename}: Inkscape RC={result.returncode}. {err_log}...", "warning")
|
|
|
|
except subprocess.TimeoutExpired:
|
|
self.preview_canvas.delete("all")
|
|
self.preview_canvas.create_text(75, 75, text="Preview\nTimeout", fill="orange", width=140, anchor="center", justify="center")
|
|
self.log(f"Preview timed out for {filename}", "warning")
|
|
except Exception as e:
|
|
self.preview_canvas.delete("all")
|
|
self.preview_canvas.create_text(75, 75, text="Preview\nError", fill="red", width=140, anchor="center", justify="center")
|
|
self.log(f"Preview error for {filename}: {e}", "error")
|
|
finally:
|
|
if temp_png_path.exists():
|
|
try: temp_png_path.unlink()
|
|
except OSError as e: self.log(f"Could not delete temp preview file: {e}", "debug")
|
|
|
|
except IndexError:
|
|
self.log("Error getting selection for preview (index out of range).", "error")
|
|
self.preview_canvas.delete("all")
|
|
self.preview_canvas.create_text(75, 75, text="Selection\nError", fill="red")
|
|
except Exception as e:
|
|
self.log(f"General error during preview generation: {e}", "error")
|
|
self.preview_canvas.delete("all")
|
|
self.preview_canvas.create_text(75, 75, text="Error", fill="red")
|
|
|
|
|
|
def update_status(self, text=""):
|
|
"""Updates the status label, typically showing file counts."""
|
|
if not text:
|
|
total = len(self.selected_files)
|
|
failed = len(self.failed_files)
|
|
selected_count = len(self.file_list.curselection())
|
|
|
|
status_parts = []
|
|
if total > 0:
|
|
status_parts.append(f"{total} File{'s' if total != 1 else ''}")
|
|
if selected_count > 0 and total > 0 :
|
|
status_parts.append(f"({selected_count} Selected)")
|
|
if failed > 0:
|
|
status_parts.append(f"[{failed} Failed]")
|
|
|
|
text = ' '.join(status_parts)
|
|
|
|
self.status_label.config(text=text)
|
|
|
|
def set_ui_state(self, active):
|
|
"""Enable/disable UI elements during conversion."""
|
|
self.conversion_active = active
|
|
self.cancel_requested = False
|
|
state = tk.DISABLED if active else tk.NORMAL
|
|
|
|
# Controls to disable/enable
|
|
widgets_to_toggle = []
|
|
|
|
# File frame buttons (except Convert/Retry)
|
|
file_frame = self.main_frame.winfo_children()[0]
|
|
btn_frame = file_frame.winfo_children()[0]
|
|
widgets_to_toggle.extend([
|
|
btn_frame.winfo_children()[0], # Add Files
|
|
btn_frame.winfo_children()[1], # Add Folder
|
|
btn_frame.winfo_children()[2], # Remove Selected
|
|
btn_frame.winfo_children()[3], # Clear List
|
|
])
|
|
# Listbox
|
|
widgets_to_toggle.append(self.file_list)
|
|
|
|
# Settings notebook tabs
|
|
settings_notebook = self.main_frame.winfo_children()[1]
|
|
for tab_id in settings_notebook.tabs():
|
|
tab_frame = settings_notebook.nametowidget(tab_id)
|
|
for widget in tab_frame.winfo_children():
|
|
if isinstance(widget, (ttk.Button, ttk.Spinbox, ttk.Entry, ttk.OptionMenu, ttk.Scale, ttk.Checkbutton)):
|
|
widgets_to_toggle.append(widget)
|
|
|
|
# Preview controls
|
|
preview_frame = self.main_frame.winfo_children()[3] # Should be preview frame
|
|
preview_controls = preview_frame.winfo_children()[1] # Should be controls frame inside preview
|
|
widgets_to_toggle.extend(preview_controls.winfo_children()) # Checkbox and Refresh button
|
|
|
|
# Apply state
|
|
for widget in widgets_to_toggle:
|
|
try:
|
|
# Handle readonly state for Entry widgets differently
|
|
if isinstance(widget, ttk.Entry) and widget.cget('state') == 'readonly':
|
|
pass # Keep readonly entries as readonly
|
|
else:
|
|
widget.config(state=state)
|
|
except tk.TclError:
|
|
pass # Ignore if widget doesn't support state
|
|
|
|
# Special handling for Convert/Retry buttons
|
|
self.convert_button.config(text="Cancel" if active else "Start Conversion")
|
|
self.convert_button.config(state=tk.NORMAL if not (active and self.cancel_requested) else tk.DISABLED)
|
|
|
|
retry_state = tk.NORMAL if not active and self.failed_files else tk.DISABLED
|
|
self.retry_button.config(state=retry_state)
|
|
|
|
# Keep log area scrollable but not editable
|
|
self.log_area.configure(state='disabled')
|
|
|
|
|
|
def start_conversion(self):
|
|
"""Initiates the conversion process or handles cancellation."""
|
|
if self.conversion_active:
|
|
# Handle Cancellation
|
|
if not self.cancel_requested:
|
|
self.log("Cancellation requested...", "warning")
|
|
self.cancel_requested = True
|
|
self.conversion_active = False # Signal threads
|
|
self.convert_button.config(text="Cancelling...", state=tk.DISABLED)
|
|
return
|
|
|
|
if not self.selected_files:
|
|
messagebox.showwarning("No Files", "Please add SVG files to the list first.")
|
|
return
|
|
|
|
if not self.inkscape_path:
|
|
messagebox.showerror("Inkscape Not Found", "Cannot start conversion without Inkscape.")
|
|
return
|
|
|
|
self.log("="*30)
|
|
self.log(f"Starting conversion for {len(self.selected_files)} file(s)...")
|
|
settings_summary = f"Format={self.output_format.get()}, DPI={self.dpi.get()}, BG={self.background.get()}"
|
|
if self.output_format.get().lower() == "jpg":
|
|
settings_summary += f", Quality={self.jpeg_quality.get()}"
|
|
dims = []
|
|
if self.export_width.get(): dims.append(f"W={self.export_width.get()}")
|
|
if self.export_height.get(): dims.append(f"H={self.export_height.get()}")
|
|
if dims: settings_summary += f", {' '.join(dims)}px"
|
|
self.log(f"Settings: {settings_summary}", "debug")
|
|
if self.output_dir.get() != "<Same as source>":
|
|
self.log(f"Output Dir: {self.output_dir.get()}", "debug")
|
|
|
|
self.set_ui_state(active=True)
|
|
self.progress['value'] = 0
|
|
self.progress['maximum'] = len(self.selected_files)
|
|
self.failed_files.clear()
|
|
for i in range(self.file_list.size()):
|
|
self.file_list.itemconfig(i, fg="") # Reset colors
|
|
|
|
self.update_status("Running...")
|
|
|
|
self.conversion_thread = threading.Thread(target=self.run_conversion_threaded, daemon=True)
|
|
self.conversion_thread.start()
|
|
|
|
def run_conversion_threaded(self):
|
|
"""Manages the conversion process using a thread pool."""
|
|
files_to_process = list(self.selected_files)
|
|
total_files = len(files_to_process)
|
|
success_count = 0
|
|
processed_count = 0
|
|
local_failed_files = []
|
|
final_output_dir = None
|
|
|
|
max_workers = self.concurrent_tasks.get()
|
|
if max_workers < 1: max_workers = 1
|
|
self.log(f"Using up to {max_workers} parallel tasks.", "debug")
|
|
|
|
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
|
futures = {executor.submit(self.convert_file, file_path): file_path for file_path in files_to_process}
|
|
|
|
for future in as_completed(futures):
|
|
if self.cancel_requested: continue
|
|
|
|
file_path = futures[future]
|
|
processed_count += 1
|
|
try:
|
|
output_path = future.result()
|
|
if self.cancel_requested: continue
|
|
|
|
if output_path:
|
|
success_count += 1
|
|
# Log success only if not cancelled mid-process
|
|
if not self.cancel_requested:
|
|
self.log(f"Success: {os.path.basename(file_path)} -> {os.path.basename(output_path)}", "success")
|
|
final_output_dir = os.path.dirname(output_path)
|
|
else:
|
|
# Failure logged in convert_file, store path if not cancelled
|
|
if not self.cancel_requested and file_path not in local_failed_files:
|
|
local_failed_files.append(file_path)
|
|
|
|
except Exception as exc:
|
|
if not self.cancel_requested:
|
|
self.log(f"Error processing {os.path.basename(file_path)}: {exc}", "error")
|
|
if file_path not in local_failed_files:
|
|
local_failed_files.append(file_path)
|
|
finally:
|
|
# Update progress bar via main thread scheduler
|
|
self.after(0, self.update_progress, processed_count, total_files)
|
|
|
|
if self.cancel_requested:
|
|
self.log("Conversion cancelled by user.", "warning")
|
|
for f in futures:
|
|
if not f.done(): f.cancel()
|
|
|
|
self.failed_files = local_failed_files
|
|
self.after(0, self.finish_conversion, success_count, total_files, final_output_dir)
|
|
|
|
|
|
def update_progress(self, current, total):
|
|
"""Updates the progress bar value."""
|
|
if total > 0:
|
|
self.progress['value'] = current
|
|
|
|
def finish_conversion(self, success_count, total_files, final_output_dir):
|
|
"""Called after all conversion threads complete or are cancelled."""
|
|
failed_count = len(self.failed_files)
|
|
processed_count = success_count + failed_count
|
|
|
|
self.log("="*30)
|
|
if self.cancel_requested:
|
|
self.log(f"Conversion Cancelled. Processed: {processed_count}/{total_files} (S:{success_count}, F:{failed_count})", "warning")
|
|
else:
|
|
self.log(f"Conversion Complete. Success: {success_count}/{total_files}, Failed: {failed_count}")
|
|
|
|
# Highlight failed files in the listbox
|
|
if self.failed_files:
|
|
# Create a mapping from absolute path to listbox index for efficiency
|
|
path_to_index = {path: i for i, path in enumerate(self.selected_files)}
|
|
for failed_path in self.failed_files:
|
|
list_index = path_to_index.get(failed_path)
|
|
if list_index is not None and 0 <= list_index < self.file_list.size():
|
|
try:
|
|
self.file_list.itemconfig(list_index, {'fg': 'red'})
|
|
except tk.TclError as e: # Handle cases where item might be gone unexpectedly
|
|
self.log(f"Error highlighting item at index {list_index}: {e}", "debug")
|
|
else:
|
|
self.log(f"Could not find index for failed file {os.path.basename(failed_path)} to highlight.", "debug")
|
|
|
|
|
|
self.set_ui_state(active=False)
|
|
self.update_status() # Update final counts
|
|
|
|
if self.cancel_requested:
|
|
messagebox.showwarning("Cancelled", f"Conversion cancelled.\n{processed_count} files processed ({failed_count} failed).")
|
|
elif failed_count > 0:
|
|
messagebox.showwarning("Conversion Issues", f"{failed_count} file(s) failed to convert. Check the log for details.\nFailed files are marked red in the list.")
|
|
elif success_count > 0:
|
|
messagebox.showinfo("Conversion Complete", f"Successfully converted {success_count} file(s).")
|
|
else: # 0 success, 0 failed (and not cancelled)
|
|
messagebox.showinfo("Conversion Complete", "No files were converted (or all failed).")
|
|
|
|
if self.auto_open_folder.get() and final_output_dir and success_count > 0 and not self.cancel_requested:
|
|
try:
|
|
self.log(f"Opening output folder: {final_output_dir}", "info")
|
|
open_folder = get_platform_open_command(final_output_dir)
|
|
open_folder()
|
|
except Exception as e:
|
|
self.log(f"Failed to open output folder: {e}", "error")
|
|
messagebox.showwarning("Open Folder Error", f"Could not automatically open the output folder:\n{final_output_dir}\nError: {e}")
|
|
|
|
|
|
def convert_file(self, svg_path_str):
|
|
"""
|
|
Converts a single SVG file using Inkscape. Returns output path or None.
|
|
Checks the self.cancel_requested flag.
|
|
"""
|
|
if self.cancel_requested: return None
|
|
|
|
svg_path = Path(svg_path_str)
|
|
base_name = svg_path.stem
|
|
source_dir = svg_path.parent
|
|
|
|
chosen_output_dir = self.output_dir.get()
|
|
if chosen_output_dir == "<Same as source>" or not chosen_output_dir:
|
|
output_dir = source_dir
|
|
else:
|
|
output_dir = Path(chosen_output_dir)
|
|
try:
|
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
except OSError as e:
|
|
self.log(f"Error creating output dir '{output_dir}': {e}. Saving to source dir instead.", "error")
|
|
output_dir = source_dir
|
|
|
|
fmt = self.output_format.get().lower()
|
|
output_filename = output_dir / f"{base_name}.{fmt}"
|
|
|
|
cmd = [
|
|
self.inkscape_path,
|
|
f"--export-filename={str(output_filename)}",
|
|
f"--export-type={fmt}",
|
|
f"--export-dpi={self.dpi.get()}",
|
|
f"--export-background={self.background.get()}",
|
|
]
|
|
|
|
bg_color_str = self.background.get().lower()
|
|
if bg_color_str != 'transparent' and bg_color_str:
|
|
cmd.append("--export-background-opacity=1")
|
|
else:
|
|
cmd.append("--export-background-opacity=0")
|
|
|
|
w = self.export_width.get()
|
|
h = self.export_height.get()
|
|
if w: cmd.append(f"--export-width={w}")
|
|
if h: cmd.append(f"--export-height={h}")
|
|
|
|
if fmt == "jpg" and 1 <= self.jpeg_quality.get() <= 100:
|
|
# This flag requires Inkscape 1.0+
|
|
cmd.append(f"--export-jpeg-quality={self.jpeg_quality.get()}")
|
|
|
|
cmd.append(str(svg_path))
|
|
|
|
self.log(f"Processing: {svg_path.name} -> {output_filename.name}", "debug")
|
|
|
|
try:
|
|
creationflags = 0
|
|
if sys.platform == "win32":
|
|
creationflags = subprocess.CREATE_NO_WINDOW
|
|
|
|
result = subprocess.run(cmd, capture_output=True, text=True, check=False, encoding='utf-8', creationflags=creationflags, timeout=300) # 5 min timeout per file
|
|
|
|
if self.cancel_requested: return None
|
|
|
|
if result.returncode == 0 and output_filename.exists() and output_filename.stat().st_size > 0:
|
|
return str(output_filename)
|
|
else:
|
|
error_msg = f"Failed: {svg_path.name}. Inkscape RC={result.returncode}."
|
|
err_details = result.stderr.strip() if result.stderr else result.stdout.strip()
|
|
if err_details:
|
|
# Check for common known issues
|
|
if "Unknown option --export-jpeg-quality" in err_details:
|
|
error_msg += " (Error: JPEG quality option failed. Is Inkscape version 1.0+ installed and in PATH?)"
|
|
elif fmt == 'tiff' and 'WARNING' in err_details and 'Expecting currStmt' in err_details:
|
|
error_msg += " (Warning: TIFF export generated warnings, potentially related to SVG content or Inkscape version.)"
|
|
else:
|
|
error_msg += f" Details: {err_details[:250]}..."
|
|
|
|
if not output_filename.exists():
|
|
error_msg += " (Output file not created)"
|
|
elif output_filename.stat().st_size == 0:
|
|
error_msg += " (Output file is empty)"
|
|
try: output_filename.unlink()
|
|
except OSError: pass
|
|
|
|
self.log(error_msg, "error")
|
|
return None
|
|
|
|
except FileNotFoundError:
|
|
self.log(f"Inkscape command not found at '{self.inkscape_path}'. Conversion failed for {svg_path.name}", "error")
|
|
self.cancel_requested = True
|
|
self.conversion_active = False
|
|
return None
|
|
except subprocess.TimeoutExpired:
|
|
self.log(f"Timeout converting {svg_path.name} (took longer than 5 minutes).", "error")
|
|
return None
|
|
except Exception as e:
|
|
self.log(f"Unexpected error converting {svg_path.name}: {type(e).__name__} - {e}", "error")
|
|
return None
|
|
|
|
def retry_failed(self):
|
|
"""Attempts to reconvert files that failed previously."""
|
|
if not self.failed_files:
|
|
self.log("No failed files to retry.", "info")
|
|
return
|
|
|
|
if self.conversion_active:
|
|
self.log("Cannot retry while conversion is active.", "warning")
|
|
return
|
|
|
|
self.log(f"Retrying {len(self.failed_files)} failed file(s)...")
|
|
files_to_retry = list(self.failed_files) # Use absolute paths
|
|
self.selected_files = files_to_retry # Set main list to only failed ones
|
|
|
|
# Rebuild listbox from the filtered selected_files
|
|
self.file_list.delete(0, tk.END)
|
|
for f_path in self.selected_files:
|
|
self.file_list.insert(tk.END, os.path.basename(f_path))
|
|
# No need to reset color here, start_conversion handles it
|
|
|
|
# self.failed_files.clear() # Cleared in start_conversion now
|
|
self.retry_button.config(state=tk.DISABLED)
|
|
self.update_status(f"{len(files_to_retry)} Files (Retry)")
|
|
self.start_conversion()
|
|
|
|
|
|
def load_settings(self):
|
|
"""Loads settings from the JSON file."""
|
|
try:
|
|
settings_path = Path(SETTINGS_FILE)
|
|
if settings_path.exists():
|
|
with open(settings_path, 'r') as f:
|
|
settings = json.load(f)
|
|
self.dpi.set(settings.get('dpi', 150))
|
|
self.background.set(settings.get('background', '#ffffff'))
|
|
self.output_format.set(settings.get('output_format', 'png'))
|
|
self.output_dir.set(settings.get('output_dir', '<Same as source>'))
|
|
self.export_width.set(settings.get('export_width', ''))
|
|
self.export_height.set(settings.get('export_height', ''))
|
|
self.jpeg_quality.set(settings.get('jpeg_quality', 90))
|
|
self.auto_open_folder.set(settings.get('auto_open_folder', False))
|
|
self.concurrent_tasks.set(settings.get('concurrent_tasks', MAX_CONCURRENT_TASKS))
|
|
self.live_preview_enabled.set(settings.get('live_preview_enabled', True)) # Load preview preference
|
|
self.log("Settings loaded.", "debug")
|
|
else:
|
|
self.log("Settings file not found, using defaults.", "debug")
|
|
except json.JSONDecodeError as e:
|
|
self.log(f"Error decoding settings file ({SETTINGS_FILE}): {e}. Using defaults.", "error")
|
|
except Exception as e:
|
|
self.log(f"Error loading settings: {type(e).__name__} - {e}", "error")
|
|
|
|
|
|
def save_settings(self):
|
|
"""Saves current settings to the JSON file."""
|
|
settings = {
|
|
'dpi': self.dpi.get(),
|
|
'background': self.background.get(),
|
|
'output_format': self.output_format.get(),
|
|
'output_dir': self.output_dir.get(),
|
|
'export_width': self.export_width.get(),
|
|
'export_height': self.export_height.get(),
|
|
'jpeg_quality': self.jpeg_quality.get(),
|
|
'auto_open_folder': self.auto_open_folder.get(),
|
|
'concurrent_tasks': self.concurrent_tasks.get(),
|
|
'live_preview_enabled': self.live_preview_enabled.get(), # Save preview preference
|
|
}
|
|
try:
|
|
settings_path = Path(SETTINGS_FILE)
|
|
with open(settings_path, 'w') as f:
|
|
json.dump(settings, f, indent=4)
|
|
except Exception as e:
|
|
self.log(f"Error saving settings: {e}", "error")
|
|
|
|
def on_closing(self):
|
|
"""Handles window close event."""
|
|
if self.conversion_active and not self.cancel_requested:
|
|
if messagebox.askyesno("Exit Confirmation", "Conversion in progress. Are you sure you want to exit?\nRunning conversions will be cancelled."):
|
|
self.log("Exiting: Cancellation requested by closing window.", "warning")
|
|
self.cancel_requested = True
|
|
self.conversion_active = False
|
|
# Allow threads a moment to potentially see the flag before closing
|
|
self.after(50, self._perform_close)
|
|
else:
|
|
return
|
|
else:
|
|
self._perform_close()
|
|
|
|
def _perform_close(self):
|
|
"""Saves settings and destroys the window."""
|
|
if self.preview_timer:
|
|
self.preview_timer.cancel()
|
|
self.save_settings()
|
|
self.destroy()
|
|
|
|
|
|
# --- Main Execution ---
|
|
if __name__ == "__main__":
|
|
# Ensure Tkinter scales correctly on high-DPI displays (Windows)
|
|
try:
|
|
from ctypes import windll
|
|
windll.shcore.SetProcessDpiAwareness(1)
|
|
except Exception:
|
|
pass # Ignore if not on Windows or ctypes fails
|
|
|
|
app = SVGtoPNGConverter()
|
|
app.mainloop() |