Additional refactoring
Some checks failed
Python CI / test (push) Successful in 1m17s
Lint / lint (push) Successful in 1m32s
Tests / test (3.10) (push) Successful in 1m10s
Tests / test (3.9) (push) Has been cancelled
Tests / test (3.11) (push) Has been cancelled

This commit is contained in:
Duncan Tourolle 2025-11-27 21:57:57 +01:00
parent d7786ede80
commit fae9e5bd2b
27 changed files with 472 additions and 204 deletions

View File

@ -3,6 +3,7 @@ Asset healing dialog for reconnecting missing images
""" """
import os import os
import shutil
from typing import List, Dict, Set from typing import List, Dict, Set
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
@ -207,7 +208,6 @@ class AssetHealDialog(QDialog):
# Copy it to the correct location # Copy it to the correct location
dest_path = os.path.join(self.project.folder_path, asset_path) dest_path = os.path.join(self.project.folder_path, asset_path)
os.makedirs(os.path.dirname(dest_path), exist_ok=True) os.makedirs(os.path.dirname(dest_path), exist_ok=True)
import shutil
shutil.copy2(found_path, dest_path) shutil.copy2(found_path, dest_path)
print(f"Restored: {asset_path} from {found_path}") print(f"Restored: {asset_path} from {found_path}")
else: else:

View File

@ -21,6 +21,8 @@ import threading
from PIL import Image from PIL import Image
from PyQt6.QtCore import QObject, pyqtSignal from PyQt6.QtCore import QObject, pyqtSignal
from pyPhotoAlbum.image_utils import convert_to_rgba, resize_to_fit
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -388,23 +390,16 @@ class AsyncImageLoader(QObject):
Returns: Returns:
Processed PIL Image Processed PIL Image
""" """
# Load image
img = Image.open(path) img = Image.open(path)
img = convert_to_rgba(img)
# Convert to RGBA for consistency
if img.mode != 'RGBA':
img = img.convert('RGBA')
# Downsample if target size specified (preserving aspect ratio) # Downsample if target size specified (preserving aspect ratio)
if target_size: if target_size:
max_size = target_size[0] # Assume square target (2048, 2048) max_size = target_size[0] # Assume square target (2048, 2048)
if img.width > max_size or img.height > max_size: original_size = img.size
# Calculate scale to fit within max_size while preserving aspect ratio img = resize_to_fit(img, max_size)
scale = min(max_size / img.width, max_size / img.height) if img.size != original_size:
new_width = int(img.width * scale) logger.debug(f"Downsampled {path}: {original_size} -> {img.size}")
new_height = int(img.height * scale)
img = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
logger.debug(f"Downsampled {path}: {img.size} -> ({new_width}, {new_height})")
return img return img
@ -721,8 +716,7 @@ class AsyncPDFGenerator(QObject):
# Load and cache (unrotated - rotation is applied per-element) # Load and cache (unrotated - rotation is applied per-element)
img = original_open(path, *args, **kwargs) img = original_open(path, *args, **kwargs)
if img.mode != 'RGBA': img = convert_to_rgba(img)
img = img.convert('RGBA')
self.image_cache.put(Path(path), img) self.image_cache.put(Path(path), img)
return img return img

View File

@ -9,7 +9,7 @@ import os
import json import json
import shutil import shutil
from pathlib import Path from pathlib import Path
from datetime import datetime from datetime import datetime, timedelta
from typing import Optional, List, Tuple from typing import Optional, List, Tuple
from pyPhotoAlbum.project_serializer import save_to_zip, load_from_zip from pyPhotoAlbum.project_serializer import save_to_zip, load_from_zip
@ -185,8 +185,6 @@ class AutosaveManager:
max_age_hours: Maximum age in hours (default: 7 days) max_age_hours: Maximum age in hours (default: 7 days)
max_count: Maximum number of checkpoints to keep per project max_count: Maximum number of checkpoints to keep per project
""" """
from datetime import timedelta
now = datetime.now() now = datetime.now()
checkpoints_by_project = {} checkpoints_by_project = {}

View File

@ -2,6 +2,7 @@
Command pattern implementation for undo/redo functionality Command pattern implementation for undo/redo functionality
""" """
import os
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Dict, Any, List, Optional from typing import Dict, Any, List, Optional
from pyPhotoAlbum.models import BaseLayoutElement, ImageData, PlaceholderData, TextBoxData from pyPhotoAlbum.models import BaseLayoutElement, ImageData, PlaceholderData, TextBoxData
@ -21,7 +22,6 @@ def _normalize_asset_path(image_path: str, asset_manager) -> str:
if not asset_manager or not image_path: if not asset_manager or not image_path:
return image_path return image_path
import os
if os.path.isabs(image_path): if os.path.isabs(image_path):
return os.path.relpath(image_path, asset_manager.project_folder) return os.path.relpath(image_path, asset_manager.project_folder)
return image_path return image_path
@ -742,7 +742,6 @@ class CommandHistory:
if isinstance(command, (AddElementCommand, DeleteElementCommand)): if isinstance(command, (AddElementCommand, DeleteElementCommand)):
if isinstance(command.element, ImageData) and command.element.image_path: if isinstance(command.element, ImageData) and command.element.image_path:
# Convert absolute path to relative for asset manager # Convert absolute path to relative for asset manager
import os
asset_path = command.element.image_path asset_path = command.element.image_path
if os.path.isabs(asset_path): if os.path.isabs(asset_path):
asset_path = os.path.relpath(asset_path, self.asset_manager.project_folder) asset_path = os.path.relpath(asset_path, self.asset_manager.project_folder)

View File

@ -2,6 +2,7 @@
Decorator system for pyPhotoAlbum ribbon UI Decorator system for pyPhotoAlbum ribbon UI
""" """
import copy
from functools import wraps from functools import wraps
from typing import Optional, Callable from typing import Optional, Callable
@ -280,7 +281,6 @@ class UndoableOperation:
current_page = instance.get_current_page() if hasattr(instance, 'get_current_page') else None current_page = instance.get_current_page() if hasattr(instance, 'get_current_page') else None
if current_page: if current_page:
# Deep copy elements # Deep copy elements
import copy
return [copy.deepcopy(elem.serialize()) for elem in current_page.layout.elements] return [copy.deepcopy(elem.serialize()) for elem in current_page.layout.elements]
return [] return []

View File

