Compare commits

..

No commits in common. "main" and "version-0" have entirely different histories.

8 changed files with 86 additions and 636 deletions

1
.gitignore vendored
View file

@ -162,4 +162,3 @@ cython_debug/
# option (not recommended) you can uncomment the following to ignore the entire idea folder. # option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/ #.idea/
.DS_Store

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

140
README.md
View file

@ -1,34 +1,16 @@
# PNG Metadata Editor v2.3.0 # PNG Metadata Editor
A graphical tool for viewing and editing metadata in PNG files with an intuitive image browser interface. A graphical tool for viewing and editing metadata in PNG files.
![PNG Metadata Editor Screenshot](screenshot.png) ![PNG Metadata Editor Screenshot](screenshot.png)
## Features ## Features
### Core Features
- View all metadata fields (tEXt, zTXt, iTXt chunks) in PNG files - View all metadata fields (tEXt, zTXt, iTXt chunks) in PNG files
- Add, edit, and delete metadata fields - Add, edit, and delete metadata fields
- Pretty-print JSON-formatted values
- Copy metadata values to clipboard - Copy metadata values to clipboard
- Visual indication of unsaved changes - Visual indication of unsaved changes
- Pretty-print JSON values in editor (auto-flattens on save)
### Image Browser
- Browse directories with thumbnail previews
- Auto-scroll to selected images
- Trackpad and mousewheel scrolling support
- Smooth navigation between multiple files
### User Interface
- Automatic dark mode detection (macOS, Windows, Linux)
- Adaptive canvas background based on system theme
- Unicode support throughout UI
- Resizable panes for optimal workflow
### Technical Improvements
- Smart JSON handling: displays formatted, saves flattened
- Proper file handle management for reliable multi-file loading
- Enhanced scroll region updates for smooth browsing
## Installation ## Installation
@ -42,139 +24,45 @@ A graphical tool for viewing and editing metadata in PNG files with an intuitive
Install required packages using pip: Install required packages using pip:
```bash ```bash
pip install pillow pip install pillow pngmeta
``` ```
## Usage ## Usage
### Basic Workflow
1. Run the application: 1. Run the application:
```bash ```bash
python png-meta-editor.py python png-meta-editor.py
``` ```
2. **Browse Directory**: Click "Browse Directory" to load thumbnails from a folder 2. Open a PNG file using the "Open PNG File" button
3. **Select Image**: Click any thumbnail to load its metadata
4. **View Metadata**: See all fields in the tree view with preview
5. **Edit Fields**:
- Double-click or use "Edit Field" to modify values
- JSON values display formatted but save flattened
6. **Save Changes**: Click "Save Changes" to write metadata back to file
### Keyboard Shortcuts 3. View metadata in the tree view on the left
4. Select a field to see its full value in the detail pane
- **Double-click** on tree item to edit 5. Use the buttons to add, edit, or delete fields
- **Enter/Escape** in dialogs to confirm/cancel
### Dark Mode
The application automatically detects your system theme:
- **macOS**: Reads `AppleInterfaceStyle` setting
- **Windows**: Reads personalization registry settings
- **Linux**: Checks GTK theme preferences
## Building for Distribution ## Building for Distribution
### Using build.py (Recommended) To create a standalone executable:
1. First install the required build tools:
```bash
pip install pyinstaller
```
2. Build the application for your platform:
```bash
python build.py
```
3. The resulting executable will be in the `dist/` directory
### Manual PyInstaller Commands
#### Windows
```bash
pyinstaller --name="PNG Metadata Editor" \
--windowed \
--onefile \
--icon=AppIcon.ico \
--version-file version.txt \
png-meta-editor.py
```
#### macOS
```bash ```bash
pyinstaller --name="PNG Metadata Editor" \ pyinstaller --name="PNG Metadata Editor" \
--windowed \ --windowed \
--onefile \ --onefile \
--icon=AppIcon.icns \ --icon=AppIcon.icns \
--version-file version.txt \
png-meta-editor.py png-meta-editor.py
``` ```
#### Linux The resulting executable will be in the `dist/` directory.
```bash
pyinstaller --name="PNG Metadata Editor" \
--windowed \
--onefile \
png-meta-editor.py
```
## Required Files
For building, you'll need:
- `version.txt` (for version information)
- `AppIcon.ico` (Windows icon)
- `AppIcon.icns` (Mac icon)
- `requirements.txt` (for dependencies)
## Technical Details
### JSON Handling
The editor intelligently handles JSON metadata:
- **Display**: Pretty-printed with indentation for readability
- **Storage**: Automatically flattened to single line to preserve original format
- **Example**:
```json
// What you see in editor:
{
"prompt": "a cat",
"steps": 20
}
// What gets saved:
{"prompt":"a cat","steps":20}
```
### File Management
- Proper file handle release after reading metadata
- Supports loading multiple files sequentially without issues
- Thumbnail caching for smooth browsing experience
## License ## License
This project is licensed under the MIT License - see [LICENSE](LICENSE) for details. This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
## Author ## Author
Robert Tusa Robert Tusa
robert@tusa.at [Your Contact Information]
## Version History ## Version History
### v2.3.0 (2026-01-07) - Major UI Update - 2026-01-05: Initial release
- Added image browser with thumbnail previews
- Implemented dark mode detection for all platforms
- Added trackpad/mousewheel scrolling support
- Smart JSON flattening (preserves original format)
- Fixed file loading issues with proper handle management
- Enhanced scroll region updates
- Unicode support in UI elements
### v1.0.0 (2026-01-05) - Initial Release
- Basic metadata viewing and editing
- Tree view with detail pane
- Add, edit, delete operations
- JSON pretty-printing

