png-meta-editor/png-meta-editor.py

720 lines
No EOL
29 KiB
Python

import tkinter as tk
from tkinter import ttk, filedialog, messagebox
from pathlib import Path
import json
from PIL import Image, ImageTk
from PIL.PngImagePlugin import PngInfo
import platform
import subprocess
# Application metadata
APP_VERSION = "2.3.0"
APP_NAME = "PNG Metadata Editor"
class PNGMetadataEditor:
def __init__(self, root):
self.root = root
self.root.title(f"{APP_NAME} v{APP_VERSION}")
self.root.geometry("1000x700") # Smaller window size
self.current_file = None
self.current_directory = None
self.metadata_dict = {}
self.has_unsaved_changes = False
self.status_timer = None
self.thumbnail_images = {} # Keep references to PhotoImage objects
self.thumbnail_frames = {} # Track thumbnail frames for highlighting
self.setup_ui()
# Bring window to foreground on startup
self.root.lift()
self.root.attributes('-topmost', True)
self.root.after_idle(self.root.attributes, '-topmost', False)
self.root.focus_force()
def detect_dark_mode(self):
"""Detect if the OS is in dark mode"""
system = platform.system()
if system == "Darwin": # macOS
try:
result = subprocess.run(
['defaults', 'read', '-g', 'AppleInterfaceStyle'],
capture_output=True,
text=True,
timeout=1
)
return result.returncode == 0 and 'Dark' in result.stdout
except:
return False
elif system == "Windows":
try:
import winreg
registry = winreg.ConnectRegistry(None, winreg.HKEY_CURRENT_USER)
key = winreg.OpenKey(registry, r'SOFTWARE\Microsoft\Windows\CurrentVersion\Themes\Personalize')
value, _ = winreg.QueryValueEx(key, 'AppsUseLightTheme')
return value == 0
except:
return False
else: # Linux
try:
result = subprocess.run(
['gsettings', 'get', 'org.gnome.desktop.interface', 'gtk-theme'],
capture_output=True,
text=True,
timeout=1
)
return 'dark' in result.stdout.lower()
except:
return False
def get_theme_colors(self):
"""Get background colors based on dark mode detection"""
is_dark = self.detect_dark_mode()
if is_dark:
return {
'canvas_bg': '#2b2b2b',
'frame_bg': '#2b2b2b',
'text_fg': '#ffffff',
'loading_fg': '#6bb6ff'
}
else:
return {
'canvas_bg': '#f0f0f0',
'frame_bg': '#f0f0f0',
'text_fg': '#000000',
'loading_fg': '#4a90d9'
}
def setup_ui(self):
# Initialize theme colors
self.theme_colors = self.get_theme_colors()
# Top frame for file operations
top_frame = ttk.Frame(self.root, padding="10")
top_frame.pack(fill=tk.X)
ttk.Button(top_frame, text="🌌 Open PNG File", command=self.open_file).pack(side=tk.LEFT, padx=5)
ttk.Button(top_frame, text="🗂️ Browse Directory", command=self.browse_directory).pack(side=tk.LEFT, padx=5)
ttk.Button(top_frame, text="💾 Save Changes", command=self.save_changes).pack(side=tk.LEFT, padx=5)
ttk.Button(top_frame, text="🍺 About", command=self.show_about).pack(side=tk.RIGHT, padx=5)
self.file_label = ttk.Label(top_frame, text="No file loaded", foreground="gray")
self.file_label.pack(side=tk.LEFT, padx=20)
# Unsaved changes indicator
self.changes_label = ttk.Label(top_frame, text="", foreground="orange")
self.changes_label.pack(side=tk.LEFT, padx=10)
# Main container with horizontal split
self.main_paned = ttk.PanedWindow(self.root, orient=tk.HORIZONTAL)
self.main_paned.pack(fill=tk.BOTH, expand=True, padx=10, pady=5)
# Left side - Thumbnail browser (smaller default width)
thumbnail_frame = ttk.Frame(self.main_paned, relief=tk.SUNKEN, borderwidth=2)
self.main_paned.add(thumbnail_frame, weight=0)
ttk.Label(thumbnail_frame, text="Image Browser", font=("TkDefaultFont", 10, "bold")).pack(anchor=tk.W, padx=5, pady=5)
# Canvas with scrollbar for thumbnails
thumb_canvas_frame = ttk.Frame(thumbnail_frame)
thumb_canvas_frame.pack(fill=tk.BOTH, expand=True)
thumb_scroll = ttk.Scrollbar(thumb_canvas_frame, orient=tk.VERTICAL)
thumb_scroll.pack(side=tk.RIGHT, fill=tk.Y)
self.thumb_canvas = tk.Canvas(thumb_canvas_frame, yscrollcommand=thumb_scroll.set, bg=self.theme_colors["canvas_bg"], width=190)
self.thumb_canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
thumb_scroll.config(command=self.thumb_canvas.yview)
# Frame inside canvas to hold thumbnails
self.thumb_container = ttk.Frame(self.thumb_canvas)
self.thumb_canvas_window = self.thumb_canvas.create_window((0, 0), window=self.thumb_container, anchor=tk.NW)
# Bind canvas resize
self.thumb_container.bind("<Configure>", self.on_thumb_frame_configure)
self.thumb_canvas.bind("<Configure>", self.on_thumb_canvas_configure)
# Bind trackpad/mousewheel scrolling
self.bind_mousewheel(self.thumb_canvas)
# Right side - Metadata editor (existing UI)
editor_frame = ttk.Frame(self.main_paned)
self.main_paned.add(editor_frame, weight=1)
# Set the sash position after a short delay to ensure proper sizing
self.root.after(100, lambda: self.main_paned.sashpos(0, 200))
# PanedWindow for splitting tree and detail view
paned = ttk.PanedWindow(editor_frame, orient=tk.VERTICAL)
paned.pack(fill=tk.BOTH, expand=True)
# Top pane - Treeview (1/3 of height)
tree_frame = ttk.Frame(paned)
paned.add(tree_frame, weight=1) # Changed from weight=2 to weight=1
# Treeview with scrollbar
tree_scroll = ttk.Scrollbar(tree_frame)
tree_scroll.pack(side=tk.RIGHT, fill=tk.Y)
self.tree = ttk.Treeview(tree_frame, columns=("Key", "Value"), show="headings",
yscrollcommand=tree_scroll.set)
self.tree.heading("Key", text="Key")
self.tree.heading("Value", text="Value Preview")
self.tree.column("Key", width=150)
self.tree.column("Value", width=400)
self.tree.pack(fill=tk.BOTH, expand=True)
tree_scroll.config(command=self.tree.yview)
# Bind selection to update detail view
self.tree.bind("<<TreeviewSelect>>", self.on_selection_change)
# Bind double-click to edit
self.tree.bind("<Double-1>", self.edit_entry)
# Bottom pane - Detail view (2/3 of height)
detail_frame = ttk.Frame(paned)
paned.add(detail_frame, weight=2) # Changed from weight=1 to weight=2
ttk.Label(detail_frame, text="Value Detail:", font=("TkDefaultFont", 10, "bold")).pack(anchor=tk.W, padx=5, pady=5)
# Text widget with scrollbar for value display
detail_scroll = ttk.Scrollbar(detail_frame)
detail_scroll.pack(side=tk.RIGHT, fill=tk.Y)
self.detail_text = tk.Text(detail_frame, wrap=tk.WORD, yscrollcommand=detail_scroll.set,
font=("Menlo", 11), height=8)
self.detail_text.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
detail_scroll.config(command=self.detail_text.yview)
# Button frame for operations
button_frame = ttk.Frame(editor_frame, padding="10")
button_frame.pack(fill=tk.X)
ttk.Button(button_frame, text="✍️ Add Field", command=self.add_field).pack(side=tk.LEFT, padx=5)
ttk.Button(button_frame, text="✏️ Edit Field", command=self.edit_entry).pack(side=tk.LEFT, padx=5)
ttk.Button(button_frame, text="❌ Delete Field", command=self.delete_field).pack(side=tk.LEFT, padx=5)
ttk.Button(button_frame, text="📋 Copy Value", command=self.copy_value).pack(side=tk.LEFT, padx=5)
# Status bar at the bottom
status_frame = ttk.Frame(self.root, relief=tk.SUNKEN)
status_frame.pack(fill=tk.X, side=tk.BOTTOM)
self.status_label = ttk.Label(status_frame, text="Ready", padding=(10, 5))
self.status_label.pack(side=tk.LEFT, fill=tk.X, expand=True)
def bind_mousewheel(self, widget):
"""Bind mousewheel/trackpad scrolling to canvas"""
system = platform.system()
if system == "Darwin": # macOS
widget.bind("<MouseWheel>", self.on_mousewheel)
widget.bind("<Button-4>", self.on_mousewheel)
widget.bind("<Button-5>", self.on_mousewheel)
elif system == "Windows":
widget.bind("<MouseWheel>", self.on_mousewheel)
elif system == "Linux":
widget.bind("<Button-4>", self.on_mousewheel)
widget.bind("<Button-5>", self.on_mousewheel)
widget.bind("<Enter>", lambda e: widget.bind_all("<MouseWheel>", self.on_mousewheel))
widget.bind("<Leave>", lambda e: widget.unbind_all("<MouseWheel>"))
def on_mousewheel(self, event):
"""Handle mousewheel/trackpad scrolling"""
system = platform.system()
if system == "Darwin": # macOS
self.thumb_canvas.yview_scroll(int(-1 * (event.delta)), "units")
elif system == "Windows":
self.thumb_canvas.yview_scroll(int(-1 * (event.delta / 120)), "units")
elif system == "Linux":
if event.num == 4:
self.thumb_canvas.yview_scroll(-1, "units")
elif event.num == 5:
self.thumb_canvas.yview_scroll(1, "units")
def on_thumb_frame_configure(self, event):
"""Update scrollregion when thumbnail container size changes"""
self.thumb_canvas.configure(scrollregion=self.thumb_canvas.bbox("all"))
def on_thumb_canvas_configure(self, event):
"""Update canvas window width to match canvas width"""
self.thumb_canvas.itemconfig(self.thumb_canvas_window, width=event.width)
def scroll_to_thumbnail(self, filepath):
"""Scroll the canvas to make the selected thumbnail visible"""
filepath_str = str(filepath)
if filepath_str not in self.thumbnail_frames:
return
frame = self.thumbnail_frames[filepath_str]
# Update the canvas to ensure all geometries are calculated
self.thumb_canvas.update_idletasks()
# Get the frame's position
frame_y = frame.winfo_y()
frame_height = frame.winfo_height()
# Get canvas viewport
canvas_height = self.thumb_canvas.winfo_height()
# Calculate scroll position to center the thumbnail
total_height = self.thumb_container.winfo_height()
if total_height > 0:
# Center the thumbnail in the viewport
target_y = frame_y - (canvas_height / 2) + (frame_height / 2)
scroll_fraction = max(0, min(1, target_y / total_height))
self.thumb_canvas.yview_moveto(scroll_fraction)
def browse_directory(self):
"""Browse a directory and show PNG thumbnails"""
directory = filedialog.askdirectory(title="Select directory with PNG files")
if directory:
self.current_directory = directory
self.load_directory_thumbnails(directory)
def load_directory_thumbnails(self, directory):
"""Load and display thumbnails for all PNG files in directory"""
# Clear existing thumbnails
for widget in self.thumb_container.winfo_children():
widget.destroy()
self.thumbnail_images.clear()
self.thumbnail_frames.clear()
# Create and show loading indicator with more visibility
loading_frame = tk.Frame(self.thumb_container, bg=self.theme_colors["frame_bg"])
loading_frame.pack(fill=tk.BOTH, expand=True, pady=50)
loading_label = tk.Label(loading_frame, text="Loading thumbnails...",
font=("TkDefaultFont", 11, "bold"), foreground="#4a90d9", bg=self.theme_colors["frame_bg"])
loading_label.pack()
# Force immediate display
self.thumb_container.update()
self.root.update()
# Find all PNG files
png_files = sorted(Path(directory).glob("*.png"))
if not png_files:
loading_frame.destroy()
no_files_frame = tk.Frame(self.thumb_container, bg=self.theme_colors["frame_bg"])
no_files_frame.pack(fill=tk.BOTH, expand=True, pady=50)
tk.Label(no_files_frame, text="No PNG files found",
font=("TkDefaultFont", 10), foreground="gray", bg=self.theme_colors["frame_bg"]).pack()
self.set_status(f"No PNG files found in {Path(directory).name}", color="orange")
return
# Update loading message with count
loading_label.config(text=f"Loading {len(png_files)} thumbnails...")
self.root.update()
# Create thumbnails
thumbnail_size = (150, 150)
for idx, png_file in enumerate(png_files):
try:
# Update progress every 3 files for better visual feedback
if idx % 3 == 0:
loading_label.config(text=f"Loading... {idx + 1}/{len(png_files)}")
self.root.update()
# Load and create thumbnail
img = Image.open(png_file)
img.thumbnail(thumbnail_size, Image.Resampling.LANCZOS)
# Convert to PhotoImage
photo = ImageTk.PhotoImage(img)
self.thumbnail_images[str(png_file)] = photo
# Create frame for each thumbnail with better styling
thumb_frame = tk.Frame(self.thumb_container, relief=tk.RIDGE, borderwidth=2,
bg="white", padx=5, pady=5)
thumb_frame.pack(fill=tk.X, padx=5, pady=5)
# Store frame reference
self.thumbnail_frames[str(png_file)] = thumb_frame
# Button with image
btn = tk.Button(thumb_frame, image=photo, command=lambda f=png_file: self.load_file_from_path(f),
cursor="hand2", bg="white", relief=tk.FLAT, bd=0)
btn.pack(side=tk.TOP, padx=2, pady=2)
# Hover effects
def on_enter(e, frame=thumb_frame, path=str(png_file)):
if self.current_file != path:
frame.config(bg="#e8f4f8", relief=tk.RAISED)
def on_leave(e, frame=thumb_frame, path=str(png_file)):
if self.current_file != path:
frame.config(bg="white", relief=tk.RIDGE)
thumb_frame.bind("<Enter>", on_enter)
thumb_frame.bind("<Leave>", on_leave)
btn.bind("<Enter>", on_enter)
btn.bind("<Leave>", on_leave)
# Filename label
name_label = tk.Label(thumb_frame, text=png_file.name, wraplength=140,
justify=tk.CENTER, bg="white", font=("TkDefaultFont", 9))
name_label.pack(side=tk.TOP, padx=2, pady=2)
name_label.bind("<Enter>", on_enter)
name_label.bind("<Leave>", on_leave)
except Exception as e:
print(f"Error loading thumbnail for {png_file}: {e}")
# Hide loading indicator
loading_frame.destroy()
# Force scroll region update
def update_scroll():
self.thumb_container.update_idletasks()
self.thumb_canvas.update_idletasks()
bbox = self.thumb_canvas.bbox("all")
if bbox:
self.thumb_canvas.configure(scrollregion=bbox)
update_scroll()
self.root.after(100, update_scroll)
self.root.after(300, update_scroll)
self.set_status(f"Loaded {len(png_files)} PNG file(s) from {Path(directory).name}", color="green")
def load_file_from_path(self, filepath, auto_scroll=True):
"""Load a specific file from thumbnail click"""
try:
self.current_file = str(filepath)
# Update thumbnail highlighting with improved visual feedback
for path, frame in self.thumbnail_frames.items():
if path == str(filepath):
# Selected style: blue gradient-like background with sunken relief
frame.config(relief=tk.SUNKEN, bg="#4a90d9", borderwidth=3)
# Update all children backgrounds
for child in frame.winfo_children():
if isinstance(child, tk.Label):
child.config(bg="#4a90d9", fg="white", font=("TkDefaultFont", 9, "bold"))
elif isinstance(child, tk.Button):
child.config(bg="#4a90d9")
else:
# Unselected style: white background with ridge relief
frame.config(relief=tk.RIDGE, bg="white", borderwidth=2)
for child in frame.winfo_children():
if isinstance(child, tk.Label):
child.config(bg="white", fg="black", font=("TkDefaultFont", 9))
elif isinstance(child, tk.Button):
child.config(bg="white")
# Scroll to the selected thumbnail if requested
if auto_scroll:
self.root.after(100, lambda: self.scroll_to_thumbnail(filepath))
# Read metadata using PIL
img = Image.open(filepath)
self.metadata_dict = {}
# Extract text chunks (tEXt, zTXt, iTXt)
if hasattr(img, 'text'):
self.metadata_dict = dict(img.text)
img.close() # Release file handle
self.file_label.config(text=Path(filepath).name, foreground="")
self.mark_as_saved()
self.refresh_tree()
field_count = len(self.metadata_dict)
self.set_status(f"Loaded {field_count} metadata field(s) from {Path(filepath).name}", color="green")
except Exception as e:
messagebox.showerror("Error", f"Failed to open file:\n{str(e)}")
self.set_status("Failed to open file", color="red")
def show_about(self):
"""Show application about dialog"""
about_text = f"{APP_NAME} v{APP_VERSION}\n\n" \
"A graphical tool for viewing and editing metadata in PNG files.\n\n" \
"Features:\n" \
"✅ Browse directories with thumbnail previews\n" \
"✅ Trackpad/mousewheel scrolling support\n" \
"✅ Auto-scroll to selected images\n" \
"✅ View and edit PNG metadata\n" \
"✅ Copy metadata between files\n\n" \
"Author: Robert Tusa\n" \
"License: MIT"
messagebox.showinfo(
f"About {APP_NAME}",
about_text,
parent=self.root
)
def set_status(self, message, duration=3000, color=""):
"""Set status bar message that auto-clears after duration (ms)"""
if self.status_timer:
self.root.after_cancel(self.status_timer)
self.status_label.config(text=message, foreground=color)
if duration > 0:
self.status_timer = self.root.after(duration, lambda: self.status_label.config(text="Ready", foreground=""))
def mark_as_modified(self):
"""Mark the document as having unsaved changes"""
if not self.has_unsaved_changes:
self.has_unsaved_changes = True
self.update_title()
self.changes_label.config(text="Unsaved changes")
def mark_as_saved(self):
"""Mark the document as saved"""
self.has_unsaved_changes = False
self.update_title()
self.changes_label.config(text="")
def update_title(self):
"""Update window title with unsaved indicator"""
base_title = f"{APP_NAME} v{APP_VERSION}"
if self.current_file:
filename = Path(self.current_file).name
if self.has_unsaved_changes:
self.root.title(f"{base_title} - {filename} *")
else:
self.root.title(f"{base_title} - {filename}")
else:
self.root.title(base_title)
def format_value_for_display(self, value):
"""Try to format value as JSON if possible, otherwise return as-is"""
try:
parsed = json.loads(value)
return json.dumps(parsed, indent=2, ensure_ascii=False)
except (json.JSONDecodeError, TypeError):
return value
def flatten_json_if_valid(self, value):
"""If value is valid JSON, return it flattened (no indent), otherwise return as-is"""
try:
parsed = json.loads(value)
return json.dumps(parsed, ensure_ascii=False, separators=(',', ':'))
except (json.JSONDecodeError, TypeError):
return value
def truncate_value(self, value, max_length=80):
"""Truncate long values for tree display"""
value_str = str(value)
if len(value_str) > max_length:
return value_str[:max_length] + "..."
return value_str
def on_selection_change(self, event):
"""Update detail view when selection changes"""
selected = self.tree.selection()
if selected:
item = selected[0]
key, _ = self.tree.item(item, "values")
if key in self.metadata_dict:
value = self.metadata_dict[key]
formatted_value = self.format_value_for_display(value)
self.detail_text.config(state=tk.NORMAL)
self.detail_text.delete(1.0, tk.END)
self.detail_text.insert(1.0, formatted_value)
self.detail_text.config(state=tk.DISABLED)
def open_file(self):
filepath = filedialog.askopenfilename(
title="Select PNG file",
filetypes=[("PNG files", "*.png"), ("All files", "*.*")]
)
if filepath:
parent_dir = str(Path(filepath).parent)
if parent_dir != self.current_directory:
self.current_directory = parent_dir
self.load_directory_thumbnails(parent_dir)
self.load_file_from_path(Path(filepath), auto_scroll=True)
def refresh_tree(self):
for item in self.tree.get_children():
self.tree.delete(item)
self.detail_text.config(state=tk.NORMAL)
self.detail_text.delete(1.0, tk.END)
self.detail_text.config(state=tk.DISABLED)
for key, value in self.metadata_dict.items():
truncated = self.truncate_value(value)
self.tree.insert("", tk.END, values=(key, truncated))
def add_field(self):
if self.current_file is None:
messagebox.showwarning("No File", "Please open a PNG file first")
return
dialog = tk.Toplevel(self.root)
dialog.title("Add Field")
dialog.geometry("600x400")
dialog.minsize(400, 300)
dialog.rowconfigure(1, weight=1)
dialog.columnconfigure(1, weight=1)
ttk.Label(dialog, text="Key:").grid(row=0, column=0, padx=10, pady=10, sticky=tk.W)
key_entry = ttk.Entry(dialog, width=50)
key_entry.grid(row=0, column=1, padx=10, pady=10, sticky=tk.EW)
ttk.Label(dialog, text="Value:").grid(row=1, column=0, padx=10, pady=10, sticky=tk.NW)
text_frame = ttk.Frame(dialog)
text_frame.grid(row=1, column=1, padx=10, pady=10, sticky=tk.NSEW)
value_scroll = ttk.Scrollbar(text_frame)
value_scroll.pack(side=tk.RIGHT, fill=tk.Y)
value_text = tk.Text(text_frame, wrap=tk.WORD, yscrollcommand=value_scroll.set,
font=("Menlo", 11))
value_text.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
value_scroll.config(command=value_text.yview)
button_frame = ttk.Frame(dialog)
button_frame.grid(row=2, column=0, columnspan=2, pady=10, sticky=tk.E)
def save_new_field():
key = key_entry.get().strip()
value = value_text.get(1.0, tk.END).strip()
if not key:
messagebox.showwarning("Invalid Input", "Key cannot be empty")
return
# Flatten JSON if valid to keep original format
flattened_value = self.flatten_json_if_valid(value)
self.metadata_dict[key] = flattened_value
self.mark_as_modified()
self.refresh_tree()
self.set_status(f"Added field '{key}'", color="green")
dialog.destroy()
ttk.Button(button_frame, text="Cancel", command=dialog.destroy).pack(side=tk.RIGHT, padx=5)
ttk.Button(button_frame, text="Add", command=save_new_field).pack(side=tk.RIGHT, padx=5)
key_entry.focus()
def edit_entry(self, event=None):
selected = self.tree.selection()
if not selected:
messagebox.showwarning("No Selection", "Please select a field to edit")
return
item = selected[0]
key, _ = self.tree.item(item, "values")
current_value = self.metadata_dict[key]
formatted_value = self.format_value_for_display(current_value)
dialog = tk.Toplevel(self.root)
dialog.title("Edit Field")
dialog.geometry("600x400")
dialog.minsize(400, 300)
dialog.rowconfigure(1, weight=1)
dialog.columnconfigure(1, weight=1)
ttk.Label(dialog, text="Key:").grid(row=0, column=0, padx=10, pady=10, sticky=tk.W)
key_label = ttk.Label(dialog, text=key, font=("TkDefaultFont", 10, "bold"))
key_label.grid(row=0, column=1, padx=10, pady=10, sticky=tk.W)
ttk.Label(dialog, text="Value:").grid(row=1, column=0, padx=10, pady=10, sticky=tk.NW)
text_frame = ttk.Frame(dialog)
text_frame.grid(row=1, column=1, padx=10, pady=10, sticky=tk.NSEW)
value_scroll = ttk.Scrollbar(text_frame)
value_scroll.pack(side=tk.RIGHT, fill=tk.Y)
value_text = tk.Text(text_frame, wrap=tk.WORD, yscrollcommand=value_scroll.set,
font=("Menlo", 11))
value_text.insert(1.0, formatted_value)
value_text.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
value_scroll.config(command=value_text.yview)
button_frame = ttk.Frame(dialog)
button_frame.grid(row=2, column=0, columnspan=2, pady=10, sticky=tk.E)
def save_edit():
new_value = value_text.get(1.0, tk.END).strip()
# Flatten JSON if valid to keep original format
flattened_value = self.flatten_json_if_valid(new_value)
self.metadata_dict[key] = flattened_value
self.mark_as_modified()
self.refresh_tree()
self.set_status(f"Updated field '{key}'", color="green")
dialog.destroy()
ttk.Button(button_frame, text="Cancel", command=dialog.destroy).pack(side=tk.RIGHT, padx=5)
ttk.Button(button_frame, text="Save", command=save_edit).pack(side=tk.RIGHT, padx=5)
value_text.focus()
def delete_field(self):
selected = self.tree.selection()
if not selected:
messagebox.showwarning("No Selection", "Please select a field to delete")
return
item = selected[0]
key, _ = self.tree.item(item, "values")
if messagebox.askyesno("Confirm Delete", f"Delete field '{key}'?"):
if key in self.metadata_dict:
del self.metadata_dict[key]
self.mark_as_modified()
self.refresh_tree()
self.set_status(f"Deleted field '{key}'", color="green")
def copy_value(self):
selected = self.tree.selection()
if not selected:
messagebox.showwarning("No Selection", "Please select a field to copy")
return
item = selected[0]
key, _ = self.tree.item(item, "values")
value = self.metadata_dict[key]
self.root.clipboard_clear()
self.root.clipboard_append(value)
self.set_status(f"Copied value of '{key}' to clipboard", color="green")
def save_changes(self):
if self.current_file is None:
messagebox.showwarning("No File", "Please open a PNG file first")
return
try:
img = Image.open(self.current_file)
metadata = PngInfo()
for key, value in self.metadata_dict.items():
metadata.add_text(key, value)
img.save(self.current_file, pnginfo=metadata)
self.mark_as_saved()
self.set_status("Metadata saved successfully", color="green")
except Exception as e:
messagebox.showerror("Error", f"Failed to save metadata:\n{str(e)}")
self.set_status("Failed to save metadata", color="red")
if __name__ == "__main__":
root = tk.Tk()
app = PNGMetadataEditor(root)
root.mainloop()