png-meta-editor/png-meta-editor.py
2026-01-05 18:56:56 +01:00

390 lines
No EOL
15 KiB
Python

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("<<TreeviewSelect>>", self.on_selection_change)
# Bind double-click to edit
self.tree.bind("<Double-1>", 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()