View file

@ -1,28 +1,21 @@
import tkinter as tk import tkinter as tk
from tkinter import ttk, filedialog, messagebox from tkinter import ttk, filedialog, messagebox
from pathlib import Path from pathlib import Path
import json import json
from PIL import Image, ImageTk from PIL import Image
from PIL.PngImagePlugin import PngInfo from PIL.PngImagePlugin import PngInfo
import platform
import subprocess
# Application metadata
APP_VERSION = "2.3.0"
APP_NAME = "PNG Metadata Editor"
class PNGMetadataEditor: class PNGMetadataEditor:
def __init__(self, root): def __init__(self, root):
self.root = root self.root = root
self.root.title(f"{APP_NAME} v{APP_VERSION}") self.root.title("PNG Metadata Editor")
self.root.geometry("1000x700") # Smaller window size self.root.geometry("900x700")
self.current_file = None self.current_file = None
self.current_directory = None
self.metadata_dict = {} self.metadata_dict = {}
self.has_unsaved_changes = False self.has_unsaved_changes = False
self.status_timer = None self.status_timer = None
self.thumbnail_images = {} # Keep references to PhotoImage objects
self.thumbnail_frames = {} # Track thumbnail frames for highlighting
self.setup_ui() self.setup_ui()
@ -32,72 +25,13 @@ class PNGMetadataEditor:
self.root.after_idle(self.root.attributes, '-topmost', False) self.root.after_idle(self.root.attributes, '-topmost', False)
self.root.focus_force() 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): def setup_ui(self):
# Initialize theme colors
self.theme_colors = self.get_theme_colors()
# Top frame for file operations # Top frame for file operations
top_frame = ttk.Frame(self.root, padding="10") top_frame = ttk.Frame(self.root, padding="10")
top_frame.pack(fill=tk.X) 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="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="💾 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 = ttk.Label(top_frame, text="No file loaded", foreground="gray")
self.file_label.pack(side=tk.LEFT, padx=20) self.file_label.pack(side=tk.LEFT, padx=20)
@ -106,75 +40,36 @@ class PNGMetadataEditor:
self.changes_label = ttk.Label(top_frame, text="", foreground="orange") self.changes_label = ttk.Label(top_frame, text="", foreground="orange")
self.changes_label.pack(side=tk.LEFT, padx=10) 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 # PanedWindow for splitting tree and detail view
paned = ttk.PanedWindow(editor_frame, orient=tk.VERTICAL) paned = ttk.PanedWindow(self.root, orient=tk.VERTICAL)
paned.pack(fill=tk.BOTH, expand=True) paned.pack(fill=tk.BOTH, expand=True, padx=10, pady=5)
# Top pane - Treeview (1/3 of height) # Top pane - Treeview
tree_frame = ttk.Frame(paned) tree_frame = ttk.Frame(paned)
paned.add(tree_frame, weight=1) # Changed from weight=2 to weight=1 paned.add(tree_frame, weight=2)
# Treeview with scrollbar # Treeview with scrollbar
tree_scroll = ttk.Scrollbar(tree_frame) tree_scroll = ttk.Scrollbar(tree_frame)
tree_scroll.pack(side=tk.RIGHT, fill=tk.Y) tree_scroll.pack(side=tk.RIGHT, fill=tk.Y)
self.tree = ttk.Treeview(tree_frame, columns=("Key", "Value"), show="headings", self.tree = ttk.Treeview(tree_frame, columns=("Key", "Value"), show="headings",
yscrollcommand=tree_scroll.set) yscrollcommand=tree_scroll.set)
self.tree.heading("Key", text="Key") self.tree.heading("Key", text="Key")
self.tree.heading("Value", text="Value Preview") self.tree.heading("Value", text="Value Preview")
self.tree.column("Key", width=150) self.tree.column("Key", width=200)
self.tree.column("Value", width=400) self.tree.column("Value", width=600)
self.tree.pack(fill=tk.BOTH, expand=True) self.tree.pack(fill=tk.BOTH, expand=True)
tree_scroll.config(command=self.tree.yview) tree_scroll.config(command=self.tree.yview)
# Bind selection to update detail view # Bind selection to update detail view
self.tree.bind("<<TreeviewSelect>>", self.on_selection_change) self.tree.bind("<<TreeviewSelect>>", self.on_selection_change)
# Bind double-click to edit # Bind double-click to edit
self.tree.bind("<Double-1>", self.edit_entry) self.tree.bind("<Double-1>", self.edit_entry)
# Bottom pane - Detail view (2/3 of height) # Bottom pane - Detail view
detail_frame = ttk.Frame(paned) detail_frame = ttk.Frame(paned)
paned.add(detail_frame, weight=2) # Changed from weight=1 to weight=2 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) ttk.Label(detail_frame, text="Value Detail:", font=("TkDefaultFont", 10, "bold")).pack(anchor=tk.W, padx=5, pady=5)
@ -183,18 +78,18 @@ class PNGMetadataEditor:
detail_scroll.pack(side=tk.RIGHT, fill=tk.Y) detail_scroll.pack(side=tk.RIGHT, fill=tk.Y)
self.detail_text = tk.Text(detail_frame, wrap=tk.WORD, yscrollcommand=detail_scroll.set, self.detail_text = tk.Text(detail_frame, wrap=tk.WORD, yscrollcommand=detail_scroll.set,
font=("Menlo", 11), height=8) font=("Menlo", 11), height=8)
self.detail_text.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) self.detail_text.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
detail_scroll.config(command=self.detail_text.yview) detail_scroll.config(command=self.detail_text.yview)
# Button frame for operations # Button frame for operations
button_frame = ttk.Frame(editor_frame, padding="10") button_frame = ttk.Frame(self.root, padding="10")
button_frame.pack(fill=tk.X) 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="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="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="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) ttk.Button(button_frame, text="Copy Value", command=self.copy_value).pack(side=tk.LEFT, padx=5)
# Status bar at the bottom # Status bar at the bottom
status_frame = ttk.Frame(self.root, relief=tk.SUNKEN) status_frame = ttk.Frame(self.root, relief=tk.SUNKEN)
@ -203,263 +98,16 @@ class PNGMetadataEditor:
self.status_label = ttk.Label(status_frame, text="Ready", padding=(10, 5)) self.status_label = ttk.Label(status_frame, text="Ready", padding=(10, 5))
self.status_label.pack(side=tk.LEFT, fill=tk.X, expand=True) 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=""): def set_status(self, message, duration=3000, color=""):
"""Set status bar message that auto-clears after duration (ms)""" """Set status bar message that auto-clears after duration (ms)"""
# Cancel any existing timer
if self.status_timer: if self.status_timer:
self.root.after_cancel(self.status_timer) self.root.after_cancel(self.status_timer)
# Set the message
self.status_label.config(text=message, foreground=color) self.status_label.config(text=message, foreground=color)
# Schedule clearing the message
if duration > 0: if duration > 0:
self.status_timer = self.root.after(duration, lambda: self.status_label.config(text="Ready", foreground="")) self.status_timer = self.root.after(duration, lambda: self.status_label.config(text="Ready", foreground=""))
@ -478,7 +126,7 @@ class PNGMetadataEditor:
def update_title(self): def update_title(self):
"""Update window title with unsaved indicator""" """Update window title with unsaved indicator"""
base_title = f"{APP_NAME} v{APP_VERSION}" base_title = "PNG Metadata Editor"
if self.current_file: if self.current_file:
filename = Path(self.current_file).name filename = Path(self.current_file).name
if self.has_unsaved_changes: if self.has_unsaved_changes:
@ -491,20 +139,14 @@ class PNGMetadataEditor:
def format_value_for_display(self, value): def format_value_for_display(self, value):
"""Try to format value as JSON if possible, otherwise return as-is""" """Try to format value as JSON if possible, otherwise return as-is"""
try: try:
# Try to parse as JSON
parsed = json.loads(value) parsed = json.loads(value)
# If successful, return pretty-printed JSON
return json.dumps(parsed, indent=2, ensure_ascii=False) return json.dumps(parsed, indent=2, ensure_ascii=False)
except (json.JSONDecodeError, TypeError): except (json.JSONDecodeError, TypeError):
# Not JSON, return original value
return value 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): def truncate_value(self, value, max_length=80):
"""Truncate long values for tree display""" """Truncate long values for tree display"""
value_str = str(value) value_str = str(value)
@ -518,10 +160,12 @@ class PNGMetadataEditor:
if selected: if selected:
item = selected[0] item = selected[0]
key, _ = self.tree.item(item, "values") key, _ = self.tree.item(item, "values")
if key in self.metadata_dict: if key in self.metadata_dict:
value = self.metadata_dict[key] value = self.metadata_dict[key]
formatted_value = self.format_value_for_display(value) formatted_value = self.format_value_for_display(value)
# Update detail text
self.detail_text.config(state=tk.NORMAL) self.detail_text.config(state=tk.NORMAL)
self.detail_text.delete(1.0, tk.END) self.detail_text.delete(1.0, tk.END)
self.detail_text.insert(1.0, formatted_value) self.detail_text.insert(1.0, formatted_value)
@ -534,21 +178,38 @@ class PNGMetadataEditor:
) )
if filepath: if filepath:
parent_dir = str(Path(filepath).parent) try:
if parent_dir != self.current_directory: self.current_file = filepath
self.current_directory = parent_dir
self.load_directory_thumbnails(parent_dir)
self.load_file_from_path(Path(filepath), auto_scroll=True) # 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): def refresh_tree(self):
# Clear existing items
for item in self.tree.get_children(): for item in self.tree.get_children():
self.tree.delete(item) self.tree.delete(item)
# Clear detail view
self.detail_text.config(state=tk.NORMAL) self.detail_text.config(state=tk.NORMAL)
self.detail_text.delete(1.0, tk.END) self.detail_text.delete(1.0, tk.END)
self.detail_text.config(state=tk.DISABLED) self.detail_text.config(state=tk.DISABLED)
# Add metadata entries from dictionary
for key, value in self.metadata_dict.items(): for key, value in self.metadata_dict.items():
truncated = self.truncate_value(value) truncated = self.truncate_value(value)
self.tree.insert("", tk.END, values=(key, truncated)) self.tree.insert("", tk.END, values=(key, truncated))
@ -563,26 +224,31 @@ class PNGMetadataEditor:
dialog.geometry("600x400") dialog.geometry("600x400")
dialog.minsize(400, 300) dialog.minsize(400, 300)
# Make dialog resizable
dialog.rowconfigure(1, weight=1) dialog.rowconfigure(1, weight=1)
dialog.columnconfigure(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) 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 = ttk.Entry(dialog, width=50)
key_entry.grid(row=0, column=1, padx=10, pady=10, sticky=tk.EW) 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) 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 = ttk.Frame(dialog)
text_frame.grid(row=1, column=1, padx=10, pady=10, sticky=tk.NSEW) text_frame.grid(row=1, column=1, padx=10, pady=10, sticky=tk.NSEW)
value_scroll = ttk.Scrollbar(text_frame) value_scroll = ttk.Scrollbar(text_frame)
value_scroll.pack(side=tk.RIGHT, fill=tk.Y) value_scroll.pack(side=tk.RIGHT, fill=tk.Y)
value_text = tk.Text(text_frame, wrap=tk.WORD, yscrollcommand=value_scroll.set, value_text = tk.Text(text_frame, wrap=tk.WORD, yscrollcommand=value_scroll.set,
font=("Menlo", 11)) font=("Menlo", 11))
value_text.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) value_text.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
value_scroll.config(command=value_text.yview) value_scroll.config(command=value_text.yview)
# Button frame
button_frame = ttk.Frame(dialog) button_frame = ttk.Frame(dialog)
button_frame.grid(row=2, column=0, columnspan=2, pady=10, sticky=tk.E) button_frame.grid(row=2, column=0, columnspan=2, pady=10, sticky=tk.E)
@ -594,9 +260,7 @@ class PNGMetadataEditor:
messagebox.showwarning("Invalid Input", "Key cannot be empty") messagebox.showwarning("Invalid Input", "Key cannot be empty")
return return
# Flatten JSON if valid to keep original format self.metadata_dict[key] = value
flattened_value = self.flatten_json_if_valid(value)
self.metadata_dict[key] = flattened_value
self.mark_as_modified() self.mark_as_modified()
self.refresh_tree() self.refresh_tree()
self.set_status(f"Added field '{key}'", color="green") self.set_status(f"Added field '{key}'", color="green")
@ -617,6 +281,7 @@ class PNGMetadataEditor:
key, _ = self.tree.item(item, "values") key, _ = self.tree.item(item, "values")
current_value = self.metadata_dict[key] current_value = self.metadata_dict[key]
# Format value as JSON if possible
formatted_value = self.format_value_for_display(current_value) formatted_value = self.format_value_for_display(current_value)
dialog = tk.Toplevel(self.root) dialog = tk.Toplevel(self.root)
@ -624,15 +289,19 @@ class PNGMetadataEditor:
dialog.geometry("600x400") dialog.geometry("600x400")
dialog.minsize(400, 300) dialog.minsize(400, 300)
# Make dialog resizable
dialog.rowconfigure(1, weight=1) dialog.rowconfigure(1, weight=1)
dialog.columnconfigure(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) 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 = ttk.Label(dialog, text=key, font=("TkDefaultFont", 10, "bold"))
key_label.grid(row=0, column=1, padx=10, pady=10, sticky=tk.W) 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) 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 = ttk.Frame(dialog)
text_frame.grid(row=1, column=1, padx=10, pady=10, sticky=tk.NSEW) text_frame.grid(row=1, column=1, padx=10, pady=10, sticky=tk.NSEW)
@ -645,14 +314,13 @@ class PNGMetadataEditor:
value_text.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) value_text.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
value_scroll.config(command=value_text.yview) value_scroll.config(command=value_text.yview)
# Button frame
button_frame = ttk.Frame(dialog) button_frame = ttk.Frame(dialog)
button_frame.grid(row=2, column=0, columnspan=2, pady=10, sticky=tk.E) button_frame.grid(row=2, column=0, columnspan=2, pady=10, sticky=tk.E)
def save_edit(): def save_edit():
new_value = value_text.get(1.0, tk.END).strip() new_value = value_text.get(1.0, tk.END).strip()
# Flatten JSON if valid to keep original format self.metadata_dict[key] = new_value
flattened_value = self.flatten_json_if_valid(new_value)
self.metadata_dict[key] = flattened_value
self.mark_as_modified() self.mark_as_modified()
self.refresh_tree() self.refresh_tree()
self.set_status(f"Updated field '{key}'", color="green") self.set_status(f"Updated field '{key}'", color="green")
@ -699,17 +367,19 @@ class PNGMetadataEditor:
return return
try: try:
# Open the image
img = Image.open(self.current_file) img = Image.open(self.current_file)
# Create new PngInfo object with only our metadata
metadata = PngInfo() metadata = PngInfo()
for key, value in self.metadata_dict.items(): for key, value in self.metadata_dict.items():
metadata.add_text(key, value) metadata.add_text(key, value)
# Save the image with new metadata (this replaces all text chunks)
img.save(self.current_file, pnginfo=metadata) img.save(self.current_file, pnginfo=metadata)
self.mark_as_saved() self.mark_as_saved()
self.set_status("Metadata saved successfully", color="green") self.set_status("Metadata saved successfully", color="green")
except Exception as e: except Exception as e:
messagebox.showerror("Error", f"Failed to save metadata:\n{str(e)}") messagebox.showerror("Error", f"Failed to save metadata:\n{str(e)}")
self.set_status("Failed to save metadata", color="red") self.set_status("Failed to save metadata", color="red")

View file

@ -1,2 +0,0 @@
pillow>=9.0.0
pngmeta>=1.0.0

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

After

Width:  |  Height:  |  Size: 806 KiB

Before After
Before After

102
setup.py
View file

@ -1,102 +0,0 @@
#!/usr/bin/env python3
import sys
import platform
from pathlib import Path
import subprocess
def check_pyinstaller():
"""Check if PyInstaller is installed and available"""
try:
subprocess.run(
[sys.executable, "-m", "PyInstaller", "--version"],
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE
)
return True
except (subprocess.CalledProcessError, FileNotFoundError):
return False
def install_pyinstaller():
"""Install PyInstaller if not already installed"""
print("PyInstaller not found. Installing now...")
try:
subprocess.check_call(
[sys.executable, "-m", "pip", "install", "--upgrade", "pyinstaller"],
stdout=subprocess.DEVNULL
)
print("PyInstaller installed successfully.")
return True
except subprocess.CalledProcessError as e:
print(f"Failed to install PyInstaller: {e}")
return False
def main():
# Check and install PyInstaller if needed
if not check_pyinstaller():
if not install_pyinstaller():
print("Error: PyInstaller installation failed. Please install it manually:")
print("pip install pyinstaller")
sys.exit(1)
# Check if requirements.txt exists and install dependencies
req_file = Path("requirements.txt")
if req_file.exists():
print("\nInstalling dependencies from requirements.txt...")
try:
subprocess.check_call(
[sys.executable, "-m", "pip", "install", "-r", "requirements.txt"],
stdout=subprocess.DEVNULL
)
except subprocess.CalledProcessError as e:
print(f"Warning: Failed to install some dependencies: {e}")
else:
print("\nWarning: requirements.txt not found. Installing default dependencies...")
try:
subprocess.check_call(
[sys.executable, "-m", "pip", "install", "pillow>=9.0.0", "pngmeta>=1.0.0"],
stdout=subprocess.DEVNULL
)
except subprocess.CalledProcessError as e:
print(f"Warning: Failed to install default dependencies: {e}")
# Determine platform-specific build options
system = platform.system()
base_args = [
"--name", "PNG Metadata Editor",
"--windowed",
"--onefile"
]
# Check for version.txt
version_file = Path("version.txt")
if version_file.exists():
print(f"\nUsing version info from {version_file}")
base_args.extend(["--version-file", "version.txt"])
else:
print("\nWarning: version.txt not found. Using default version.")
if system == "Windows":
base_args.extend(["--icon", "AppIcon.ico"])
elif system == "Darwin": # macOS
base_args.extend(["--icon", "AppIcon.icns"])
else: # Linux
pass
# Build the PyInstaller command
pyinstaller_args = ["--distpath", "dist", "--workpath", "build"] + base_args
cmd = [sys.executable, "-m", "PyInstaller"] + pyinstaller_args + ["png-meta-editor.py"]
print(f"\nBuilding PNG Metadata Editor for {system}...")
try:
result = subprocess.run(cmd)
if result.returncode == 0:
print("\nBuild completed successfully!")
print(f"Your executable is in the dist/ folder")
return result.returncode
except subprocess.CalledProcessError as e:
print(f"\nBuild failed with error: {e}")
return 1
if __name__ == "__main__":
sys.exit(main())

View file

@ -1,3 +0,0 @@
1.0.0
FileVersion 1, 0, 0, 0
ProductVersion 1.0.0