From eca6d43e6ad1529ded3d959efc46b57f99a09002 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 22 Nov 2025 00:06:07 +0100 Subject: [PATCH] Improved snapping. Fixed bug in content embedding. --- pyPhotoAlbum/alignment.py | 43 +++--- pyPhotoAlbum/asset_heal_dialog.py | 59 ++++++-- pyPhotoAlbum/mixins/asset_drop.py | 46 +++--- pyPhotoAlbum/mixins/element_manipulation.py | 3 +- pyPhotoAlbum/mixins/mouse_interaction.py | 3 +- pyPhotoAlbum/mixins/operations/view_ops.py | 132 ++++++++++------- pyPhotoAlbum/mixins/rendering.py | 2 +- pyPhotoAlbum/page_layout.py | 66 ++++++--- pyPhotoAlbum/project.py | 27 +++- pyPhotoAlbum/project_serializer.py | 65 +++++++- pyPhotoAlbum/snapping.py | 116 ++++++++++----- test_drop_bug.py | 50 +++++++ test_heal_function.py | 155 ++++++++++++++++++++ test_zip_embedding.py | 139 ++++++++++++++++++ 14 files changed, 735 insertions(+), 171 deletions(-) create mode 100644 test_drop_bug.py create mode 100755 test_heal_function.py create mode 100755 test_zip_embedding.py diff --git a/pyPhotoAlbum/alignment.py b/pyPhotoAlbum/alignment.py index f0d445c..c988d29 100644 --- a/pyPhotoAlbum/alignment.py +++ b/pyPhotoAlbum/alignment.py @@ -665,6 +665,7 @@ class AlignmentManager: max_bottom = (page_height - min_gap) - (y + h) # How much we can expand down # Check constraints from other elements + # We need to be conservative and check ALL elements against ALL expansion directions for other in other_elements: ox, oy = other.position ow, oh = other.size @@ -681,33 +682,39 @@ class AlignmentManager: elem_top = y elem_bottom = y + h - # Check if elements are aligned horizontally (could affect left/right expansion) - # Two rectangles are "aligned horizontally" if their vertical ranges overlap - vertical_overlap = not (elem_bottom < other_top or elem_top > other_bottom) - - if vertical_overlap: - # Other element is to the left - limits leftward expansion - if other_right <= elem_left: + # Check leftward expansion + # An element blocks leftward expansion if: + # 1. It's to the left of our left edge (other_right <= elem_left) + # 2. Its vertical range would overlap with ANY part of our vertical extent + if other_right <= elem_left: + # Check if vertical ranges overlap (current OR after any vertical expansion) + # Conservative: assume we might expand vertically to page bounds + if not (other_bottom <= elem_top - min_gap or other_top >= elem_bottom + min_gap): + # This element blocks leftward expansion available_left = elem_left - other_right - min_gap max_left = min(max_left, available_left) - # Other element is to the right - limits rightward expansion - if other_left >= elem_right: + # Check rightward expansion + if other_left >= elem_right: + # Check if vertical ranges overlap + if not (other_bottom <= elem_top - min_gap or other_top >= elem_bottom + min_gap): + # This element blocks rightward expansion available_right = other_left - elem_right - min_gap max_right = min(max_right, available_right) - # Check if elements are aligned vertically (could affect top/bottom expansion) - # Two rectangles are "aligned vertically" if their horizontal ranges overlap - horizontal_overlap = not (elem_right < other_left or elem_left > other_right) - - if horizontal_overlap: - # Other element is above - limits upward expansion - if other_bottom <= elem_top: + # Check upward expansion + if other_bottom <= elem_top: + # Check if horizontal ranges overlap + if not (other_right <= elem_left - min_gap or other_left >= elem_right + min_gap): + # This element blocks upward expansion available_top = elem_top - other_bottom - min_gap max_top = min(max_top, available_top) - # Other element is below - limits downward expansion - if other_top >= elem_bottom: + # Check downward expansion + if other_top >= elem_bottom: + # Check if horizontal ranges overlap + if not (other_right <= elem_left - min_gap or other_left >= elem_right + min_gap): + # This element blocks downward expansion available_bottom = other_top - elem_bottom - min_gap max_bottom = min(max_bottom, available_bottom) diff --git a/pyPhotoAlbum/asset_heal_dialog.py b/pyPhotoAlbum/asset_heal_dialog.py index 72ab194..9593efc 100644 --- a/pyPhotoAlbum/asset_heal_dialog.py +++ b/pyPhotoAlbum/asset_heal_dialog.py @@ -140,14 +140,25 @@ class AssetHealDialog(QDialog): return healed_count = 0 + imported_count = 0 still_missing = [] # Update asset resolution context with search paths set_asset_resolution_context(self.project.folder_path, self.search_paths) - # Try to find each missing asset + # Build mapping of missing paths to elements + path_to_elements: Dict[str, List] = {} + for page in self.project.pages: + for element in page.layout.elements: + if isinstance(element, ImageData) and element.image_path: + if element.image_path in self.missing_assets: + if element.image_path not in path_to_elements: + path_to_elements[element.image_path] = [] + path_to_elements[element.image_path].append(element) + + # Try to find and import each missing asset for asset_path in self.missing_assets: - found = False + found_path = None filename = os.path.basename(asset_path) # Search in each search path @@ -155,25 +166,53 @@ class AssetHealDialog(QDialog): # Try direct match candidate = os.path.join(search_path, filename) if os.path.exists(candidate): - found = True - healed_count += 1 - print(f"Healed: {asset_path} → {candidate}") + found_path = candidate break # Try with same relative path candidate = os.path.join(search_path, asset_path) if os.path.exists(candidate): - found = True - healed_count += 1 - print(f"Healed: {asset_path} → {candidate}") + found_path = candidate break - if not found: + if found_path: + healed_count += 1 + + # Check if the found file needs to be imported + # (i.e., it's not already in the assets folder) + needs_import = True + if not os.path.isabs(asset_path) and asset_path.startswith('assets/'): + # It's already a relative assets path, just missing from disk + # Copy it to the correct location + dest_path = os.path.join(self.project.folder_path, asset_path) + os.makedirs(os.path.dirname(dest_path), exist_ok=True) + import shutil + shutil.copy2(found_path, dest_path) + print(f"Restored: {asset_path} from {found_path}") + else: + # It's an absolute path or external path - need to import it + try: + new_asset_path = self.project.asset_manager.import_asset(found_path) + imported_count += 1 + + # Update all elements using this path + if asset_path in path_to_elements: + for element in path_to_elements[asset_path]: + element.image_path = new_asset_path + + print(f"Imported and updated: {asset_path} → {new_asset_path}") + except Exception as e: + print(f"Error importing {found_path}: {e}") + still_missing.append(asset_path) + continue + else: still_missing.append(asset_path) # Report results message = f"Healing complete!\n\n" - message += f"Assets healed: {healed_count}\n" + message += f"Assets found: {healed_count}\n" + if imported_count > 0: + message += f"Assets imported to project: {imported_count}\n" message += f"Still missing: {len(still_missing)}" if still_missing: diff --git a/pyPhotoAlbum/mixins/asset_drop.py b/pyPhotoAlbum/mixins/asset_drop.py index 9d64cd1..214f795 100644 --- a/pyPhotoAlbum/mixins/asset_drop.py +++ b/pyPhotoAlbum/mixins/asset_drop.py @@ -57,26 +57,34 @@ class AssetDropMixin: target_element = self._get_element_at(x, y) if target_element and isinstance(target_element, (ImageData, PlaceholderData)): - if isinstance(target_element, PlaceholderData): - new_image = ImageData( - image_path=image_path, - x=target_element.position[0], - y=target_element.position[1], - width=target_element.size[0], - height=target_element.size[1], - z_index=target_element.z_index - ) - main_window = self.window() - if hasattr(main_window, 'project') and main_window.project and main_window.project.pages: - for page in main_window.project.pages: - if target_element in page.layout.elements: - page.layout.elements.remove(target_element) - page.layout.add_element(new_image) - break - else: - target_element.image_path = image_path + main_window = self.window() + if hasattr(main_window, 'project') and main_window.project: + try: + # Import the asset to the project's assets folder + asset_path = main_window.project.asset_manager.import_asset(image_path) - print(f"Updated element with image: {image_path}") + if isinstance(target_element, PlaceholderData): + new_image = ImageData( + image_path=asset_path, # Use imported asset path + x=target_element.position[0], + y=target_element.position[1], + width=target_element.size[0], + height=target_element.size[1], + z_index=target_element.z_index + ) + if main_window.project.pages: + for page in main_window.project.pages: + if target_element in page.layout.elements: + page.layout.elements.remove(target_element) + page.layout.add_element(new_image) + break + else: + # Update existing ImageData with imported asset + target_element.image_path = asset_path + + print(f"Updated element with image: {asset_path}") + except Exception as e: + print(f"Error importing dropped image: {e}") else: try: from PIL import Image diff --git a/pyPhotoAlbum/mixins/element_manipulation.py b/pyPhotoAlbum/mixins/element_manipulation.py index 4bd508b..0dbd6cd 100644 --- a/pyPhotoAlbum/mixins/element_manipulation.py +++ b/pyPhotoAlbum/mixins/element_manipulation.py @@ -68,7 +68,8 @@ class ElementManipulationMixin: dy=dy, resize_handle=self.resize_handle, page_size=page_size, - dpi=dpi + dpi=dpi, + project=main_window.project ) # Apply the snapped values diff --git a/pyPhotoAlbum/mixins/mouse_interaction.py b/pyPhotoAlbum/mixins/mouse_interaction.py index a178f82..9b736bf 100644 --- a/pyPhotoAlbum/mixins/mouse_interaction.py +++ b/pyPhotoAlbum/mixins/mouse_interaction.py @@ -197,7 +197,8 @@ class MouseInteractionMixin: position=(new_x, new_y), size=self.selected_element.size, page_size=page_size, - dpi=dpi + dpi=dpi, + project=main_window.project ) self.selected_element.position = snapped_pos diff --git a/pyPhotoAlbum/mixins/operations/view_ops.py b/pyPhotoAlbum/mixins/operations/view_ops.py index 7f0ebb2..25e3c17 100644 --- a/pyPhotoAlbum/mixins/operations/view_ops.py +++ b/pyPhotoAlbum/mixins/operations/view_ops.py @@ -80,14 +80,12 @@ class ViewOperationsMixin: ) def toggle_grid_snap(self): """Toggle grid snapping""" - current_page = self.get_current_page() - if not current_page: + if not self.project: return - - snap_sys = current_page.layout.snapping_system - snap_sys.snap_to_grid = not snap_sys.snap_to_grid - - status = "enabled" if snap_sys.snap_to_grid else "disabled" + + self.project.snap_to_grid = not self.project.snap_to_grid + + status = "enabled" if self.project.snap_to_grid else "disabled" self.update_view() self.show_status(f"Grid snapping {status}", 2000) print(f"Grid snapping {status}") @@ -100,14 +98,12 @@ class ViewOperationsMixin: ) def toggle_edge_snap(self): """Toggle edge snapping""" - current_page = self.get_current_page() - if not current_page: + if not self.project: return - - snap_sys = current_page.layout.snapping_system - snap_sys.snap_to_edges = not snap_sys.snap_to_edges - - status = "enabled" if snap_sys.snap_to_edges else "disabled" + + self.project.snap_to_edges = not self.project.snap_to_edges + + status = "enabled" if self.project.snap_to_edges else "disabled" self.update_view() self.show_status(f"Edge snapping {status}", 2000) print(f"Edge snapping {status}") @@ -120,36 +116,51 @@ class ViewOperationsMixin: ) def toggle_guide_snap(self): """Toggle guide snapping""" - current_page = self.get_current_page() - if not current_page: + if not self.project: return - - snap_sys = current_page.layout.snapping_system - snap_sys.snap_to_guides = not snap_sys.snap_to_guides - - status = "enabled" if snap_sys.snap_to_guides else "disabled" + + self.project.snap_to_guides = not self.project.snap_to_guides + + status = "enabled" if self.project.snap_to_guides else "disabled" self.update_view() self.show_status(f"Guide snapping {status}", 2000) print(f"Guide snapping {status}") @ribbon_action( - label="Toggle Snap Lines", - tooltip="Toggle visibility of snap lines", + label="Show Grid", + tooltip="Toggle visibility of grid lines", + tab="View", + group="Snapping" + ) + def toggle_show_grid(self): + """Toggle grid visibility""" + if not self.project: + return + + self.project.show_grid = not self.project.show_grid + + status = "visible" if self.project.show_grid else "hidden" + self.update_view() + self.show_status(f"Grid {status}", 2000) + print(f"Grid {status}") + + @ribbon_action( + label="Show Guides", + tooltip="Toggle visibility of guide lines", tab="View", group="Snapping" ) def toggle_snap_lines(self): - """Toggle snap lines visibility""" - current_page = self.get_current_page() - if not current_page: + """Toggle guide lines visibility""" + if not self.project: return - - current_page.layout.show_snap_lines = not current_page.layout.show_snap_lines - - status = "visible" if current_page.layout.show_snap_lines else "hidden" + + self.project.show_snap_lines = not self.project.show_snap_lines + + status = "visible" if self.project.show_snap_lines else "hidden" self.update_view() - self.show_status(f"Snap lines {status}", 2000) - print(f"Snap lines {status}") + self.show_status(f"Guides {status}", 2000) + print(f"Guides {status}") @ribbon_action( label="Add H Guide", @@ -219,48 +230,45 @@ class ViewOperationsMixin: def set_grid_size(self): """Open dialog to set grid size""" from PyQt6.QtWidgets import QDialog, QVBoxLayout, QHBoxLayout, QLabel, QDoubleSpinBox, QPushButton - - current_page = self.get_current_page() - if not current_page: + + if not self.project: return - - snap_sys = current_page.layout.snapping_system - + # Create dialog dialog = QDialog(self) dialog.setWindowTitle("Grid Settings") dialog.setMinimumWidth(300) - + layout = QVBoxLayout() - + # Grid size setting size_layout = QHBoxLayout() size_layout.addWidget(QLabel("Grid Size:")) - + size_spinbox = QDoubleSpinBox() size_spinbox.setRange(1.0, 100.0) - size_spinbox.setValue(snap_sys.grid_size_mm) + size_spinbox.setValue(self.project.grid_size_mm) size_spinbox.setSuffix(" mm") size_spinbox.setDecimals(1) size_spinbox.setSingleStep(1.0) size_layout.addWidget(size_spinbox) - + layout.addLayout(size_layout) - + # Snap threshold setting threshold_layout = QHBoxLayout() threshold_layout.addWidget(QLabel("Snap Threshold:")) - + threshold_spinbox = QDoubleSpinBox() threshold_spinbox.setRange(0.5, 20.0) - threshold_spinbox.setValue(snap_sys.snap_threshold_mm) + threshold_spinbox.setValue(self.project.snap_threshold_mm) threshold_spinbox.setSuffix(" mm") threshold_spinbox.setDecimals(1) threshold_spinbox.setSingleStep(0.5) threshold_layout.addWidget(threshold_spinbox) - + layout.addLayout(threshold_layout) - + # Buttons button_layout = QHBoxLayout() cancel_btn = QPushButton("Cancel") @@ -268,22 +276,22 @@ class ViewOperationsMixin: ok_btn = QPushButton("OK") ok_btn.clicked.connect(dialog.accept) ok_btn.setDefault(True) - + button_layout.addStretch() button_layout.addWidget(cancel_btn) button_layout.addWidget(ok_btn) layout.addLayout(button_layout) - + dialog.setLayout(layout) - + # Show dialog and apply if accepted if dialog.exec() == QDialog.DialogCode.Accepted: new_grid_size = size_spinbox.value() new_threshold = threshold_spinbox.value() - - snap_sys.grid_size_mm = new_grid_size - snap_sys.snap_threshold_mm = new_threshold - + + self.project.grid_size_mm = new_grid_size + self.project.snap_threshold_mm = new_threshold + self.update_view() self.show_status(f"Grid size: {new_grid_size:.1f}mm, Threshold: {new_threshold:.1f}mm", 2000) print(f"Updated grid settings - Size: {new_grid_size:.1f}mm, Threshold: {new_threshold:.1f}mm") @@ -325,12 +333,22 @@ class ViewOperationsMixin: @ribbon_action( label="Show Grid", - tooltip="Toggle visibility of snap lines", + tooltip="Toggle visibility of grid lines", + tab="Layout", + group="Snapping" + ) + def layout_toggle_show_grid(self): + """Toggle grid visibility (Layout tab)""" + self.toggle_show_grid() + + @ribbon_action( + label="Show Guides", + tooltip="Toggle visibility of guide lines", tab="Layout", group="Snapping" ) def layout_toggle_snap_lines(self): - """Toggle snap lines visibility (Layout tab)""" + """Toggle guide lines visibility (Layout tab)""" self.toggle_snap_lines() @ribbon_action( diff --git a/pyPhotoAlbum/mixins/rendering.py b/pyPhotoAlbum/mixins/rendering.py index 9f6e4e0..5a23f22 100644 --- a/pyPhotoAlbum/mixins/rendering.py +++ b/pyPhotoAlbum/mixins/rendering.py @@ -69,7 +69,7 @@ class RenderingMixin: renderer.begin_render() # Pass widget reference for async loading page.layout._parent_widget = self - page.layout.render(dpi=dpi) + page.layout.render(dpi=dpi, project=main_window.project) renderer.end_render() elif page_type == 'ghost': diff --git a/pyPhotoAlbum/page_layout.py b/pyPhotoAlbum/page_layout.py index 5014cc7..839c6e8 100644 --- a/pyPhotoAlbum/page_layout.py +++ b/pyPhotoAlbum/page_layout.py @@ -39,15 +39,16 @@ class PageLayout: """Set a grid layout for the page""" self.grid_layout = grid - def render(self, dpi: int = 300): + def render(self, dpi: int = 300, project=None): """ Render all elements on the page in page-local coordinates. - + Note: This method assumes OpenGL transformations have already been set up by PageRenderer.begin_render(). All coordinates here are in page-local space. - + Args: dpi: Working DPI for converting mm to pixels + project: Optional project instance for global snapping settings """ from OpenGL.GL import glBegin, glEnd, glVertex2f, glColor3f, GL_QUADS, GL_LINE_LOOP, GL_LINES, glLineWidth, glDisable, glEnable, GL_DEPTH_TEST @@ -134,26 +135,55 @@ class PageLayout: glEnd() glLineWidth(1.0) - # Always render snap lines (grid shows when snap_to_grid is on, guides show when show_snap_lines is on) - self._render_snap_lines(dpi, page_x, page_y) - + # Always render snap lines (grid shows when show_grid is on, guides show when show_snap_lines is on) + self._render_snap_lines(dpi, page_x, page_y, project) + # Re-enable depth testing glEnable(GL_DEPTH_TEST) - def _render_snap_lines(self, dpi: int, page_x: float, page_y: float): + def _render_snap_lines(self, dpi: int, page_x: float, page_y: float, project=None): """Render snap lines (grid, edges, guides)""" - from OpenGL.GL import (glColor3f, glColor4f, glLineWidth, glBegin, glEnd, - glVertex2f, GL_LINES, glEnable, glDisable, GL_BLEND, + from OpenGL.GL import (glColor3f, glColor4f, glLineWidth, glBegin, glEnd, + glVertex2f, GL_LINES, glEnable, glDisable, GL_BLEND, glBlendFunc, GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) - - snap_lines = self.snapping_system.get_snap_lines(self.size, dpi) - + + # Use project settings if available, otherwise fall back to local snapping_system + if project: + # Use project-level global settings + snap_to_grid = project.snap_to_grid + snap_to_edges = project.snap_to_edges + snap_to_guides = project.snap_to_guides + grid_size_mm = project.grid_size_mm + snap_threshold_mm = project.snap_threshold_mm + show_grid = project.show_grid + show_snap_lines = project.show_snap_lines + else: + # Fall back to per-page settings (backward compatibility) + snap_to_grid = self.snapping_system.snap_to_grid + snap_to_edges = self.snapping_system.snap_to_edges + snap_to_guides = self.snapping_system.snap_to_guides + grid_size_mm = self.snapping_system.grid_size_mm + snap_threshold_mm = self.snapping_system.snap_threshold_mm + show_grid = snap_to_grid # Old behavior: grid only shows when snapping + show_snap_lines = self.show_snap_lines + + # Create a temporary snapping system with project settings to get snap lines + from pyPhotoAlbum.snapping import SnappingSystem + temp_snap_sys = SnappingSystem(snap_threshold_mm=snap_threshold_mm) + temp_snap_sys.grid_size_mm = grid_size_mm + temp_snap_sys.snap_to_grid = snap_to_grid + temp_snap_sys.snap_to_edges = snap_to_edges + temp_snap_sys.snap_to_guides = snap_to_guides + temp_snap_sys.guides = self.snapping_system.guides # Use page-specific guides + + snap_lines = temp_snap_sys.get_snap_lines(self.size, dpi) + # Enable alpha blending for transparency glEnable(GL_BLEND) glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) - - # Draw grid lines (darker gray with transparency) - always visible when snap_to_grid is enabled - if self.snapping_system.snap_to_grid and snap_lines['grid']: + + # Draw grid lines (darker gray with transparency) - visible when show_grid is enabled + if show_grid and snap_lines['grid']: glColor4f(0.6, 0.6, 0.6, 0.4) # Gray with 40% opacity glLineWidth(1.0) for orientation, position in snap_lines['grid']: @@ -165,9 +195,9 @@ class PageLayout: glVertex2f(page_x, page_y + position) glVertex2f(page_x + self.size[0] * dpi / 25.4, page_y + position) glEnd() - + # Draw guides (cyan, more visible with transparency) - only show when show_snap_lines is on - if self.show_snap_lines and snap_lines['guides']: + if show_snap_lines and snap_lines['guides']: glColor4f(0.0, 0.7, 0.9, 0.8) # Cyan with 80% opacity glLineWidth(1.5) for orientation, position in snap_lines['guides']: @@ -179,7 +209,7 @@ class PageLayout: glVertex2f(page_x, page_y + position) glVertex2f(page_x + self.size[0] * dpi / 25.4, page_y + position) glEnd() - + glLineWidth(1.0) glDisable(GL_BLEND) diff --git a/pyPhotoAlbum/project.py b/pyPhotoAlbum/project.py index 929cb29..d9a8f17 100644 --- a/pyPhotoAlbum/project.py +++ b/pyPhotoAlbum/project.py @@ -123,6 +123,15 @@ class Project: # Using TemporaryDirectory instance that auto-cleans on deletion self._temp_dir = None + # Global snapping settings (apply to all pages) + self.snap_to_grid = False + self.snap_to_edges = True + self.snap_to_guides = True + self.grid_size_mm = 10.0 + self.snap_threshold_mm = 5.0 + self.show_grid = False # Show grid lines independently of snap_to_grid + self.show_snap_lines = True # Show snap lines (guides) during dragging + # Initialize asset manager self.asset_manager = AssetManager(self.folder_path) @@ -317,6 +326,13 @@ class Project: "cover_bleed_mm": self.cover_bleed_mm, "binding_type": self.binding_type, "embedded_templates": self.embedded_templates, + "snap_to_grid": self.snap_to_grid, + "snap_to_edges": self.snap_to_edges, + "snap_to_guides": self.snap_to_guides, + "grid_size_mm": self.grid_size_mm, + "snap_threshold_mm": self.snap_threshold_mm, + "show_grid": self.show_grid, + "show_snap_lines": self.show_snap_lines, "pages": [page.serialize() for page in self.pages], "history": self.history.serialize(), "asset_manager": self.asset_manager.serialize() @@ -337,9 +353,18 @@ class Project: self.paper_thickness_mm = data.get("paper_thickness_mm", 0.2) self.cover_bleed_mm = data.get("cover_bleed_mm", 0.0) self.binding_type = data.get("binding_type", "saddle_stitch") - + # Deserialize embedded templates self.embedded_templates = data.get("embedded_templates", {}) + + # Deserialize global snapping settings + self.snap_to_grid = data.get("snap_to_grid", False) + self.snap_to_edges = data.get("snap_to_edges", True) + self.snap_to_guides = data.get("snap_to_guides", True) + self.grid_size_mm = data.get("grid_size_mm", 10.0) + self.snap_threshold_mm = data.get("snap_threshold_mm", 5.0) + self.show_grid = data.get("show_grid", False) + self.show_snap_lines = data.get("show_snap_lines", True) self.pages = [] diff --git a/pyPhotoAlbum/project_serializer.py b/pyPhotoAlbum/project_serializer.py index 9a86a4e..704a4f1 100644 --- a/pyPhotoAlbum/project_serializer.py +++ b/pyPhotoAlbum/project_serializer.py @@ -22,6 +22,56 @@ from pyPhotoAlbum.version_manager import ( SERIALIZATION_VERSION = CURRENT_DATA_VERSION +def _import_external_images(project: Project): + """ + Find and import any images that have external (absolute or non-assets) paths. + This ensures all images are in the assets folder before saving. + + Args: + project: The Project instance to check + """ + from pyPhotoAlbum.models import ImageData + + imported_count = 0 + + for page in project.pages: + for element in page.layout.elements: + if isinstance(element, ImageData) and element.image_path: + # Check if this is an external path (absolute or not in assets/) + is_external = False + + if os.path.isabs(element.image_path): + # Absolute path - definitely external + is_external = True + external_path = element.image_path + elif not element.image_path.startswith('assets/'): + # Relative path but not in assets folder + # Check if it exists relative to project folder + full_path = os.path.join(project.folder_path, element.image_path) + if os.path.exists(full_path) and not full_path.startswith(project.asset_manager.assets_folder): + is_external = True + external_path = full_path + else: + # Path doesn't exist - skip it (will be caught as missing asset) + continue + else: + # Already in assets/ folder + continue + + # Import the external image + if is_external and os.path.exists(external_path): + try: + new_asset_path = project.asset_manager.import_asset(external_path) + element.image_path = new_asset_path + imported_count += 1 + print(f"Auto-imported external image: {external_path} → {new_asset_path}") + except Exception as e: + print(f"Warning: Failed to import external image {external_path}: {e}") + + if imported_count > 0: + print(f"Auto-imported {imported_count} external image(s) to assets folder") + + def _normalize_asset_paths(project: Project, project_folder: str): """ Normalize asset paths in a loaded project to be relative to the project folder. @@ -73,11 +123,11 @@ def _normalize_asset_paths(project: Project, project_folder: str): def save_to_zip(project: Project, zip_path: str) -> Tuple[bool, Optional[str]]: """ Save a project to a ZIP file, including all assets. - + Args: project: The Project instance to save zip_path: Path where the ZIP file should be created - + Returns: Tuple of (success: bool, error_message: Optional[str]) """ @@ -85,7 +135,10 @@ def save_to_zip(project: Project, zip_path: str) -> Tuple[bool, Optional[str]]: # Ensure .ppz extension if not zip_path.lower().endswith('.ppz'): zip_path += '.ppz' - + + # Check for and import any external images before saving + _import_external_images(project) + # Serialize project to dictionary project_data = project.serialize() @@ -98,7 +151,7 @@ def save_to_zip(project: Project, zip_path: str) -> Tuple[bool, Optional[str]]: # Write project.json project_json = json.dumps(project_data, indent=2) zipf.writestr('project.json', project_json) - + # Add all files from the assets folder assets_folder = project.asset_manager.assets_folder if os.path.exists(assets_folder): @@ -108,10 +161,10 @@ def save_to_zip(project: Project, zip_path: str) -> Tuple[bool, Optional[str]]: # Store with relative path from project folder arcname = os.path.relpath(file_path, project.folder_path) zipf.write(file_path, arcname) - + print(f"Project saved to {zip_path}") return True, None - + except Exception as e: error_msg = f"Error saving project: {str(e)}" print(error_msg) diff --git a/pyPhotoAlbum/snapping.py b/pyPhotoAlbum/snapping.py index 6cecbee..ac4dfbb 100644 --- a/pyPhotoAlbum/snapping.py +++ b/pyPhotoAlbum/snapping.py @@ -61,40 +61,56 @@ class SnappingSystem: """Remove all guides""" self.guides.clear() - def snap_position(self, - position: Tuple[float, float], + def snap_position(self, + position: Tuple[float, float], size: Tuple[float, float], page_size: Tuple[float, float], - dpi: int = 300) -> Tuple[float, float]: + dpi: int = 300, + project=None) -> Tuple[float, float]: """ Apply snapping to a position using combined distance threshold - + Args: position: Current position (x, y) in pixels size: Element size (width, height) in pixels page_size: Page size (width, height) in mm dpi: DPI for conversion - + project: Optional project for global snapping settings + Returns: Snapped position (x, y) in pixels """ import math - + x, y = position width, height = size page_width_mm, page_height_mm = page_size - + + # Use project settings if available, otherwise use local settings + if project: + snap_to_grid = project.snap_to_grid + snap_to_edges = project.snap_to_edges + snap_to_guides = project.snap_to_guides + grid_size_mm = project.grid_size_mm + snap_threshold_mm = project.snap_threshold_mm + else: + snap_to_grid = self.snap_to_grid + snap_to_edges = self.snap_to_edges + snap_to_guides = self.snap_to_guides + grid_size_mm = self.grid_size_mm + snap_threshold_mm = self.snap_threshold_mm + # Convert threshold from mm to pixels - snap_threshold_px = self.snap_threshold_mm * dpi / 25.4 + snap_threshold_px = snap_threshold_mm * dpi / 25.4 # Collect all potential snap points for both edges of the element snap_points = [] # 1. Page edge snap points - if self.snap_to_edges: + if snap_to_edges: page_width_px = page_width_mm * dpi / 25.4 page_height_px = page_height_mm * dpi / 25.4 - + # Corners where element's top-left can snap snap_points.extend([ (0, 0), # Top-left corner @@ -102,7 +118,7 @@ class SnappingSystem: (0, page_height_px - height), # Bottom-left corner (page_width_px - width, page_height_px - height), # Bottom-right corner ]) - + # Edge positions (element aligned to edge on one axis) snap_points.extend([ (0, y), # Left edge @@ -110,10 +126,10 @@ class SnappingSystem: (x, 0), # Top edge (x, page_height_px - height), # Bottom edge ]) - + # 2. Grid snap points - if self.snap_to_grid: - grid_size_px = self.grid_size_mm * dpi / 25.4 + if snap_to_grid: + grid_size_px = grid_size_mm * dpi / 25.4 page_width_px = page_width_mm * dpi / 25.4 page_height_px = page_height_mm * dpi / 25.4 @@ -137,7 +153,7 @@ class SnappingSystem: grid_x += grid_size_px # 3. Guide snap points - if self.snap_to_guides: + if snap_to_guides: vertical_guides = [g.position * dpi / 25.4 for g in self.guides if g.orientation == 'vertical'] horizontal_guides = [g.position * dpi / 25.4 for g in self.guides if g.orientation == 'horizontal'] @@ -173,10 +189,11 @@ class SnappingSystem: dy: float, resize_handle: str, page_size: Tuple[float, float], - dpi: int = 300) -> Tuple[Tuple[float, float], Tuple[float, float]]: + dpi: int = 300, + project=None) -> Tuple[Tuple[float, float], Tuple[float, float]]: """ Apply snapping during resize operations - + Args: position: Current position (x, y) in pixels size: Current size (width, height) in pixels @@ -185,16 +202,23 @@ class SnappingSystem: resize_handle: Which handle is being dragged ('nw', 'ne', 'sw', 'se', 'n', 's', 'e', 'w') page_size: Page size (width, height) in mm dpi: DPI for conversion - + project: Optional project for global snapping settings + Returns: Tuple of (snapped_position, snapped_size) in pixels """ x, y = position width, height = size page_width_mm, page_height_mm = page_size - + + # Use project settings if available, otherwise use local settings + if project: + snap_threshold_mm = project.snap_threshold_mm + else: + snap_threshold_mm = self.snap_threshold_mm + # Convert threshold from mm to pixels - snap_threshold_px = self.snap_threshold_mm * dpi / 25.4 + snap_threshold_px = snap_threshold_mm * dpi / 25.4 # Calculate new position and size based on resize handle new_x, new_y = x, y @@ -226,44 +250,44 @@ class SnappingSystem: if resize_handle in ['nw', 'w', 'sw']: # Try to snap the left edge snapped_left = self._snap_edge_to_targets( - new_x, page_width_mm, dpi, snap_threshold_px, 'vertical' + new_x, page_width_mm, dpi, snap_threshold_px, 'vertical', project ) if snapped_left is not None: # Adjust width to compensate for position change width_adjustment = new_x - snapped_left new_x = snapped_left new_width += width_adjustment - + # Snap right edge (for ne, e, se handles) if resize_handle in ['ne', 'e', 'se']: # Calculate right edge position right_edge = new_x + new_width # Try to snap the right edge snapped_right = self._snap_edge_to_targets( - right_edge, page_width_mm, dpi, snap_threshold_px, 'vertical' + right_edge, page_width_mm, dpi, snap_threshold_px, 'vertical', project ) if snapped_right is not None: new_width = snapped_right - new_x - + # Snap top edge (for nw, n, ne handles) if resize_handle in ['nw', 'n', 'ne']: # Try to snap the top edge snapped_top = self._snap_edge_to_targets( - new_y, page_height_mm, dpi, snap_threshold_px, 'horizontal' + new_y, page_height_mm, dpi, snap_threshold_px, 'horizontal', project ) if snapped_top is not None: # Adjust height to compensate for position change height_adjustment = new_y - snapped_top new_y = snapped_top new_height += height_adjustment - + # Snap bottom edge (for sw, s, se handles) if resize_handle in ['sw', 's', 'se']: # Calculate bottom edge position bottom_edge = new_y + new_height # Try to snap the bottom edge snapped_bottom = self._snap_edge_to_targets( - bottom_edge, page_height_mm, dpi, snap_threshold_px, 'horizontal' + bottom_edge, page_height_mm, dpi, snap_threshold_px, 'horizontal', project ) if snapped_bottom is not None: new_height = snapped_bottom - new_y @@ -280,41 +304,55 @@ class SnappingSystem: page_size_mm: float, dpi: int, snap_threshold_px: float, - orientation: str) -> Optional[float]: + orientation: str, + project=None) -> Optional[float]: """ Snap an edge position to available targets (grid, edges, guides) - + Args: edge_position: Current edge position in pixels page_size_mm: Page size along axis in mm dpi: DPI for conversion snap_threshold_px: Snap threshold in pixels orientation: 'vertical' for x-axis, 'horizontal' for y-axis - + project: Optional project for global snapping settings + Returns: Snapped edge position in pixels, or None if no snap """ + # Use project settings if available, otherwise use local settings + if project: + snap_to_grid = project.snap_to_grid + snap_to_edges = project.snap_to_edges + snap_to_guides = project.snap_to_guides + grid_size_mm = project.grid_size_mm + else: + snap_to_grid = self.snap_to_grid + snap_to_edges = self.snap_to_edges + snap_to_guides = self.snap_to_guides + grid_size_mm = self.grid_size_mm + snap_candidates = [] - + # 1. Page edge snapping - if self.snap_to_edges: + if snap_to_edges: # Snap to start edge (0) snap_candidates.append((0, abs(edge_position - 0))) - + # Snap to end edge page_size_px = page_size_mm * dpi / 25.4 snap_candidates.append((page_size_px, abs(edge_position - page_size_px))) - + # 2. Grid snapping - if self.snap_to_grid: - grid_size_px = self.grid_size_mm * dpi / 25.4 - + if snap_to_grid: + grid_size_px = grid_size_mm * dpi / 25.4 + # Snap to nearest grid line nearest_grid = round(edge_position / grid_size_px) * grid_size_px snap_candidates.append((nearest_grid, abs(edge_position - nearest_grid))) - + # 3. Guide snapping - if self.snap_to_guides: + if snap_to_guides: for guide in self.guides: if guide.orientation == orientation: guide_pos_px = guide.position * dpi / 25.4 diff --git a/test_drop_bug.py b/test_drop_bug.py new file mode 100644 index 0000000..317378a --- /dev/null +++ b/test_drop_bug.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +""" +Test to demonstrate the asset drop bug +""" + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from pyPhotoAlbum.project import Project, Page +from pyPhotoAlbum.models import ImageData + +def test_direct_path_assignment(): + """Simulate what happens when you drop an image on existing element""" + + project = Project("Test Direct Path") + page = Page() + project.add_page(page) + + # Add an image element + img = ImageData() + img.position = (10, 10) + img.size = (50, 50) + page.layout.add_element(img) + + # Simulate dropping a new image on existing element (line 77 in asset_drop.py) + external_image = "/home/dtourolle/Pictures/some_photo.jpg" + print(f"\nSimulating drop on existing image element...") + print(f"Setting image_path directly to: {external_image}") + img.image_path = external_image # BUG: Not imported! + + # Check assets folder + assets = os.listdir(project.asset_manager.assets_folder) if os.path.exists(project.asset_manager.assets_folder) else [] + print(f"\nAssets in folder: {len(assets)}") + print(f" {assets if assets else '(empty)'}") + + # The image path in the element points to external file + print(f"\nImage path in element: {img.image_path}") + print(f" Is absolute path: {os.path.isabs(img.image_path)}") + + if os.path.isabs(img.image_path): + print("\n❌ BUG CONFIRMED: Image path is absolute, not copied to assets!") + print(" When saved to .ppz, this external file will NOT be included.") + return False + + return True + +if __name__ == "__main__": + test_direct_path_assignment() diff --git a/test_heal_function.py b/test_heal_function.py new file mode 100755 index 0000000..0821507 --- /dev/null +++ b/test_heal_function.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +""" +Test to verify the heal function can fix old files with missing assets +""" + +import os +import sys +import tempfile +import shutil + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from pyPhotoAlbum.project import Project, Page +from pyPhotoAlbum.models import ImageData +from pyPhotoAlbum.project_serializer import save_to_zip, load_from_zip + +def test_heal_external_paths(): + """Test healing a project with external (absolute) image paths""" + + print("=" * 70) + print("Test: Healing External Image Paths") + print("=" * 70) + + # Create a test image in a temp location + with tempfile.TemporaryDirectory() as temp_dir: + # Create a test image file + test_image_source = "./projects/project_with_image/assets/test_image.jpg" + external_image_path = os.path.join(temp_dir, "external_photo.jpg") + shutil.copy2(test_image_source, external_image_path) + + print(f"\n1. Created external image at: {external_image_path}") + + # Create a project + project = Project("Test Heal") + page = Page() + project.add_page(page) + + # Add an image element with ABSOLUTE path (simulating the bug) + img = ImageData() + img.image_path = external_image_path # BUG: Absolute path! + img.position = (10, 10) + img.size = (50, 50) + page.layout.add_element(img) + + print(f"2. Created project with absolute path: {img.image_path}") + + # Save to zip + with tempfile.NamedTemporaryFile(suffix='.ppz', delete=False) as tmp: + zip_path = tmp.name + + save_to_zip(project, zip_path) + print(f"3. Saved to: {zip_path}") + + # Check what was saved + import zipfile + with zipfile.ZipFile(zip_path, 'r') as zf: + files = zf.namelist() + asset_files = [f for f in files if f.startswith('assets/')] + print(f"\n4. Assets in zip: {len(asset_files)}") + if len(asset_files) == 0: + print(" ❌ No assets saved (expected - this is the bug!)") + else: + print(f" ✅ Assets: {asset_files}") + + # Load the project + loaded_project, error = load_from_zip(zip_path) + if not loaded_project: + print(f" ❌ Failed to load: {error}") + return False + + print(f"\n5. Loaded project from zip") + + # Check for missing assets + from pyPhotoAlbum.models import ImageData as ImageDataCheck + missing_count = 0 + for page in loaded_project.pages: + for element in page.layout.elements: + if isinstance(element, ImageDataCheck) and element.image_path: + if os.path.isabs(element.image_path): + full_path = element.image_path + else: + full_path = os.path.join(loaded_project.folder_path, element.image_path) + + if not os.path.exists(full_path): + missing_count += 1 + print(f" ❌ Missing: {element.image_path}") + + print(f"\n6. Missing assets detected: {missing_count}") + + if missing_count == 0: + print(" ⚠️ No missing assets - test may not be accurate") + return False + + # Now test the healing logic + print(f"\n7. Testing heal function...") + + # Simulate what the heal dialog does + from pyPhotoAlbum.models import set_asset_resolution_context + + search_paths = [temp_dir] # The directory where our external image is + set_asset_resolution_context(loaded_project.folder_path, search_paths) + + healed_count = 0 + for page in loaded_project.pages: + for element in page.layout.elements: + if isinstance(element, ImageDataCheck) and element.image_path: + # Check if missing + if os.path.isabs(element.image_path): + full_path = element.image_path + else: + full_path = os.path.join(loaded_project.folder_path, element.image_path) + + if not os.path.exists(full_path): + # Try to find and import + filename = os.path.basename(element.image_path) + for search_path in search_paths: + candidate = os.path.join(search_path, filename) + if os.path.exists(candidate): + # Import it! + new_asset_path = loaded_project.asset_manager.import_asset(candidate) + element.image_path = new_asset_path + healed_count += 1 + print(f" ✅ Healed: {filename} → {new_asset_path}") + break + + print(f"\n8. Healed {healed_count} asset(s)") + + # Save the healed project + healed_zip_path = zip_path.replace('.ppz', '_healed.ppz') + save_to_zip(loaded_project, healed_zip_path) + print(f"9. Saved healed project to: {healed_zip_path}") + + # Verify the healed version has assets + with zipfile.ZipFile(healed_zip_path, 'r') as zf: + files = zf.namelist() + asset_files = [f for f in files if f.startswith('assets/')] + print(f"\n10. Assets in healed zip: {len(asset_files)}") + for asset in asset_files: + print(f" - {asset}") + + # Cleanup + loaded_project.cleanup() + os.unlink(zip_path) + os.unlink(healed_zip_path) + + if healed_count > 0 and len(asset_files) > 0: + print("\n✅ HEAL FUNCTION WORKS - Old files can be fixed!") + return True + else: + print("\n❌ Healing did not work as expected") + return False + +if __name__ == "__main__": + success = test_heal_external_paths() + sys.exit(0 if success else 1) diff --git a/test_zip_embedding.py b/test_zip_embedding.py new file mode 100755 index 0000000..f67feeb --- /dev/null +++ b/test_zip_embedding.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +""" +Test script to verify that images are being embedded in .ppz files +""" + +import os +import sys +import zipfile +import tempfile +import shutil + +# Add project to path +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from pyPhotoAlbum.project import Project, Page +from pyPhotoAlbum.models import ImageData +from pyPhotoAlbum.project_serializer import save_to_zip, load_from_zip + +def test_zip_embedding(): + """Test that images are properly embedded in the zip file""" + + # Create a test project with an image + project = Project("Test Embedding") + page = Page() + project.add_page(page) + + # Use an existing test image + test_image = "./projects/project_with_image/assets/test_image.jpg" + if not os.path.exists(test_image): + print(f"ERROR: Test image not found: {test_image}") + return False + + print(f"Using test image: {test_image}") + print(f"Test image size: {os.path.getsize(test_image)} bytes") + + # Import the image through the asset manager + print("\n1. Importing image through asset manager...") + asset_path = project.asset_manager.import_asset(test_image) + print(f" Asset path: {asset_path}") + + # Check that the asset was copied + full_asset_path = project.asset_manager.get_absolute_path(asset_path) + if os.path.exists(full_asset_path): + print(f" ✓ Asset exists at: {full_asset_path}") + print(f" Asset size: {os.path.getsize(full_asset_path)} bytes") + else: + print(f" ✗ ERROR: Asset not found at {full_asset_path}") + return False + + # Add image to page + img = ImageData() + img.image_path = asset_path + img.position = (10, 10) + img.size = (50, 50) + page.layout.add_element(img) + + # Save to a temporary zip file + print("\n2. Saving project to .ppz file...") + with tempfile.NamedTemporaryFile(suffix='.ppz', delete=False) as tmp: + zip_path = tmp.name + + success, error = save_to_zip(project, zip_path) + if not success: + print(f" ✗ ERROR: Failed to save: {error}") + return False + + print(f" ✓ Saved to: {zip_path}") + zip_size = os.path.getsize(zip_path) + print(f" Zip file size: {zip_size:,} bytes") + + # Inspect the zip file contents + print("\n3. Inspecting zip file contents...") + with zipfile.ZipFile(zip_path, 'r') as zf: + files = zf.namelist() + print(f" Files in zip: {len(files)}") + for fname in files: + info = zf.getinfo(fname) + print(f" - {fname} ({info.file_size:,} bytes)") + + # Check if assets are included + asset_files = [f for f in files if f.startswith('assets/')] + print(f"\n4. Checking for embedded assets...") + print(f" Assets found: {len(asset_files)}") + + if len(asset_files) == 0: + print(" ✗ ERROR: No assets embedded in zip file!") + print(f"\n DEBUG INFO:") + print(f" Project folder: {project.folder_path}") + print(f" Assets folder: {project.asset_manager.assets_folder}") + print(f" Assets folder exists: {os.path.exists(project.asset_manager.assets_folder)}") + + if os.path.exists(project.asset_manager.assets_folder): + assets = os.listdir(project.asset_manager.assets_folder) + print(f" Files in assets folder: {assets}") + + # Cleanup + os.unlink(zip_path) + return False + + print(f" ✓ Found {len(asset_files)} asset file(s) in zip") + + # Load the project back + print("\n5. Loading project from zip...") + loaded_project, error = load_from_zip(zip_path) + if loaded_project is None: + print(f" ✗ ERROR: Failed to load: {error}") + os.unlink(zip_path) + return False + + print(f" ✓ Loaded project: {loaded_project.name}") + + # Check that the image is accessible + print("\n6. Verifying loaded image...") + if loaded_project.pages and loaded_project.pages[0].layout.elements: + img_elem = loaded_project.pages[0].layout.elements[0] + if isinstance(img_elem, ImageData): + loaded_img_path = loaded_project.asset_manager.get_absolute_path(img_elem.image_path) + if os.path.exists(loaded_img_path): + print(f" ✓ Image accessible at: {loaded_img_path}") + print(f" Image size: {os.path.getsize(loaded_img_path)} bytes") + else: + print(f" ✗ ERROR: Image not found at {loaded_img_path}") + os.unlink(zip_path) + return False + + # Cleanup + os.unlink(zip_path) + loaded_project.cleanup() + + print("\n✅ ALL TESTS PASSED - Images are being embedded correctly!") + return True + +if __name__ == "__main__": + print("=" * 70) + print("Testing .ppz file image embedding") + print("=" * 70) + + success = test_zip_embedding() + sys.exit(0 if success else 1)