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
1
.gitignore
vendored
|
|
@ -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
|
|
||||||
|
|
|
||||||
BIN
AppIcon.ico
BIN
AppIcon.ico
Binary file not shown.
|
Before Width: | Height: | Size: 136 KiB |
140
README.md
140
README.md
|
|
@ -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.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## 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
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
pillow>=9.0.0
|
|
||||||
pngmeta>=1.0.0
|
|
||||||
BIN
screenshot.png
BIN
screenshot.png
Binary file not shown.
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 806 KiB |
102
setup.py
102
setup.py
|
|
@ -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())
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
1.0.0
|
|
||||||
FileVersion 1, 0, 0, 0
|
|
||||||
ProductVersion 1.0.0
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue