import tkinter as tk from tkinter import ttk, filedialog, messagebox from pathlib import Path import json from PIL import Image from PIL.PngImagePlugin import PngInfo # Application metadata APP_VERSION = "1.0.0" APP_NAME = "PNG Metadata Editor" class PNGMetadataEditor: def __init__(self, root): self.root = root self.root.title(f"{APP_NAME} - No file loaded") self.root.geometry("900x700") self.current_file = None self.metadata_dict = {} self.has_unsaved_changes = False self.status_timer = None 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 setup_ui(self): # 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="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) # New About button 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) # PanedWindow for splitting tree and detail view paned = ttk.PanedWindow(self.root, orient=tk.VERTICAL) paned.pack(fill=tk.BOTH, expand=True, padx=10, pady=5) # Top pane - Treeview tree_frame = ttk.Frame(paned) paned.add(tree_frame, weight=2) # 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=200) self.tree.column("Value", width=600) self.tree.pack(fill=tk.BOTH, expand=True) tree_scroll.config(command=self.tree.yview) # Bind selection to update detail view self.tree.bind("<>", self.on_selection_change) # Bind double-click to edit self.tree.bind("", self.edit_entry) # Bottom pane - Detail view detail_frame = ttk.Frame(paned) paned.add(detail_frame, weight=1) 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(self.root, 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 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" \ "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)""" # Cancel any existing timer if self.status_timer: self.root.after_cancel(self.status_timer) # Set the message self.status_label.config(text=message, foreground=color) # Schedule clearing the message 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: # Try to parse as JSON parsed = json.loads(value) # If successful, return pretty-printed JSON return json.dumps(parsed, indent=2, ensure_ascii=False) except (json.JSONDecodeError, TypeError): # Not JSON, return original value 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) # Update detail text 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: try: self.current_file = 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) 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 refresh_tree(self): # Clear existing items for item in self.tree.get_children(): self.tree.delete(item) # Clear detail view self.detail_text.config(state=tk.NORMAL) self.detail_text.delete(1.0, tk.END) self.detail_text.config(state=tk.DISABLED) # Add metadata entries from dictionary 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) # Make dialog resizable dialog.rowconfigure(1, weight=1) dialog.columnconfigure(1, weight=1) # Key entry 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) # Value text with label ttk.Label(dialog, text="Value:").grid(row=1, column=0, padx=10, pady=10, sticky=tk.NW) # Frame for text widget and scrollbar 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 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 self.metadata_dict[key] = 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] # Format value as JSON if possible 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) # Make dialog resizable dialog.rowconfigure(1, weight=1) dialog.columnconfigure(1, weight=1) # Key display 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) # Value text with label ttk.Label(dialog, text="Value:").grid(row=1, column=0, padx=10, pady=10, sticky=tk.NW) # Frame for text widget and scrollbar 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 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() self.metadata_dict[key] = new_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: # Open the image img = Image.open(self.current_file) # Create new PngInfo object with only our metadata metadata = PngInfo() for key, value in self.metadata_dict.items(): metadata.add_text(key, value) # Save the image with new metadata (this replaces all text chunks) 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()