diff --git a/png-meta-editor.py b/png-meta-editor.py new file mode 100644 index 0000000..984ca5a --- /dev/null +++ b/png-meta-editor.py @@ -0,0 +1,390 @@ + +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 + +class PNGMetadataEditor: + def __init__(self, root): + self.root = root + self.root.title("PNG Metadata Editor") + 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) + + 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 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 = "PNG Metadata Editor" + 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() \ No newline at end of file diff --git a/screenshot.png b/screenshot.png new file mode 100644 index 0000000..ac57d77 Binary files /dev/null and b/screenshot.png differ