@ -5,6 +5,7 @@ Encapsulates all UI logic for page setup configuration,
separating presentation from business logic. separating presentation from business logic.
""" """
import math
from typing import Optional, Dict, Any from typing import Optional, Dict, Any
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QDialog, QVBoxLayout, QHBoxLayout, QLabel,
@ -286,7 +287,6 @@ class PageSetupDialog(QDialog):
content_pages = sum( content_pages = sum(
p.get_page_count() for p in self.project.pages if not p.is_cover p.get_page_count() for p in self.project.pages if not p.is_cover
) )
import math
sheets = math.ceil(content_pages / 4) sheets = math.ceil(content_pages / 4)
spine_width = sheets * self.thickness_spinbox.value() * 2 spine_width = sheets * self.thickness_spinbox.value() * 2

115
pyPhotoAlbum/gl_imports.py Normal file
View File

@ -0,0 +1,115 @@
"""
Centralized OpenGL imports for pyPhotoAlbum.
Provides a single point of import for all OpenGL functions used throughout
the application. This centralizes GL dependency management and provides
graceful handling when OpenGL is not available (e.g., during testing).
Usage:
from pyPhotoAlbum.gl_imports import glBegin, glEnd, GL_QUADS, GL_AVAILABLE
if GL_AVAILABLE:
# Safe to use GL functions
glBegin(GL_QUADS)
...
"""
try:
from OpenGL.GL import (
# Drawing primitives
glBegin,
glEnd,
glVertex2f,
GL_QUADS,
GL_LINE_LOOP,
GL_LINES,
GL_TRIANGLE_FAN,
# Colors
glColor3f,
glColor4f,
# Line state
glLineWidth,
glLineStipple,
GL_LINE_STIPPLE,
# General state
glEnable,
glDisable,
GL_DEPTH_TEST,
GL_BLEND,
GL_SRC_ALPHA,
GL_ONE_MINUS_SRC_ALPHA,
glBlendFunc,
# Textures
glGenTextures,
glBindTexture,
glTexImage2D,
glTexParameteri,
glDeleteTextures,
GL_TEXTURE_2D,
GL_RGBA,
GL_UNSIGNED_BYTE,
GL_TEXTURE_MIN_FILTER,
GL_TEXTURE_MAG_FILTER,
GL_LINEAR,
glTexCoord2f,
# Matrix operations
glPushMatrix,
glPopMatrix,
glScalef,
glTranslatef,
glLoadIdentity,
glRotatef,
# Clear operations
glClear,
glClearColor,
GL_COLOR_BUFFER_BIT,
GL_DEPTH_BUFFER_BIT,
# Viewport
glViewport,
glMatrixMode,
glOrtho,
GL_PROJECTION,
GL_MODELVIEW,
# Info/debug
glGetString,
GL_VERSION,
)
GL_AVAILABLE = True
except ImportError:
GL_AVAILABLE = False
# Define dummy functions/constants for when OpenGL is not available
# This allows the code to be imported without OpenGL for testing
def _gl_stub(*args, **kwargs):
pass
glBegin = glEnd = glVertex2f = _gl_stub
glColor3f = glColor4f = _gl_stub
glLineWidth = glLineStipple = _gl_stub
glEnable = glDisable = glBlendFunc = _gl_stub
glGenTextures = glBindTexture = glTexImage2D = _gl_stub
glTexParameteri = glDeleteTextures = glTexCoord2f = _gl_stub
glPushMatrix = glPopMatrix = glScalef = glTranslatef = _gl_stub
glLoadIdentity = glRotatef = _gl_stub
glClear = glClearColor = _gl_stub
glViewport = glMatrixMode = glOrtho = _gl_stub
glGetString = _gl_stub
# Constants
GL_QUADS = GL_LINE_LOOP = GL_LINES = GL_TRIANGLE_FAN = 0
GL_LINE_STIPPLE = GL_DEPTH_TEST = GL_BLEND = 0
GL_SRC_ALPHA = GL_ONE_MINUS_SRC_ALPHA = 0
GL_TEXTURE_2D = GL_RGBA = GL_UNSIGNED_BYTE = 0
GL_TEXTURE_MIN_FILTER = GL_TEXTURE_MAG_FILTER = GL_LINEAR = 0
GL_COLOR_BUFFER_BIT = GL_DEPTH_BUFFER_BIT = 0
GL_PROJECTION = GL_MODELVIEW = 0
GL_VERSION = 0

View File

