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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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':

View File

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

View File

@ -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 = []

View File

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

View File

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

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)