diff --git a/.gitignore b/.gitignore index 4e2899b..ab3e8ce 100644 --- a/.gitignore +++ b/.gitignore @@ -162,4 +162,3 @@ cython_debug/ # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ -.DS_Store diff --git a/AppIcon.ico b/AppIcon.ico deleted file mode 100644 index fa4611a..0000000 Binary files a/AppIcon.ico and /dev/null differ diff --git a/README.md b/README.md index c94fbc8..8b4f229 100644 --- a/README.md +++ b/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. ![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 @@ -42,139 +24,45 @@ A graphical tool for viewing and editing metadata in PNG files with an intuitive Install required packages using pip: ```bash -pip install pillow +pip install pillow pngmeta ``` ## Usage -### Basic Workflow - 1. Run the application: ```bash python png-meta-editor.py ``` -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 +2. Open a PNG file using the "Open PNG File" button -### 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 +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 ## 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 pyinstaller --name="PNG Metadata Editor" \ --windowed \ --onefile \ --icon=AppIcon.icns \ - --version-file version.txt \ png-meta-editor.py ``` -#### 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 +The resulting executable will be in the `dist/` directory. ## 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 -Robert Tusa -robert@tusa.at +Robert Tusa +[Your Contact Information] ## Version History -### 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 \ No newline at end of file +- 2026-01-05: Initial release \ No newline at end of file diff --git a/png-meta-editor.py b/png-meta-editor.py index 4cc3bc4..984ca5a 100644 --- a/png-meta-editor.py +++ b/png-meta-editor.py @@ -1,28 +1,21 @@ + import tkinter as tk from tkinter import ttk, filedialog, messagebox from pathlib import Path import json -from PIL import Image, ImageTk +from PIL import Image 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(f"{APP_NAME} v{APP_VERSION}") - self.root.geometry("1000x700") # Smaller window size + self.root.title("PNG Metadata Editor") + self.root.geometry("900x700") + 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() @@ -32,72 +25,13 @@ 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="🗂️ 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) + ttk.Button(top_frame, text="Open PNG File", command=self.open_file).pack(side=tk.LEFT, padx=5) + ttk.Button(top_frame, text="Save Changes", command=self.save_changes).pack(side=tk.LEFT, padx=5) self.file_label = ttk.Label(top_frame, text="No file loaded", foreground="gray") self.file_label.pack(side=tk.LEFT, padx=20) @@ -106,75 +40,36 @@ class PNGMetadataEditor: self.changes_label = ttk.Label(top_frame, text="", foreground="orange") 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("", self.on_thumb_frame_configure) - self.thumb_canvas.bind("", 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) + paned = ttk.PanedWindow(self.root, orient=tk.VERTICAL) + 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) - paned.add(tree_frame, weight=1) # Changed from weight=2 to weight=1 + paned.add(tree_frame, weight=2) # Treeview with scrollbar tree_scroll = ttk.Scrollbar(tree_frame) tree_scroll.pack(side=tk.RIGHT, fill=tk.Y) - self.tree = ttk.Treeview(tree_frame, columns=("Key", "Value"), show="headings", - yscrollcommand=tree_scroll.set) + self.tree = 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=150) - self.tree.column("Value", width=400) + self.tree.column("Key", width=200) + self.tree.column("Value", width=600) self.tree.pack(fill=tk.BOTH, expand=True) + tree_scroll.config(command=self.tree.yview) # Bind selection to update detail view self.tree.bind("<>", self.on_selection_change) - # Bind double-click to edit self.tree.bind("", self.edit_entry) - # Bottom pane - Detail view (2/3 of height) + # Bottom pane - Detail view 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) @@ -183,18 +78,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(editor_frame, padding="10") + button_frame = ttk.Frame(self.root, padding="10") button_frame.pack(fill=tk.X) - ttk.Button(button_frame, text="✍️ Add Field", command=self.add_field).pack(side=tk.LEFT, padx=5) - ttk.Button(button_frame, text="✏️ Edit Field", command=self.edit_entry).pack(side=tk.LEFT, padx=5) - ttk.Button(button_frame, text="❌ Delete Field", command=self.delete_field).pack(side=tk.LEFT, padx=5) - ttk.Button(button_frame, text="📋 Copy Value", command=self.copy_value).pack(side=tk.LEFT, padx=5) + 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) @@ -203,263 +98,16 @@ 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("", self.on_mousewheel) - widget.bind("", self.on_mousewheel) - widget.bind("", self.on_mousewheel) - elif system == "Windows": - widget.bind("", self.on_mousewheel) - elif system == "Linux": - widget.bind("", self.on_mousewheel) - widget.bind("", self.on_mousewheel) - - widget.bind("", lambda e: widget.bind_all("", self.on_mousewheel)) - widget.bind("", lambda e: widget.unbind_all("")) - - 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("", on_enter) - thumb_frame.bind("", on_leave) - btn.bind("", on_enter) - btn.bind("", 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("", on_enter) - name_label.bind("", 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="")) @@ -478,7 +126,7 @@ class PNGMetadataEditor: def update_title(self): """Update window title with unsaved indicator""" - base_title = f"{APP_NAME} v{APP_VERSION}" + base_title = "PNG Metadata Editor" if self.current_file: filename = Path(self.current_file).name if self.has_unsaved_changes: @@ -491,20 +139,14 @@ 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) @@ -518,10 +160,12 @@ 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) @@ -534,21 +178,38 @@ class PNGMetadataEditor: ) if filepath: - parent_dir = str(Path(filepath).parent) - if parent_dir != self.current_directory: - self.current_directory = parent_dir - self.load_directory_thumbnails(parent_dir) + try: + self.current_file = filepath - 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): + # 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)) @@ -563,26 +224,31 @@ 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) @@ -594,9 +260,7 @@ class PNGMetadataEditor: messagebox.showwarning("Invalid Input", "Key cannot be empty") return - # Flatten JSON if valid to keep original format - flattened_value = self.flatten_json_if_valid(value) - self.metadata_dict[key] = flattened_value + self.metadata_dict[key] = value self.mark_as_modified() self.refresh_tree() self.set_status(f"Added field '{key}'", color="green") @@ -617,6 +281,7 @@ 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) @@ -624,15 +289,19 @@ 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) @@ -645,14 +314,13 @@ 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() - # Flatten JSON if valid to keep original format - flattened_value = self.flatten_json_if_valid(new_value) - self.metadata_dict[key] = flattened_value + self.metadata_dict[key] = new_value self.mark_as_modified() self.refresh_tree() self.set_status(f"Updated field '{key}'", color="green") @@ -699,17 +367,19 @@ 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") diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index b0d90d0..0000000 --- a/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -pillow>=9.0.0 -pngmeta>=1.0.0 \ No newline at end of file diff --git a/screenshot.png b/screenshot.png index 6582577..ac57d77 100644 Binary files a/screenshot.png and b/screenshot.png differ diff --git a/setup.py b/setup.py deleted file mode 100644 index 98f5ddb..0000000 --- a/setup.py +++ /dev/null @@ -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()) diff --git a/version.txt b/version.txt deleted file mode 100644 index 5643ef4..0000000 --- a/version.txt +++ /dev/null @@ -1,3 +0,0 @@ -1.0.0 -FileVersion 1, 0, 0, 0 -ProductVersion 1.0.0 \ No newline at end of file