Improved snapping. Fixed bug in content embedding.
Some checks failed
Python CI / test (push) Successful in 1m23s
Lint / lint (push) Successful in 1m21s
Tests / test (3.10) (push) Failing after 1m9s
Tests / test (3.11) (push) Failing after 1m3s
Tests / test (3.9) (push) Failing after 1m4s

This commit is contained in:
Duncan Tourolle 2025-11-22 00:06:07 +01:00
parent e972fb864e
commit eca6d43e6a
14 changed files with 735 additions and 171 deletions

View File

@ -665,6 +665,7 @@ class AlignmentManager:
max_bottom = (page_height - min_gap) - (y + h) # How much we can expand down max_bottom = (page_height - min_gap) - (y + h) # How much we can expand down
# Check constraints from other elements # Check constraints from other elements
# We need to be conservative and check ALL elements against ALL expansion directions
for other in other_elements: for other in other_elements:
ox, oy = other.position ox, oy = other.position
ow, oh = other.size ow, oh = other.size
@ -681,33 +682,39 @@ class AlignmentManager:
elem_top = y elem_top = y
elem_bottom = y + h elem_bottom = y + h
# Check if elements are aligned horizontally (could affect left/right expansion) # Check leftward expansion
# Two rectangles are "aligned horizontally" if their vertical ranges overlap # An element blocks leftward expansion if:
vertical_overlap = not (elem_bottom < other_top or elem_top > other_bottom) # 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 vertical_overlap: if other_right <= elem_left:
# Other element is to the left - limits leftward expansion # Check if vertical ranges overlap (current OR after any vertical expansion)
if other_right <= elem_left: # 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 available_left = elem_left - other_right - min_gap
max_left = min(max_left, available_left) max_left = min(max_left, available_left)
# Other element is to the right - limits rightward expansion # Check rightward expansion
if other_left >= elem_right: 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 available_right = other_left - elem_right - min_gap
max_right = min(max_right, available_right) max_right = min(max_right, available_right)
# Check if elements are aligned vertically (could affect top/bottom expansion) # Check upward expansion
# Two rectangles are "aligned vertically" if their horizontal ranges overlap if other_bottom <= elem_top:
horizontal_overlap = not (elem_right < other_left or elem_left > other_right) # Check if horizontal ranges overlap
if not (other_right <= elem_left - min_gap or other_left >= elem_right + min_gap):
if horizontal_overlap: # This element blocks upward expansion
# Other element is above - limits upward expansion
if other_bottom <= elem_top:
available_top = elem_top - other_bottom - min_gap available_top = elem_top - other_bottom - min_gap
max_top = min(max_top, available_top) max_top = min(max_top, available_top)
# Other element is below - limits downward expansion # Check downward expansion
if other_top >= elem_bottom: 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 available_bottom = other_top - elem_bottom - min_gap
max_bottom = min(max_bottom, available_bottom) max_bottom = min(max_bottom, available_bottom)

View File

@ -140,14 +140,25 @@ class AssetHealDialog(QDialog):
return return
healed_count = 0 healed_count = 0
imported_count = 0
still_missing = [] still_missing = []
# Update asset resolution context with search paths # Update asset resolution context with search paths
set_asset_resolution_context(self.project.folder_path, self.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: for asset_path in self.missing_assets:
found = False found_path = None
filename = os.path.basename(asset_path) filename = os.path.basename(asset_path)
# Search in each search path # Search in each search path
@ -155,25 +166,53 @@ class AssetHealDialog(QDialog):
# Try direct match # Try direct match
candidate = os.path.join(search_path, filename) candidate = os.path.join(search_path, filename)
if os.path.exists(candidate): if os.path.exists(candidate):
found = True found_path = candidate
healed_count += 1
print(f"Healed: {asset_path}{candidate}")
break break
# Try with same relative path # Try with same relative path
candidate = os.path.join(search_path, asset_path) candidate = os.path.join(search_path, asset_path)
if os.path.exists(candidate): if os.path.exists(candidate):
found = True found_path = candidate
healed_count += 1
print(f"Healed: {asset_path}{candidate}")
break 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) still_missing.append(asset_path)
# Report results # Report results
message = f"Healing complete!\n\n" 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)}" message += f"Still missing: {len(still_missing)}"
if still_missing: if still_missing:

View File

@ -57,26 +57,34 @@ class AssetDropMixin:
target_element = self._get_element_at(x, y) target_element = self._get_element_at(x, y)
if target_element and isinstance(target_element, (ImageData, PlaceholderData)): if target_element and isinstance(target_element, (ImageData, PlaceholderData)):
if isinstance(target_element, PlaceholderData): main_window = self.window()
new_image = ImageData( if hasattr(main_window, 'project') and main_window.project:
image_path=image_path, try:
x=target_element.position[0], # Import the asset to the project's assets folder
y=target_element.position[1], asset_path = main_window.project.asset_manager.import_asset(image_path)
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
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: else:
try: try:
from PIL import Image from PIL import Image

View File

@ -68,7 +68,8 @@ class ElementManipulationMixin:
dy=dy, dy=dy,
resize_handle=self.resize_handle, resize_handle=self.resize_handle,
page_size=page_size, page_size=page_size,
dpi=dpi dpi=dpi,
project=main_window.project
) )
# Apply the snapped values # Apply the snapped values

View File

@ -197,7 +197,8 @@ class MouseInteractionMixin:
position=(new_x, new_y), position=(new_x, new_y),
size=self.selected_element.size, size=self.selected_element.size,
page_size=page_size, page_size=page_size,
dpi=dpi dpi=dpi,
project=main_window.project
) )
self.selected_element.position = snapped_pos self.selected_element.position = snapped_pos

View File

