Compare commits

...

6 commits

Author SHA1 Message Date
robert
c34e2d663c feat(v2.3.0): add JSON flattening, fix file handle leaks, update README 2026-01-07 22:30:21 +01:00
robert
5d838bc27e fixed dark mode and scrolling, added symbols to buttons 2026-01-06 23:08:33 +01:00
robert
44c05ed66e added image browser 2026-01-06 22:35:40 +01:00
robert
f4b407a196 modified: README.md 2026-01-06 19:42:00 +01:00
robert
9bd189a4a9 ico file for windows build and setup for windows 2026-01-06 19:37:57 +01:00
robert
835ac2e9a6 added versioning, requirements and setup 2026-01-06 16:15:18 +01:00
8 changed files with 637 additions and 87 deletions

1
.gitignore vendored
View file

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

BIN
AppIcon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

140
README.md
View file

@ -1,16 +1,34 @@
# PNG Metadata Editor
# PNG Metadata Editor v2.3.0
A graphical tool for viewing and editing metadata in PNG files.
A graphical tool for viewing and editing metadata in PNG files with an intuitive image browser interface.
![PNG Metadata Editor Screenshot](screenshot.png)
## Features
### Core Features
- View all metadata fields (tEXt, zTXt, iTXt chunks) in PNG files
- Add, edit, and delete metadata fields
- Pretty-print JSON-formatted values
- Copy metadata values to clipboard
- 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
@ -24,45 +42,139 @@ A graphical tool for viewing and editing metadata in PNG files.
Install required packages using pip:
```bash
pip install pillow pngmeta
pip install pillow
```
## Usage
### Basic Workflow
1. Run the application:
```bash
python png-meta-editor.py
```
2. Open a PNG file using the "Open PNG File" button
2. **Browse Directory**: Click "Browse Directory" to load thumbnails from a folder
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
3. View metadata in the tree view on the left
4. Select a field to see its full value in the detail pane
5. Use the buttons to add, edit, or delete fields
### Keyboard Shortcuts
- **Double-click** on tree item to edit
- **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
To create a standalone executable:
### Using build.py (Recommended)
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
pyinstaller --name="PNG Metadata Editor" \
--windowed \
--onefile \
--icon=AppIcon.icns \
--version-file version.txt \
png-meta-editor.py
```
The resulting executable will be in the `dist/` directory.
#### Linux
```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
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
This project is licensed under the MIT License - see [LICENSE](LICENSE) for details.
## Author
Robert Tusa
[Your Contact Information]
Robert Tusa
robert@tusa.at
## Version History
- 2026-01-05: Initial release
### v2.3.0 (2026-01-07) - Major UI Update
- 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,21 +1,28 @@
import tkinter as tk
from tkinter import ttk, filedialog, messagebox
from pathlib import Path
import json
from PIL import Image
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("PNG Metadata Editor")
self.root.geometry("900x700")
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()
@ -25,13 +32,72 @@ class PNGMetadataEditor:
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="Save Changes", command=self.save_changes).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="🍺 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)
@ -40,36 +106,75 @@ class PNGMetadataEditor:
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)
# 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)
# Top pane - Treeview
# 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=2)
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 = 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.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
# Bottom pane - Detail view (2/3 of height)
detail_frame = ttk.Frame(paned)
paned.add(detail_frame, weight=1)
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)
@ -78,18 +183,18 @@ class PNGMetadataEditor:
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)
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 = 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)
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)
@ -98,16 +203,263 @@ class PNGMetadataEditor:
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)"""
# 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=""))
@ -126,7 +478,7 @@ class PNGMetadataEditor:
def update_title(self):
"""Update window title with unsaved indicator"""
base_title = "PNG Metadata Editor"
base_title = f"{APP_NAME} v{APP_VERSION}"
if self.current_file:
filename = Path(self.current_file).name
if self.has_unsaved_changes:
@ -139,14 +491,20 @@ class PNGMetadataEditor:
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 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)
@ -160,12 +518,10 @@ class PNGMetadataEditor:
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)
@ -178,38 +534,21 @@ class PNGMetadataEditor:
)
if filepath:
try:
self.current_file = filepath
parent_dir = str(Path(filepath).parent)
if parent_dir != self.current_directory:
self.current_directory = parent_dir
self.load_directory_thumbnails(parent_dir)
# 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")
self.load_file_from_path(Path(filepath), auto_scroll=True)
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))
@ -224,31 +563,26 @@ class PNGMetadataEditor:
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,
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)
@ -260,7 +594,9 @@ class PNGMetadataEditor:
messagebox.showwarning("Invalid Input", "Key cannot be empty")
return
self.metadata_dict[key] = value
# 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")
@ -281,7 +617,6 @@ class PNGMetadataEditor:
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)
@ -289,19 +624,15 @@ class PNGMetadataEditor:
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)
@ -314,13 +645,14 @@ class PNGMetadataEditor:
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
# 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")
@ -367,19 +699,17 @@ class PNGMetadataEditor:
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")

2
requirements.txt Normal file
View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 806 KiB

After

Width:  |  Height:  |  Size: 1.8 MiB

Before After
Before After

102
setup.py Normal file
View file

@ -0,0 +1,102 @@
#!/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())

3
version.txt Normal file
View file

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