@ -4,11 +4,12 @@ OpenGL widget for pyPhotoAlbum rendering - refactored with mixins
from PyQt6.QtOpenGLWidgets import QOpenGLWidget from PyQt6.QtOpenGLWidgets import QOpenGLWidget
from PyQt6.QtCore import Qt from PyQt6.QtCore import Qt
from OpenGL.GL import * from pyPhotoAlbum.gl_imports import *
# Import all mixins # Import all mixins
from pyPhotoAlbum.mixins.viewport import ViewportMixin from pyPhotoAlbum.mixins.viewport import ViewportMixin
from pyPhotoAlbum.mixins.rendering import RenderingMixin from pyPhotoAlbum.mixins.rendering import RenderingMixin
from pyPhotoAlbum.mixins.asset_path import AssetPathMixin
from pyPhotoAlbum.mixins.asset_drop import AssetDropMixin from pyPhotoAlbum.mixins.asset_drop import AssetDropMixin
from pyPhotoAlbum.mixins.page_navigation import PageNavigationMixin from pyPhotoAlbum.mixins.page_navigation import PageNavigationMixin
from pyPhotoAlbum.mixins.image_pan import ImagePanMixin from pyPhotoAlbum.mixins.image_pan import ImagePanMixin
@ -24,6 +25,7 @@ class GLWidget(
AsyncLoadingMixin, AsyncLoadingMixin,
ViewportMixin, ViewportMixin,
RenderingMixin, RenderingMixin,
AssetPathMixin,
AssetDropMixin, AssetDropMixin,
PageNavigationMixin, PageNavigationMixin,
ImagePanMixin, ImagePanMixin,
@ -72,6 +74,13 @@ class GLWidget(
self._cleanup_async_loading() self._cleanup_async_loading()
super().closeEvent(event) super().closeEvent(event)
def _get_project_folder(self):
"""Override AssetPathMixin to access project via main window."""
main_window = self.window()
if hasattr(main_window, 'project') and main_window.project:
return getattr(main_window.project, 'folder_path', None)
return None
def keyPressEvent(self, event): def keyPressEvent(self, event):
"""Handle key press events""" """Handle key press events"""
if event.key() == Qt.Key.Key_Delete or event.key() == Qt.Key.Key_Backspace: if event.key() == Qt.Key.Key_Delete or event.key() == Qt.Key.Key_Backspace:

164
pyPhotoAlbum/image_utils.py Normal file
View File

@ -0,0 +1,164 @@
"""
Centralized image processing utilities for pyPhotoAlbum.
This module consolidates common image operations to avoid code duplication
across models.py, pdf_exporter.py, and async_backend.py.
"""
from typing import Tuple
from PIL import Image
# =============================================================================
# Image Processing Utilities
# =============================================================================
def apply_pil_rotation(image: Image.Image, pil_rotation_90: int) -> Image.Image:
"""
Apply 90-degree rotation increments to a PIL image.
Args:
image: PIL Image to rotate
pil_rotation_90: Number of 90-degree rotations (0, 1, 2, or 3)
Returns:
Rotated PIL Image (or original if no rotation needed)
"""
if pil_rotation_90 <= 0:
return image
angle = pil_rotation_90 * 90
if angle == 90:
return image.transpose(Image.ROTATE_270) # CCW 90 = rotate right
elif angle == 180:
return image.transpose(Image.ROTATE_180)
elif angle == 270:
return image.transpose(Image.ROTATE_90) # CCW 270 = rotate left
return image
def convert_to_rgba(image: Image.Image) -> Image.Image:
"""
Convert image to RGBA mode if not already.
Args:
image: PIL Image in any mode
Returns:
PIL Image in RGBA mode
"""
if image.mode != 'RGBA':
return image.convert('RGBA')
return image
def calculate_center_crop_coords(
img_width: int,
img_height: int,
target_width: float,
target_height: float,
crop_info: Tuple[float, float, float, float] = (0, 0, 1, 1)
) -> Tuple[float, float, float, float]:
"""
Calculate texture/crop coordinates for center-crop fitting an image to a target aspect ratio.
This implements the center-crop algorithm used for fitting images into frames
while preserving aspect ratio. The image is scaled to cover the target area,
then the excess is cropped equally from both sides.
Args:
img_width: Source image width in pixels
img_height: Source image height in pixels
target_width: Target frame width (any unit, only ratio matters)
target_height: Target frame height (any unit, only ratio matters)
crop_info: Additional crop range as (x_min, y_min, x_max, y_max) in 0-1 range
Default (0, 0, 1, 1) means no additional cropping
Returns:
Tuple of (tx_min, ty_min, tx_max, ty_max) texture coordinates in 0-1 range
"""
crop_x_min, crop_y_min, crop_x_max, crop_y_max = crop_info
img_aspect = img_width / img_height
target_aspect = target_width / target_height
# Calculate base texture coordinates for center crop
if img_aspect > target_aspect:
# Image is wider than target - crop horizontally
scale = target_aspect / img_aspect
tx_offset = (1.0 - scale) / 2.0
tx_min_base = tx_offset
tx_max_base = 1.0 - tx_offset
ty_min_base = 0.0
ty_max_base = 1.0
else:
# Image is taller than target - crop vertically
scale = img_aspect / target_aspect
ty_offset = (1.0 - scale) / 2.0
tx_min_base = 0.0
tx_max_base = 1.0
ty_min_base = ty_offset
ty_max_base = 1.0 - ty_offset
# Apply additional crop from crop_info (for spanning elements, user crops, etc.)
tx_range = tx_max_base - tx_min_base
ty_range = ty_max_base - ty_min_base
tx_min = tx_min_base + crop_x_min * tx_range
tx_max = tx_min_base + crop_x_max * tx_range
ty_min = ty_min_base + crop_y_min * ty_range
ty_max = ty_min_base + crop_y_max * ty_range
return (tx_min, ty_min, tx_max, ty_max)
def crop_image_to_coords(
image: Image.Image,
coords: Tuple[float, float, float, float]
) -> Image.Image:
"""
Crop an image using normalized texture coordinates.
Args:
image: PIL Image to crop
coords: Tuple of (tx_min, ty_min, tx_max, ty_max) in 0-1 range
Returns:
Cropped PIL Image
"""
tx_min, ty_min, tx_max, ty_max = coords
img_width, img_height = image.size
crop_left_px = int(tx_min * img_width)
crop_right_px = int(tx_max * img_width)
crop_top_px = int(ty_min * img_height)
crop_bottom_px = int(ty_max * img_height)
return image.crop((crop_left_px, crop_top_px, crop_right_px, crop_bottom_px))
def resize_to_fit(
image: Image.Image,
max_size: int,
resample: Image.Resampling = Image.Resampling.LANCZOS
) -> Image.Image:
"""
Resize image to fit within max_size while preserving aspect ratio.
Args:
image: PIL Image to resize
max_size: Maximum dimension (width or height)
resample: Resampling filter (default LANCZOS for quality)
Returns:
Resized PIL Image, or original if already smaller
"""
if image.width <= max_size and image.height <= max_size:
return image
scale = min(max_size / image.width, max_size / image.height)
new_width = int(image.width * scale)
new_height = int(image.height * scale)
return image.resize((new_width, new_height), resample)

View File

@ -6,6 +6,7 @@ This version uses the mixin architecture with auto-generated ribbon configuratio
""" """
import sys import sys
from datetime import datetime
from pathlib import Path from pathlib import Path
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QApplication, QMainWindow, QVBoxLayout, QWidget, QApplication, QMainWindow, QVBoxLayout, QWidget,
@ -23,6 +24,7 @@ from pyPhotoAlbum.autosave_manager import AutosaveManager
# Import mixins # Import mixins
from pyPhotoAlbum.mixins.base import ApplicationStateMixin from pyPhotoAlbum.mixins.base import ApplicationStateMixin
from pyPhotoAlbum.mixins.asset_path import AssetPathMixin
from pyPhotoAlbum.mixins.operations import ( from pyPhotoAlbum.mixins.operations import (
FileOperationsMixin, FileOperationsMixin,
EditOperationsMixin, EditOperationsMixin,
@ -41,6 +43,7 @@ from pyPhotoAlbum.mixins.operations import (
class MainWindow( class MainWindow(
QMainWindow, QMainWindow,
ApplicationStateMixin, ApplicationStateMixin,
AssetPathMixin,
FileOperationsMixin, FileOperationsMixin,
EditOperationsMixin, EditOperationsMixin,
ElementOperationsMixin, ElementOperationsMixin,
@ -317,7 +320,6 @@ class MainWindow(
# Parse timestamp for better display # Parse timestamp for better display
try: try:
from datetime import datetime
timestamp = datetime.fromisoformat(timestamp_str) timestamp = datetime.fromisoformat(timestamp_str)
time_display = timestamp.strftime("%Y-%m-%d %H:%M:%S") time_display = timestamp.strftime("%Y-%m-%d %H:%M:%S")
except: except:

View File

@ -7,10 +7,11 @@ This module provides functionality for:
- Resolving conflicts based on user input or automatic strategies - Resolving conflicts based on user input or automatic strategies
""" """
import copy
from typing import Dict, Any, List, Optional, Tuple from typing import Dict, Any, List, Optional, Tuple
from enum import Enum from enum import Enum
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime, timezone
class ConflictType(Enum): class ConflictType(Enum):
@ -369,7 +370,6 @@ class MergeManager:
Merged project data Merged project data
""" """
# Start with a copy of our project # Start with a copy of our project
import copy
merged_data = copy.deepcopy(our_project_data) merged_data = copy.deepcopy(our_project_data)
# Apply resolutions # Apply resolutions
@ -529,8 +529,6 @@ def concatenate_projects(
Returns: Returns:
Combined project data Combined project data
""" """
import copy
# Start with project A as base # Start with project A as base
merged_data = copy.deepcopy(project_a_data) merged_data = copy.deepcopy(project_a_data)
@ -542,7 +540,6 @@ def concatenate_projects(
# Keep project A's ID and settings # Keep project A's ID and settings
# Update last_modified to now # Update last_modified to now
from datetime import datetime, timezone
merged_data["last_modified"] = datetime.now(timezone.utc).isoformat() merged_data["last_modified"] = datetime.now(timezone.utc).isoformat()
print(f"Concatenated projects: {len(project_a_data.get('pages', []))} + {len(project_b_data.get('pages', []))} = {len(merged_data['pages'])} pages") print(f"Concatenated projects: {len(project_a_data.get('pages', []))} + {len(project_b_data.get('pages', []))} = {len(merged_data['pages'])} pages")

View File

@ -103,7 +103,6 @@ class AssetDropMixin:
def _handle_drop_on_empty_space(self, image_path, x, y): def _handle_drop_on_empty_space(self, image_path, x, y):
"""Handle dropping an image onto empty space""" """Handle dropping an image onto empty space"""
import os
main_window = self.window() main_window = self.window()
if not (hasattr(main_window, 'project') and main_window.project and main_window.project.pages): if not (hasattr(main_window, 'project') and main_window.project and main_window.project.pages):
return return
@ -117,7 +116,7 @@ class AssetDropMixin:
try: try:
# Import asset first, then calculate dimensions from imported asset # Import asset first, then calculate dimensions from imported asset
asset_path = main_window.project.asset_manager.import_asset(image_path) asset_path = main_window.project.asset_manager.import_asset(image_path)
full_asset_path = os.path.join(main_window.project.folder_path, asset_path) full_asset_path = self.get_asset_full_path(asset_path)
img_width, img_height = self._calculate_image_dimensions(full_asset_path) img_width, img_height = self._calculate_image_dimensions(full_asset_path)
self._add_new_image_to_page( self._add_new_image_to_page(

View File

@ -0,0 +1,68 @@
"""
Asset path resolution mixin for components that need to resolve asset paths.
"""
import os
from typing import Optional
class AssetPathMixin:
"""
Mixin providing asset path resolution functionality.
Requires access to self.project (typically via ApplicationStateMixin).
"""
def resolve_asset_path(self, asset_path: str) -> Optional[str]:
"""
Resolve a relative asset path to an absolute path.
Args:
asset_path: Relative path (e.g., "assets/photo.jpg") or absolute path
Returns:
Absolute path if the asset exists, None otherwise
"""
if not asset_path:
return None
# Handle absolute paths
if os.path.isabs(asset_path):
if os.path.exists(asset_path):
return asset_path
return None
# Resolve relative path using project folder
project_folder = self._get_project_folder()
if project_folder:
full_path = os.path.join(project_folder, asset_path)
if os.path.exists(full_path):
return full_path
return None
def get_asset_full_path(self, relative_path: str) -> Optional[str]:
"""
Get the full path for a relative asset path (without existence check).
Args:
relative_path: Relative path from project folder
Returns:
Full absolute path, or None if no project folder
"""
project_folder = self._get_project_folder()
if project_folder and relative_path:
return os.path.join(project_folder, relative_path)
return None
def _get_project_folder(self) -> Optional[str]:
"""
Get the current project folder.
Override this method if the project is accessed differently.
Default implementation uses self.project.folder_path.
"""
if hasattr(self, 'project') and self.project:
return getattr(self.project, 'folder_path', None)
return None

View File

@ -2,6 +2,7 @@
Async loading mixin for non-blocking image loading and PDF generation. Async loading mixin for non-blocking image loading and PDF generation.
""" """
import os
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
import logging import logging
@ -168,33 +169,17 @@ class AsyncLoadingMixin:
if not image_data.image_path: if not image_data.image_path:
return return
# Resolve path - only load from project's assets folder # Security: only load images from the assets folder
# Search paths are only used for healing, not for async loading
from pyPhotoAlbum.models import get_asset_search_paths
import os
# Only load images that are properly in the assets folder
# Paths must be relative and start with "assets/"
if os.path.isabs(image_data.image_path):
logger.warning(f"Skipping absolute path (needs healing): {image_data.image_path}")
return
if not image_data.image_path.startswith("assets/"): if not image_data.image_path.startswith("assets/"):
logger.warning(f"Skipping path not in assets folder (needs healing): {image_data.image_path}") logger.warning(f"Skipping path not in assets folder (needs healing): {image_data.image_path}")
return return
project_folder, _ = get_asset_search_paths() # Use ImageData's path resolution (delegates to project layer)
if not project_folder: image_full_path = image_data.resolve_image_path()
logger.warning("No project folder set, cannot load image") if not image_full_path:
logger.warning(f"Image not found (needs healing): {image_data.image_path}")
return return
full_path = os.path.join(project_folder, image_data.image_path)
if not os.path.exists(full_path):
logger.warning(f"Image not found in assets (needs healing): {image_data.image_path}")
return
image_full_path = full_path
# Calculate target size (max 2048px like original) # Calculate target size (max 2048px like original)
target_size = (2048, 2048) # Will be downsampled if larger target_size = (2048, 2048) # Will be downsampled if larger

View File

@ -2,6 +2,8 @@
Mouse interaction mixin for GLWidget - coordinates all mouse events Mouse interaction mixin for GLWidget - coordinates all mouse events
""" """
import math
from PyQt6.QtCore import Qt from PyQt6.QtCore import Qt
from pyPhotoAlbum.models import ImageData from pyPhotoAlbum.models import ImageData
@ -150,8 +152,6 @@ class MouseInteractionMixin:
elif self.rotation_mode: elif self.rotation_mode:
# Rotation mode # Rotation mode
import math
if not hasattr(self.selected_element, '_page_renderer'): if not hasattr(self.selected_element, '_page_renderer'):
return return

View File

@ -39,12 +39,11 @@ class ElementOperationsMixin:
return return
try: try:
import os
# Import asset to project # Import asset to project
asset_path = self.project.asset_manager.import_asset(file_path) asset_path = self.project.asset_manager.import_asset(file_path)
# Get dimensions using centralized utility (max 300px for UI display) # Get dimensions using centralized utility (max 300px for UI display)
full_asset_path = os.path.join(self.project.folder_path, asset_path) full_asset_path = self.get_asset_full_path(asset_path)
dimensions = get_image_dimensions(full_asset_path, max_size=300) dimensions = get_image_dimensions(full_asset_path, max_size=300)
if dimensions: if dimensions:
img_width, img_height = dimensions img_width, img_height = dimensions

View File

@ -2,6 +2,8 @@
File operations mixin for pyPhotoAlbum File operations mixin for pyPhotoAlbum
""" """
import os
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QFileDialog, QDialog, QVBoxLayout, QHBoxLayout, QLabel, QFileDialog, QDialog, QVBoxLayout, QHBoxLayout, QLabel,
QDoubleSpinBox, QSpinBox, QPushButton, QGroupBox, QRadioButton, QDoubleSpinBox, QSpinBox, QPushButton, QGroupBox, QRadioButton,
@ -312,7 +314,6 @@ class FileOperationsMixin:
def _check_missing_assets(self) -> list: def _check_missing_assets(self) -> list:
"""Check for missing assets in the project - returns list of missing paths""" """Check for missing assets in the project - returns list of missing paths"""
import os
from pyPhotoAlbum.models import ImageData from pyPhotoAlbum.models import ImageData
missing = [] missing = []
@ -326,9 +327,8 @@ class FileOperationsMixin:
elif not element.image_path.startswith("assets/"): elif not element.image_path.startswith("assets/"):
missing.append(element.image_path) missing.append(element.image_path)
else: else:
# Check if file exists in assets # Check if file exists in assets using mixin
full_path = os.path.join(self.project.folder_path, element.image_path) if not self.resolve_asset_path(element.image_path):
if not os.path.exists(full_path):
missing.append(element.image_path) missing.append(element.image_path)
return list(set(missing)) # Remove duplicates return list(set(missing)) # Remove duplicates

View File

@ -2,7 +2,9 @@
Rendering mixin for GLWidget - handles OpenGL rendering Rendering mixin for GLWidget - handles OpenGL rendering
""" """
from OpenGL.GL import * import math
from pyPhotoAlbum.gl_imports import *
from PyQt6.QtGui import QPainter, QFont, QColor, QPen from PyQt6.QtGui import QPainter, QFont, QColor, QPen
from PyQt6.QtCore import Qt, QRectF from PyQt6.QtCore import Qt, QRectF
from pyPhotoAlbum.models import TextBoxData from pyPhotoAlbum.models import TextBoxData
@ -152,7 +154,6 @@ class RenderingMixin:
glLineWidth(1.0) glLineWidth(1.0)
if self.rotation_mode: if self.rotation_mode:
import math
handle_radius = 6 handle_radius = 6
handles = [(x, y), (x + w, y), (x, y + h), (x + w, y + h)] handles = [(x, y), (x + w, y), (x, y + h), (x + w, y + h)]

View File

@ -2,7 +2,7 @@
Viewport mixin for GLWidget - handles zoom and pan Viewport mixin for GLWidget - handles zoom and pan
""" """
from OpenGL.GL import * from pyPhotoAlbum.gl_imports import *
class ViewportMixin: class ViewportMixin:

View File

@ -11,6 +11,18 @@ import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from PIL import Image from PIL import Image
from pyPhotoAlbum.image_utils import apply_pil_rotation, calculate_center_crop_coords
from pyPhotoAlbum.gl_imports import (
GL_AVAILABLE, glBegin, glEnd, glVertex2f, glColor3f, glColor4f,
GL_QUADS, GL_LINE_LOOP, glEnable, glDisable, GL_TEXTURE_2D,
glBindTexture, glTexCoord2f, glTexParameteri, GL_TEXTURE_MIN_FILTER,
GL_TEXTURE_MAG_FILTER, GL_LINEAR, glGenTextures, glTexImage2D,
GL_RGBA, GL_UNSIGNED_BYTE, glDeleteTextures, glGetString, GL_VERSION,
glLineStipple, GL_LINE_STIPPLE, glPushMatrix, glPopMatrix,
glTranslatef, glRotatef, GL_BLEND, glBlendFunc, GL_SRC_ALPHA,
GL_ONE_MINUS_SRC_ALPHA,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Global configuration for asset path resolution # Global configuration for asset path resolution
@ -171,9 +183,6 @@ class ImageData(BaseLayoutElement):
def render(self): def render(self):
"""Render the image using OpenGL""" """Render the image using OpenGL"""
from OpenGL.GL import (glBegin, glEnd, glVertex2f, glColor3f, glColor4f, GL_QUADS, GL_LINE_LOOP,
glEnable, glDisable, GL_TEXTURE_2D, glBindTexture, glTexCoord2f,
glTexParameteri, GL_TEXTURE_MIN_FILTER, GL_TEXTURE_MAG_FILTER, GL_LINEAR)
x, y = self.position x, y = self.position
w, h = self.size w, h = self.size
@ -197,39 +206,10 @@ class ImageData(BaseLayoutElement):
# No dimensions available, render without aspect ratio correction # No dimensions available, render without aspect ratio correction
img_width, img_height = int(w), int(h) img_width, img_height = int(w), int(h)
# Get crop info # Calculate texture coordinates for center crop with element's crop_info
crop_x_min, crop_y_min, crop_x_max, crop_y_max = self.crop_info tx_min, ty_min, tx_max, ty_max = calculate_center_crop_coords(
img_width, img_height, w, h, self.crop_info
# Calculate aspect ratios for center crop )
img_aspect = img_width / img_height
target_aspect = w / h
# Calculate texture coordinates for center crop
if img_aspect > target_aspect:
# Image is wider - crop horizontally
scale = target_aspect / img_aspect
tx_offset = (1.0 - scale) / 2.0
tx_min_base = tx_offset
tx_max_base = 1.0 - tx_offset
ty_min_base = 0.0
ty_max_base = 1.0
else:
# Image is taller - crop vertically
scale = img_aspect / target_aspect
ty_offset = (1.0 - scale) / 2.0
tx_min_base = 0.0
tx_max_base = 1.0
ty_min_base = ty_offset
ty_max_base = 1.0 - ty_offset
# Apply additional crop from crop_info (for spanning elements)
tx_range = tx_max_base - tx_min_base
ty_range = ty_max_base - ty_min_base
tx_min = tx_min_base + crop_x_min * tx_range
tx_max = tx_min_base + crop_x_max * tx_range
ty_min = ty_min_base + crop_y_min * ty_range
ty_max = ty_min_base + crop_y_max * ty_range
# Enable texturing and draw with crop # Enable texturing and draw with crop
glEnable(GL_TEXTURE_2D) glEnable(GL_TEXTURE_2D)
@ -334,16 +314,8 @@ class ImageData(BaseLayoutElement):
# Apply PIL-level rotation if needed # Apply PIL-level rotation if needed
if hasattr(self, 'pil_rotation_90') and self.pil_rotation_90 > 0: if hasattr(self, 'pil_rotation_90') and self.pil_rotation_90 > 0:
# Rotate counter-clockwise by 90° * pil_rotation_90 pil_image = apply_pil_rotation(pil_image, self.pil_rotation_90)
# PIL.Image.ROTATE_90 rotates counter-clockwise logger.debug(f"ImageData: Applied PIL rotation {self.pil_rotation_90 * 90}° to {self.image_path}")
angle = self.pil_rotation_90 * 90
if angle == 90:
pil_image = pil_image.transpose(Image.ROTATE_270) # CCW 90 = rotate right
elif angle == 180:
pil_image = pil_image.transpose(Image.ROTATE_180)
elif angle == 270:
pil_image = pil_image.transpose(Image.ROTATE_90) # CCW 270 = rotate left
logger.debug(f"ImageData: Applied PIL rotation {angle}° to {self.image_path}")
# Store the image for texture creation during next render() # Store the image for texture creation during next render()
# This avoids GL context issues when callback runs on wrong thread/timing # This avoids GL context issues when callback runs on wrong thread/timing
@ -370,11 +342,6 @@ class ImageData(BaseLayoutElement):
if not hasattr(self, '_pending_pil_image') or self._pending_pil_image is None: if not hasattr(self, '_pending_pil_image') or self._pending_pil_image is None:
return False return False
from OpenGL.GL import (glGenTextures, glBindTexture, glTexImage2D, GL_TEXTURE_2D,
glTexParameteri, GL_TEXTURE_MIN_FILTER, GL_TEXTURE_MAG_FILTER,
GL_LINEAR, GL_RGBA, GL_UNSIGNED_BYTE, glDeleteTextures,
glGetString, GL_VERSION)
try: try:
# Verify GL context is actually current before creating textures # Verify GL context is actually current before creating textures
# glGetString returns None if no context is active # glGetString returns None if no context is active
@ -454,8 +421,6 @@ class PlaceholderData(BaseLayoutElement):
def render(self): def render(self):
"""Render the placeholder using OpenGL""" """Render the placeholder using OpenGL"""
from OpenGL.GL import (glBegin, glEnd, glVertex2f, glColor3f, GL_QUADS, GL_LINE_LOOP, glLineStipple,
glEnable, glDisable, GL_LINE_STIPPLE, glPushMatrix, glPopMatrix, glTranslatef, glRotatef)
x, y = self.position x, y = self.position
w, h = self.size w, h = self.size
@ -535,10 +500,6 @@ class TextBoxData(BaseLayoutElement):
def render(self): def render(self):
"""Render the text box using OpenGL""" """Render the text box using OpenGL"""
from OpenGL.GL import (glBegin, glEnd, glVertex2f, glColor3f, glColor4f, GL_QUADS, GL_LINE_LOOP,
glEnable, glDisable, GL_BLEND, glBlendFunc, GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA,
glPushMatrix, glPopMatrix, glTranslatef, glRotatef)
x, y = self.position x, y = self.position
w, h = self.size w, h = self.size
@ -623,9 +584,6 @@ class GhostPageData(BaseLayoutElement):
def render(self): def render(self):
"""Render the ghost page with 'Add Page' button in page-local coordinates""" """Render the ghost page with 'Add Page' button in page-local coordinates"""
from OpenGL.GL import (glBegin, glEnd, glVertex2f, glColor3f, glColor4f, GL_QUADS, GL_LINE_LOOP,
glEnable, glDisable, GL_BLEND, glBlendFunc, GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA,
glLineStipple, GL_LINE_STIPPLE)
# Render at page origin (0,0) in page-local coordinates # Render at page origin (0,0) in page-local coordinates
# PageRenderer will handle transformation to screen coordinates # PageRenderer will handle transformation to screen coordinates

View File

@ -5,6 +5,12 @@ Page layout and template system for pyPhotoAlbum
from typing import List, Dict, Any, Optional, Tuple from typing import List, Dict, Any, Optional, Tuple
from pyPhotoAlbum.models import BaseLayoutElement, ImageData, PlaceholderData, TextBoxData from pyPhotoAlbum.models import BaseLayoutElement, ImageData, PlaceholderData, TextBoxData
from pyPhotoAlbum.snapping import SnappingSystem from pyPhotoAlbum.snapping import SnappingSystem
from pyPhotoAlbum.gl_imports import (
glBegin, glEnd, glVertex2f, glColor3f, glColor4f,
GL_QUADS, GL_LINE_LOOP, GL_LINES, glLineWidth,
glEnable, glDisable, GL_DEPTH_TEST, GL_BLEND,
glBlendFunc, GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA,
)
class PageLayout: class PageLayout:
"""Class to manage page layout and templates""" """Class to manage page layout and templates"""
@ -50,8 +56,6 @@ class PageLayout:
dpi: Working DPI for converting mm to pixels dpi: Working DPI for converting mm to pixels
project: Optional project instance for global snapping settings 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
# Disable depth testing for 2D rendering # Disable depth testing for 2D rendering
glDisable(GL_DEPTH_TEST) glDisable(GL_DEPTH_TEST)
@ -143,10 +147,6 @@ class PageLayout:
def _render_snap_lines(self, dpi: int, page_x: float, page_y: float, project=None): 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,
glVertex2f, GL_LINES, glEnable, glDisable, GL_BLEND,
glBlendFunc, GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
# Use project settings if available, otherwise fall back to local snapping_system # Use project settings if available, otherwise fall back to local snapping_system
if project: if project:
# Use project-level global settings # Use project-level global settings

View File

@ -11,7 +11,7 @@ Coordinate Systems:
""" """
from typing import Tuple, Optional from typing import Tuple, Optional
from OpenGL.GL import glPushMatrix, glPopMatrix, glScalef, glTranslatef from pyPhotoAlbum.gl_imports import glPushMatrix, glPopMatrix, glScalef, glTranslatef
class PageRenderer: class PageRenderer:

View File

@ -14,6 +14,12 @@ from reportlab.lib.enums import TA_LEFT, TA_CENTER, TA_RIGHT
from PIL import Image from PIL import Image
import math import math
from pyPhotoAlbum.models import ImageData, TextBoxData, PlaceholderData from pyPhotoAlbum.models import ImageData, TextBoxData, PlaceholderData
from pyPhotoAlbum.image_utils import (
apply_pil_rotation,
convert_to_rgba,
calculate_center_crop_coords,
crop_image_to_coords,
)
@dataclass @dataclass
@ -429,73 +435,36 @@ class PDFExporter:
try: try:
# Load image using resolved path # Load image using resolved path
img = Image.open(image_full_path) img = Image.open(image_full_path)
img = img.convert('RGBA') img = convert_to_rgba(img)
# Apply PIL-level rotation if needed (same logic as _on_async_image_loaded in models.py) # Apply PIL-level rotation if needed
if hasattr(ctx.image_element, 'pil_rotation_90') and ctx.image_element.pil_rotation_90 > 0: if hasattr(ctx.image_element, 'pil_rotation_90') and ctx.image_element.pil_rotation_90 > 0:
# Rotate counter-clockwise by 90° * pil_rotation_90 img = apply_pil_rotation(img, ctx.image_element.pil_rotation_90)
# PIL.Image.ROTATE_90 rotates counter-clockwise
angle = ctx.image_element.pil_rotation_90 * 90
if angle == 90:
img = img.transpose(Image.ROTATE_270) # CCW 90 = rotate right
elif angle == 180:
img = img.transpose(Image.ROTATE_180)
elif angle == 270:
img = img.transpose(Image.ROTATE_90) # CCW 270 = rotate left
# Apply element's crop_info (from the element's own cropping) # Get element's crop_info and combine with split cropping if applicable
crop_x_min, crop_y_min, crop_x_max, crop_y_max = ctx.image_element.crop_info crop_x_min, crop_y_min, crop_x_max, crop_y_max = ctx.image_element.crop_info
# Combine with split cropping if applicable
final_crop_x_min = crop_x_min + (crop_x_max - crop_x_min) * ctx.crop_left final_crop_x_min = crop_x_min + (crop_x_max - crop_x_min) * ctx.crop_left
final_crop_x_max = crop_x_min + (crop_x_max - crop_x_min) * ctx.crop_right final_crop_x_max = crop_x_min + (crop_x_max - crop_x_min) * ctx.crop_right
# Calculate pixel crop coordinates # Determine target dimensions for aspect ratio
img_width, img_height = img.size # Use original dimensions for split images to prevent stretching
# Apply center crop first (matching the render logic in models.py)
img_aspect = img_width / img_height
# Use original dimensions for aspect ratio if provided (for split images)
# This prevents stretching when splitting an image across pages
if ctx.original_width_pt is not None and ctx.original_height_pt is not None: if ctx.original_width_pt is not None and ctx.original_height_pt is not None:
target_aspect = ctx.original_width_pt / ctx.original_height_pt target_width = ctx.original_width_pt
target_height = ctx.original_height_pt
else: else:
target_aspect = ctx.width_pt / ctx.height_pt target_width = ctx.width_pt
target_height = ctx.height_pt
if img_aspect > target_aspect: # Calculate center crop coordinates
# Image is wider - crop horizontally img_width, img_height = img.size
scale = target_aspect / img_aspect crop_coords = calculate_center_crop_coords(
tx_offset = (1.0 - scale) / 2.0 img_width, img_height,
tx_min_base = tx_offset target_width, target_height,
tx_max_base = 1.0 - tx_offset (final_crop_x_min, crop_y_min, final_crop_x_max, crop_y_max)
ty_min_base = 0.0 )
ty_max_base = 1.0
else:
# Image is taller - crop vertically
scale = img_aspect / target_aspect
ty_offset = (1.0 - scale) / 2.0
tx_min_base = 0.0
tx_max_base = 1.0
ty_min_base = ty_offset
ty_max_base = 1.0 - ty_offset
# Apply element crop_info range
tx_range = tx_max_base - tx_min_base
ty_range = ty_max_base - ty_min_base
tx_min = tx_min_base + final_crop_x_min * tx_range
tx_max = tx_min_base + final_crop_x_max * tx_range
ty_min = ty_min_base + crop_y_min * ty_range
ty_max = ty_min_base + crop_y_max * ty_range
# Convert to pixel coordinates
crop_left_px = int(tx_min * img_width)
crop_right_px = int(tx_max * img_width)
crop_top_px = int(ty_min * img_height)
crop_bottom_px = int(ty_max * img_height)
# Crop the image # Crop the image
cropped_img = img.crop((crop_left_px, crop_top_px, crop_right_px, crop_bottom_px)) cropped_img = crop_image_to_coords(img, crop_coords)
# Downsample image to target resolution based on export DPI # Downsample image to target resolution based on export DPI
# This prevents embedding huge images and reduces PDF file size # This prevents embedding huge images and reduces PDF file size

View File

@ -3,6 +3,7 @@ Snapping system for pyPhotoAlbum
Provides grid snapping, edge snapping, and custom guide snapping Provides grid snapping, edge snapping, and custom guide snapping
""" """
import math
from typing import List, Tuple, Optional from typing import List, Tuple, Optional
from dataclasses import dataclass from dataclasses import dataclass
@ -93,8 +94,6 @@ class SnappingSystem:
Returns: Returns:
Snapped position (x, y) in pixels Snapped position (x, y) in pixels
""" """
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

View File

@ -2,8 +2,10 @@
Version management and migration system for pyPhotoAlbum projects Version management and migration system for pyPhotoAlbum projects
""" """
from typing import Dict, Any, Optional, Callable, List
import os import os
import uuid
from datetime import datetime, timezone
from typing import Dict, Any, Optional, Callable, List
# Current data version - increment when making breaking changes to data format # Current data version - increment when making breaking changes to data format
@ -193,9 +195,6 @@ def migrate_2_0_to_3_0(data: Dict[str, Any]) -> Dict[str, Any]:
- Add project_id to project - Add project_id to project
- Add deletion tracking (deleted, deleted_at) to pages and elements - Add deletion tracking (deleted, deleted_at) to pages and elements
""" """
import uuid
from datetime import datetime, timezone
print("Migration 2.0 → 3.0: Adding UUIDs, timestamps, and project_id") print("Migration 2.0 → 3.0: Adding UUIDs, timestamps, and project_id")
# Get current timestamp for migration # Get current timestamp for migration

View File

@ -8,6 +8,7 @@ from PyQt6.QtCore import QMimeData, QUrl, QPoint
from PyQt6.QtGui import QDragEnterEvent, QDragMoveEvent, QDropEvent from PyQt6.QtGui import QDragEnterEvent, QDragMoveEvent, QDropEvent
from PyQt6.QtOpenGLWidgets import QOpenGLWidget from PyQt6.QtOpenGLWidgets import QOpenGLWidget
from pyPhotoAlbum.mixins.asset_drop import AssetDropMixin from pyPhotoAlbum.mixins.asset_drop import AssetDropMixin
from pyPhotoAlbum.mixins.asset_path import AssetPathMixin
from pyPhotoAlbum.mixins.viewport import ViewportMixin from pyPhotoAlbum.mixins.viewport import ViewportMixin
from pyPhotoAlbum.mixins.page_navigation import PageNavigationMixin from pyPhotoAlbum.mixins.page_navigation import PageNavigationMixin
from pyPhotoAlbum.project import Project, Page from pyPhotoAlbum.project import Project, Page
@ -16,14 +17,21 @@ from pyPhotoAlbum.models import ImageData
# Create test widget combining necessary mixins # Create test widget combining necessary mixins
class TestAssetDropWidget(AssetDropMixin, PageNavigationMixin, ViewportMixin, QOpenGLWidget): class TestAssetDropWidget(AssetDropMixin, AssetPathMixin, PageNavigationMixin, ViewportMixin, QOpenGLWidget):
"""Test widget combining asset drop, page navigation, and viewport mixins""" """Test widget combining asset drop, asset path, page navigation, and viewport mixins"""
def _get_element_at(self, x, y): def _get_element_at(self, x, y):
"""Mock implementation for testing""" """Mock implementation for testing"""
# Will be overridden in tests that need it # Will be overridden in tests that need it
return None return None
def _get_project_folder(self):
"""Override to access project via window mock"""
main_window = self.window()
if hasattr(main_window, 'project') and main_window.project:
return getattr(main_window.project, 'folder_path', None)
return None
class TestAssetDropInitialization: class TestAssetDropInitialization:
"""Test AssetDropMixin initialization""" """Test AssetDropMixin initialization"""

View File

@ -6,6 +6,7 @@ import pytest
from unittest.mock import Mock, MagicMock, patch, mock_open from unittest.mock import Mock, MagicMock, patch, mock_open
from PyQt6.QtWidgets import QMainWindow, QFileDialog from PyQt6.QtWidgets import QMainWindow, QFileDialog
from pyPhotoAlbum.mixins.operations.element_ops import ElementOperationsMixin from pyPhotoAlbum.mixins.operations.element_ops import ElementOperationsMixin
from pyPhotoAlbum.mixins.asset_path import AssetPathMixin
from pyPhotoAlbum.models import ImageData, TextBoxData, PlaceholderData from pyPhotoAlbum.models import ImageData, TextBoxData, PlaceholderData
from pyPhotoAlbum.project import Project, Page from pyPhotoAlbum.project import Project, Page
from pyPhotoAlbum.page_layout import PageLayout from pyPhotoAlbum.page_layout import PageLayout
@ -13,7 +14,7 @@ from pyPhotoAlbum.commands import CommandHistory
# Create test window with ElementOperationsMixin # Create test window with ElementOperationsMixin
class TestElementWindow(ElementOperationsMixin, QMainWindow): class TestElementWindow(ElementOperationsMixin, AssetPathMixin, QMainWindow):
"""Test window with element operations mixin""" """Test window with element operations mixin"""
def __init__(self): def __init__(self):
@ -23,10 +24,10 @@ class TestElementWindow(ElementOperationsMixin, QMainWindow):
self.gl_widget = Mock() self.gl_widget = Mock()
# Mock project # Mock project
self.project = Mock() self._project = Mock()
self.project.history = CommandHistory() self._project.history = CommandHistory()
self.project.asset_manager = Mock() self._project.asset_manager = Mock()
self.project.folder_path = "/tmp/test_project" self._project.folder_path = "/tmp/test_project"
# Track method calls # Track method calls
self._update_view_called = False self._update_view_called = False
@ -35,6 +36,10 @@ class TestElementWindow(ElementOperationsMixin, QMainWindow):
self._require_page_called = False self._require_page_called = False
self._current_page_index = 0 self._current_page_index = 0
@property
def project(self):
return self._project
def require_page(self): def require_page(self):
"""Track require_page calls""" """Track require_page calls"""
self._require_page_called = True self._require_page_called = True