Improved snapping. Fixed bug in content embedding.
This commit is contained in:
parent
e972fb864e
commit
eca6d43e6a
@ -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)
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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':
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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 = []
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
50
test_drop_bug.py
Normal 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
155
test_heal_function.py
Executable 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
139
test_zip_embedding.py
Executable 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)
|
||||
Loading…
x
Reference in New Issue
Block a user