@ -80,14 +80,12 @@ class ViewOperationsMixin:
) )
def toggle_grid_snap(self): def toggle_grid_snap(self):
"""Toggle grid snapping""" """Toggle grid snapping"""
current_page = self.get_current_page() if not self.project:
if not current_page:
return return
snap_sys = current_page.layout.snapping_system self.project.snap_to_grid = not self.project.snap_to_grid
snap_sys.snap_to_grid = not snap_sys.snap_to_grid
status = "enabled" if self.project.snap_to_grid else "disabled"
status = "enabled" if snap_sys.snap_to_grid else "disabled"
self.update_view() self.update_view()
self.show_status(f"Grid snapping {status}", 2000) self.show_status(f"Grid snapping {status}", 2000)
print(f"Grid snapping {status}") print(f"Grid snapping {status}")
@ -100,14 +98,12 @@ class ViewOperationsMixin:
) )
def toggle_edge_snap(self): def toggle_edge_snap(self):
"""Toggle edge snapping""" """Toggle edge snapping"""
current_page = self.get_current_page() if not self.project:
if not current_page:
return return
snap_sys = current_page.layout.snapping_system self.project.snap_to_edges = not self.project.snap_to_edges
snap_sys.snap_to_edges = not snap_sys.snap_to_edges
status = "enabled" if self.project.snap_to_edges else "disabled"
status = "enabled" if snap_sys.snap_to_edges else "disabled"
self.update_view() self.update_view()
self.show_status(f"Edge snapping {status}", 2000) self.show_status(f"Edge snapping {status}", 2000)
print(f"Edge snapping {status}") print(f"Edge snapping {status}")
@ -120,36 +116,51 @@ class ViewOperationsMixin:
) )
def toggle_guide_snap(self): def toggle_guide_snap(self):
"""Toggle guide snapping""" """Toggle guide snapping"""
current_page = self.get_current_page() if not self.project:
if not current_page:
return return
snap_sys = current_page.layout.snapping_system self.project.snap_to_guides = not self.project.snap_to_guides
snap_sys.snap_to_guides = not snap_sys.snap_to_guides
status = "enabled" if self.project.snap_to_guides else "disabled"
status = "enabled" if snap_sys.snap_to_guides else "disabled"
self.update_view() self.update_view()
self.show_status(f"Guide snapping {status}", 2000) self.show_status(f"Guide snapping {status}", 2000)
print(f"Guide snapping {status}") print(f"Guide snapping {status}")
@ribbon_action( @ribbon_action(
label="Toggle Snap Lines", label="Show Grid",
tooltip="Toggle visibility of snap lines", 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", tab="View",
group="Snapping" group="Snapping"
) )
def toggle_snap_lines(self): def toggle_snap_lines(self):
"""Toggle snap lines visibility""" """Toggle guide lines visibility"""
current_page = self.get_current_page() if not self.project:
if not current_page:
return return
current_page.layout.show_snap_lines = not current_page.layout.show_snap_lines self.project.show_snap_lines = not self.project.show_snap_lines
status = "visible" if current_page.layout.show_snap_lines else "hidden" status = "visible" if self.project.show_snap_lines else "hidden"
self.update_view() self.update_view()
self.show_status(f"Snap lines {status}", 2000) self.show_status(f"Guides {status}", 2000)
print(f"Snap lines {status}") print(f"Guides {status}")
@ribbon_action( @ribbon_action(
label="Add H Guide", label="Add H Guide",
@ -219,48 +230,45 @@ class ViewOperationsMixin:
def set_grid_size(self): def set_grid_size(self):
"""Open dialog to set grid size""" """Open dialog to set grid size"""
from PyQt6.QtWidgets import QDialog, QVBoxLayout, QHBoxLayout, QLabel, QDoubleSpinBox, QPushButton from PyQt6.QtWidgets import QDialog, QVBoxLayout, QHBoxLayout, QLabel, QDoubleSpinBox, QPushButton
current_page = self.get_current_page() if not self.project:
if not current_page:
return return
snap_sys = current_page.layout.snapping_system
# Create dialog # Create dialog
dialog = QDialog(self) dialog = QDialog(self)
dialog.setWindowTitle("Grid Settings") dialog.setWindowTitle("Grid Settings")
dialog.setMinimumWidth(300) dialog.setMinimumWidth(300)
layout = QVBoxLayout() layout = QVBoxLayout()
# Grid size setting # Grid size setting
size_layout = QHBoxLayout() size_layout = QHBoxLayout()
size_layout.addWidget(QLabel("Grid Size:")) size_layout.addWidget(QLabel("Grid Size:"))
size_spinbox = QDoubleSpinBox() size_spinbox = QDoubleSpinBox()
size_spinbox.setRange(1.0, 100.0) 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.setSuffix(" mm")
size_spinbox.setDecimals(1) size_spinbox.setDecimals(1)
size_spinbox.setSingleStep(1.0) size_spinbox.setSingleStep(1.0)
size_layout.addWidget(size_spinbox) size_layout.addWidget(size_spinbox)
layout.addLayout(size_layout) layout.addLayout(size_layout)
# Snap threshold setting # Snap threshold setting
threshold_layout = QHBoxLayout() threshold_layout = QHBoxLayout()
threshold_layout.addWidget(QLabel("Snap Threshold:")) threshold_layout.addWidget(QLabel("Snap Threshold:"))
threshold_spinbox = QDoubleSpinBox() threshold_spinbox = QDoubleSpinBox()
threshold_spinbox.setRange(0.5, 20.0) 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.setSuffix(" mm")
threshold_spinbox.setDecimals(1) threshold_spinbox.setDecimals(1)
threshold_spinbox.setSingleStep(0.5) threshold_spinbox.setSingleStep(0.5)
threshold_layout.addWidget(threshold_spinbox) threshold_layout.addWidget(threshold_spinbox)
layout.addLayout(threshold_layout) layout.addLayout(threshold_layout)
# Buttons # Buttons
button_layout = QHBoxLayout() button_layout = QHBoxLayout()
cancel_btn = QPushButton("Cancel") cancel_btn = QPushButton("Cancel")
@ -268,22 +276,22 @@ class ViewOperationsMixin:
ok_btn = QPushButton("OK") ok_btn = QPushButton("OK")
ok_btn.clicked.connect(dialog.accept) ok_btn.clicked.connect(dialog.accept)
ok_btn.setDefault(True) ok_btn.setDefault(True)
button_layout.addStretch() button_layout.addStretch()
button_layout.addWidget(cancel_btn) button_layout.addWidget(cancel_btn)
button_layout.addWidget(ok_btn) button_layout.addWidget(ok_btn)
layout.addLayout(button_layout) layout.addLayout(button_layout)
dialog.setLayout(layout) dialog.setLayout(layout)
# Show dialog and apply if accepted # Show dialog and apply if accepted
if dialog.exec() == QDialog.DialogCode.Accepted: if dialog.exec() == QDialog.DialogCode.Accepted:
new_grid_size = size_spinbox.value() new_grid_size = size_spinbox.value()
new_threshold = threshold_spinbox.value() new_threshold = threshold_spinbox.value()
snap_sys.grid_size_mm = new_grid_size self.project.grid_size_mm = new_grid_size
snap_sys.snap_threshold_mm = new_threshold self.project.snap_threshold_mm = new_threshold
self.update_view() self.update_view()
self.show_status(f"Grid size: {new_grid_size:.1f}mm, Threshold: {new_threshold:.1f}mm", 2000) 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") print(f"Updated grid settings - Size: {new_grid_size:.1f}mm, Threshold: {new_threshold:.1f}mm")
@ -325,12 +333,22 @@ class ViewOperationsMixin:
@ribbon_action( @ribbon_action(
label="Show Grid", 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", tab="Layout",
group="Snapping" group="Snapping"
) )
def layout_toggle_snap_lines(self): def layout_toggle_snap_lines(self):
"""Toggle snap lines visibility (Layout tab)""" """Toggle guide lines visibility (Layout tab)"""
self.toggle_snap_lines() self.toggle_snap_lines()
@ribbon_action( @ribbon_action(

View File

@ -69,7 +69,7 @@ class RenderingMixin:
renderer.begin_render() renderer.begin_render()
# Pass widget reference for async loading # Pass widget reference for async loading
page.layout._parent_widget = self page.layout._parent_widget = self
page.layout.render(dpi=dpi) page.layout.render(dpi=dpi, project=main_window.project)
renderer.end_render() renderer.end_render()
elif page_type == 'ghost': elif page_type == 'ghost':

View File

@ -39,15 +39,16 @@ class PageLayout:
"""Set a grid layout for the page""" """Set a grid layout for the page"""
self.grid_layout = grid 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. Render all elements on the page in page-local coordinates.
Note: This method assumes OpenGL transformations have already been set up Note: This method assumes OpenGL transformations have already been set up
by PageRenderer.begin_render(). All coordinates here are in page-local space. by PageRenderer.begin_render(). All coordinates here are in page-local space.
Args: Args:
dpi: Working DPI for converting mm to pixels 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 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() glEnd()
glLineWidth(1.0) glLineWidth(1.0)
# Always render snap lines (grid shows when snap_to_grid is on, guides show when show_snap_lines is on) # 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) self._render_snap_lines(dpi, page_x, page_y, project)
# Re-enable depth testing # Re-enable depth testing
glEnable(GL_DEPTH_TEST) 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)""" """Render snap lines (grid, edges, guides)"""
from OpenGL.GL import (glColor3f, glColor4f, glLineWidth, glBegin, glEnd, from OpenGL.GL import (glColor3f, glColor4f, glLineWidth, glBegin, glEnd,
glVertex2f, GL_LINES, glEnable, glDisable, GL_BLEND, glVertex2f, GL_LINES, glEnable, glDisable, GL_BLEND,
glBlendFunc, GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) 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 # Enable alpha blending for transparency
glEnable(GL_BLEND) glEnable(GL_BLEND)
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
# Draw grid lines (darker gray with transparency) - always visible when snap_to_grid is enabled # Draw grid lines (darker gray with transparency) - visible when show_grid is enabled
if self.snapping_system.snap_to_grid and snap_lines['grid']: if show_grid and snap_lines['grid']:
glColor4f(0.6, 0.6, 0.6, 0.4) # Gray with 40% opacity glColor4f(0.6, 0.6, 0.6, 0.4) # Gray with 40% opacity
glLineWidth(1.0) glLineWidth(1.0)
for orientation, position in snap_lines['grid']: for orientation, position in snap_lines['grid']:
@ -165,9 +195,9 @@ class PageLayout:
glVertex2f(page_x, page_y + position) glVertex2f(page_x, page_y + position)
glVertex2f(page_x + self.size[0] * dpi / 25.4, page_y + position) glVertex2f(page_x + self.size[0] * dpi / 25.4, page_y + position)
glEnd() glEnd()
# Draw guides (cyan, more visible with transparency) - only show when show_snap_lines is on # 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 glColor4f(0.0, 0.7, 0.9, 0.8) # Cyan with 80% opacity
glLineWidth(1.5) glLineWidth(1.5)
for orientation, position in snap_lines['guides']: for orientation, position in snap_lines['guides']:
@ -179,7 +209,7 @@ class PageLayout:
glVertex2f(page_x, page_y + position) glVertex2f(page_x, page_y + position)
glVertex2f(page_x + self.size[0] * dpi / 25.4, page_y + position) glVertex2f(page_x + self.size[0] * dpi / 25.4, page_y + position)
glEnd() glEnd()
glLineWidth(1.0) glLineWidth(1.0)
glDisable(GL_BLEND) glDisable(GL_BLEND)

View File

@ -123,6 +123,15 @@ class Project:
# Using TemporaryDirectory instance that auto-cleans on deletion # Using TemporaryDirectory instance that auto-cleans on deletion
self._temp_dir = None 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 # Initialize asset manager
self.asset_manager = AssetManager(self.folder_path) self.asset_manager = AssetManager(self.folder_path)
@ -317,6 +326,13 @@ class Project:
"cover_bleed_mm": self.cover_bleed_mm, "cover_bleed_mm": self.cover_bleed_mm,
"binding_type": self.binding_type, "binding_type": self.binding_type,
"embedded_templates": self.embedded_templates, "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], "pages": [page.serialize() for page in self.pages],
"history": self.history.serialize(), "history": self.history.serialize(),
"asset_manager": self.asset_manager.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.paper_thickness_mm = data.get("paper_thickness_mm", 0.2)
self.cover_bleed_mm = data.get("cover_bleed_mm", 0.0) self.cover_bleed_mm = data.get("cover_bleed_mm", 0.0)
self.binding_type = data.get("binding_type", "saddle_stitch") self.binding_type = data.get("binding_type", "saddle_stitch")
# Deserialize embedded templates # Deserialize embedded templates
self.embedded_templates = data.get("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 = [] self.pages = []

View File

@ -22,6 +22,56 @@ from pyPhotoAlbum.version_manager import (
SERIALIZATION_VERSION = CURRENT_DATA_VERSION 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): def _normalize_asset_paths(project: Project, project_folder: str):
""" """
Normalize asset paths in a loaded project to be relative to the project folder. 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]]: def save_to_zip(project: Project, zip_path: str) -> Tuple[bool, Optional[str]]:
""" """
Save a project to a ZIP file, including all assets. Save a project to a ZIP file, including all assets.
Args: Args:
project: The Project instance to save project: The Project instance to save
zip_path: Path where the ZIP file should be created zip_path: Path where the ZIP file should be created
Returns: Returns:
Tuple of (success: bool, error_message: Optional[str]) 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 # Ensure .ppz extension
if not zip_path.lower().endswith('.ppz'): if not zip_path.lower().endswith('.ppz'):
zip_path += '.ppz' zip_path += '.ppz'
# Check for and import any external images before saving
_import_external_images(project)
# Serialize project to dictionary # Serialize project to dictionary
project_data = project.serialize() project_data = project.serialize()
@ -98,7 +151,7 @@ def save_to_zip(project: Project, zip_path: str) -> Tuple[bool, Optional[str]]:
# Write project.json # Write project.json
project_json = json.dumps(project_data, indent=2) project_json = json.dumps(project_data, indent=2)
zipf.writestr('project.json', project_json) zipf.writestr('project.json', project_json)
# Add all files from the assets folder # Add all files from the assets folder
assets_folder = project.asset_manager.assets_folder assets_folder = project.asset_manager.assets_folder
if os.path.exists(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 # Store with relative path from project folder
arcname = os.path.relpath(file_path, project.folder_path) arcname = os.path.relpath(file_path, project.folder_path)
zipf.write(file_path, arcname) zipf.write(file_path, arcname)
print(f"Project saved to {zip_path}") print(f"Project saved to {zip_path}")
return True, None return True, None
except Exception as e: except Exception as e:
error_msg = f"Error saving project: {str(e)}" error_msg = f"Error saving project: {str(e)}"
print(error_msg) print(error_msg)

View File

@ -61,40 +61,56 @@ class SnappingSystem:
"""Remove all guides""" """Remove all guides"""
self.guides.clear() self.guides.clear()
def snap_position(self, def snap_position(self,
position: Tuple[float, float], position: Tuple[float, float],
size: Tuple[float, float], size: Tuple[float, float],
page_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 Apply snapping to a position using combined distance threshold
Args: Args:
position: Current position (x, y) in pixels position: Current position (x, y) in pixels
size: Element size (width, height) in pixels size: Element size (width, height) in pixels
page_size: Page size (width, height) in mm page_size: Page size (width, height) in mm
dpi: DPI for conversion dpi: DPI for conversion
project: Optional project for global snapping settings
Returns: Returns:
Snapped position (x, y) in pixels Snapped position (x, y) in pixels
""" """
import math import math
x, y = position x, y = position
width, height = size width, height = size
page_width_mm, page_height_mm = page_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 # 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 # Collect all potential snap points for both edges of the element
snap_points = [] snap_points = []
# 1. Page edge 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_width_px = page_width_mm * dpi / 25.4
page_height_px = page_height_mm * dpi / 25.4 page_height_px = page_height_mm * dpi / 25.4
# Corners where element's top-left can snap # Corners where element's top-left can snap
snap_points.extend([ snap_points.extend([
(0, 0), # Top-left corner (0, 0), # Top-left corner
@ -102,7 +118,7 @@ class SnappingSystem:
(0, page_height_px - height), # Bottom-left corner (0, page_height_px - height), # Bottom-left corner
(page_width_px - width, page_height_px - height), # Bottom-right corner (page_width_px - width, page_height_px - height), # Bottom-right corner
]) ])
# Edge positions (element aligned to edge on one axis) # Edge positions (element aligned to edge on one axis)
snap_points.extend([ snap_points.extend([
(0, y), # Left edge (0, y), # Left edge
@ -110,10 +126,10 @@ class SnappingSystem:
(x, 0), # Top edge (x, 0), # Top edge
(x, page_height_px - height), # Bottom edge (x, page_height_px - height), # Bottom edge
]) ])
# 2. Grid snap points # 2. Grid snap points
if self.snap_to_grid: if snap_to_grid:
grid_size_px = self.grid_size_mm * dpi / 25.4 grid_size_px = grid_size_mm * dpi / 25.4
page_width_px = page_width_mm * dpi / 25.4 page_width_px = page_width_mm * dpi / 25.4
page_height_px = page_height_mm * dpi / 25.4 page_height_px = page_height_mm * dpi / 25.4
@ -137,7 +153,7 @@ class SnappingSystem:
grid_x += grid_size_px grid_x += grid_size_px
# 3. Guide snap points # 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'] 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'] horizontal_guides = [g.position * dpi / 25.4 for g in self.guides if g.orientation == 'horizontal']
@ -173,10 +189,11 @@ class SnappingSystem:
dy: float, dy: float,
resize_handle: str, resize_handle: str,
page_size: Tuple[float, float], 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 Apply snapping during resize operations
Args: Args:
position: Current position (x, y) in pixels position: Current position (x, y) in pixels
size: Current size (width, height) 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') resize_handle: Which handle is being dragged ('nw', 'ne', 'sw', 'se', 'n', 's', 'e', 'w')
page_size: Page size (width, height) in mm page_size: Page size (width, height) in mm
dpi: DPI for conversion dpi: DPI for conversion
project: Optional project for global snapping settings
Returns: Returns:
Tuple of (snapped_position, snapped_size) in pixels Tuple of (snapped_position, snapped_size) in pixels
""" """
x, y = position x, y = position
width, height = size width, height = size
page_width_mm, page_height_mm = page_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 # 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 # Calculate new position and size based on resize handle
new_x, new_y = x, y new_x, new_y = x, y
@ -226,44 +250,44 @@ class SnappingSystem:
if resize_handle in ['nw', 'w', 'sw']: if resize_handle in ['nw', 'w', 'sw']:
# Try to snap the left edge # Try to snap the left edge
snapped_left = self._snap_edge_to_targets( 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: if snapped_left is not None:
# Adjust width to compensate for position change # Adjust width to compensate for position change
width_adjustment = new_x - snapped_left width_adjustment = new_x - snapped_left
new_x = snapped_left new_x = snapped_left
new_width += width_adjustment new_width += width_adjustment
# Snap right edge (for ne, e, se handles) # Snap right edge (for ne, e, se handles)
if resize_handle in ['ne', 'e', 'se']: if resize_handle in ['ne', 'e', 'se']:
# Calculate right edge position # Calculate right edge position
right_edge = new_x + new_width right_edge = new_x + new_width
# Try to snap the right edge # Try to snap the right edge
snapped_right = self._snap_edge_to_targets( 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: if snapped_right is not None:
new_width = snapped_right - new_x new_width = snapped_right - new_x
# Snap top edge (for nw, n, ne handles) # Snap top edge (for nw, n, ne handles)
if resize_handle in ['nw', 'n', 'ne']: if resize_handle in ['nw', 'n', 'ne']:
# Try to snap the top edge # Try to snap the top edge
snapped_top = self._snap_edge_to_targets( 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: if snapped_top is not None:
# Adjust height to compensate for position change # Adjust height to compensate for position change
height_adjustment = new_y - snapped_top height_adjustment = new_y - snapped_top
new_y = snapped_top new_y = snapped_top
new_height += height_adjustment new_height += height_adjustment
# Snap bottom edge (for sw, s, se handles) # Snap bottom edge (for sw, s, se handles)
if resize_handle in ['sw', 's', 'se']: if resize_handle in ['sw', 's', 'se']:
# Calculate bottom edge position # Calculate bottom edge position
bottom_edge = new_y + new_height bottom_edge = new_y + new_height
# Try to snap the bottom edge # Try to snap the bottom edge
snapped_bottom = self._snap_edge_to_targets( 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: if snapped_bottom is not None:
new_height = snapped_bottom - new_y new_height = snapped_bottom - new_y
@ -280,41 +304,55 @@ class SnappingSystem:
page_size_mm: float, page_size_mm: float,
dpi: int, dpi: int,
snap_threshold_px: float, snap_threshold_px: float,
orientation: str) -> Optional[float]: orientation: str,
project=None) -> Optional[float]:
""" """
Snap an edge position to available targets (grid, edges, guides) Snap an edge position to available targets (grid, edges, guides)
Args: Args:
edge_position: Current edge position in pixels edge_position: Current edge position in pixels
page_size_mm: Page size along axis in mm page_size_mm: Page size along axis in mm
dpi: DPI for conversion dpi: DPI for conversion
snap_threshold_px: Snap threshold in pixels snap_threshold_px: Snap threshold in pixels
orientation: 'vertical' for x-axis, 'horizontal' for y-axis orientation: 'vertical' for x-axis, 'horizontal' for y-axis
project: Optional project for global snapping settings
Returns: Returns:
Snapped edge position in pixels, or None if no snap 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 = [] snap_candidates = []
# 1. Page edge snapping # 1. Page edge snapping
if self.snap_to_edges: if snap_to_edges:
# Snap to start edge (0) # Snap to start edge (0)
snap_candidates.append((0, abs(edge_position - 0))) snap_candidates.append((0, abs(edge_position - 0)))
# Snap to end edge # Snap to end edge
page_size_px = page_size_mm * dpi / 25.4 page_size_px = page_size_mm * dpi / 25.4
snap_candidates.append((page_size_px, abs(edge_position - page_size_px))) snap_candidates.append((page_size_px, abs(edge_position - page_size_px)))
# 2. Grid snapping # 2. Grid snapping
if self.snap_to_grid: if snap_to_grid:
grid_size_px = self.grid_size_mm * dpi / 25.4 grid_size_px = grid_size_mm * dpi / 25.4
# Snap to nearest grid line # Snap to nearest grid line
nearest_grid = round(edge_position / grid_size_px) * grid_size_px nearest_grid = round(edge_position / grid_size_px) * grid_size_px
snap_candidates.append((nearest_grid, abs(edge_position - nearest_grid))) snap_candidates.append((nearest_grid, abs(edge_position - nearest_grid)))
# 3. Guide snapping # 3. Guide snapping
if self.snap_to_guides: if snap_to_guides:
for guide in self.guides: for guide in self.guides:
if guide.orientation == orientation: if guide.orientation == orientation:
guide_pos_px = guide.position * dpi / 25.4 guide_pos_px = guide.position * dpi / 25.4

50
test_drop_bug.py Normal file
View File

@ -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()

155
test_heal_function.py Executable file
View File

@ -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)

139
test_zip_embedding.py Executable file
View File

@ -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)